From 71f48e7fe997a9228dc81cd673470133a05a3b79 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 00:43:18 -0500 Subject: [PATCH 01/23] Draft support for Slash Commands Completely replaces message reading and the old style of commands. Confine your bots to specific channels to reduce clutter in bot channels. Be sure any sudo users have access to bypass slow mode so they can use the sudo commands; not sure if it is a great way to do that, but idk. --- .../Console/IConsoleConnectionAsync.cs | 7 +- .../Console/IConsoleConnectionSync.cs | 4 +- .../Switch/ISwitchConnectionAsync.cs | 42 +-- .../Switch/SwitchConfigureParameter.cs | 3 +- .../Connection/Switch/USB/SwitchUSB.cs | 183 ++++++----- .../Connection/Switch/USB/SwitchUSBAsync.cs | 78 +++-- .../Connection/Switch/USB/SwitchUSBSync.cs | 9 +- .../Switch/Wireless/SwitchSocketAsync.cs | 58 ++-- .../Switch/Wireless/SwitchSocketSync.cs | 19 +- SysBot.Base/Control/BotSource.cs | 4 +- SysBot.Base/Control/RoutineExecutor.cs | 2 +- SysBot.Base/Control/SwitchRoutineExecutor.cs | 11 +- SysBot.Base/SysBot.Base.csproj | 4 +- SysBot.Base/SysBot.Base.csproj.DotSettings | 2 + SysBot.Base/Util/EchoUtil.cs | 8 +- SysBot.Base/Util/Logging/LogUtil.cs | 9 +- SysBot.Base/Util/RecordUtil.cs | 2 +- SysBot.Base/Util/SwitchCommand.cs | 4 +- .../PokeBotRunnerImpl.cs | 6 +- SysBot.Pokemon.ConsoleApp/Program.cs | 6 +- .../Commands/Bots/CloneModule.cs | 60 ---- .../Commands/Bots/DumpModule.cs | 60 ---- .../Commands/Bots/QueueModule.cs | 146 --------- .../Commands/Bots/RemoteControlModule.cs | 145 --------- .../Commands/Bots/SeedCheckModule.cs | 88 ------ .../Commands/Bots/TradeModule.cs | 246 -------------- .../Commands/Extra/BatchEditingModule.cs | 75 +++-- .../Commands/Extra/LegalityCheckModule.cs | 57 ++-- .../Commands/Extra/LegalizerModule.cs | 41 ++- .../Commands/General/HelloModule.cs | 19 +- .../Commands/General/HelpModule.cs | 128 ++------ .../Commands/General/InfoModule.cs | 68 ++-- .../Commands/General/PingModule.cs | 14 - .../Commands/Management/BotModule.cs | 124 -------- .../Commands/Management/EchoModule.cs | 119 ------- .../Commands/Management/EncounterModule.cs | 27 -- .../Commands/Management/HubModule.cs | 58 ++-- .../Commands/Management/LogModule.cs | 104 ------ .../Commands/Management/OwnerModule.cs | 120 +++---- .../Commands/Management/PoolModule.cs | 59 ++-- .../Commands/Management/SudoModule.cs | 205 ------------ .../Commands/Management/TradeStartModule.cs | 124 -------- .../Commands/SlashModuleBase.cs | 130 ++++++++ .../Commands/Sudo/BotModule.cs | 123 +++++++ .../Commands/Sudo/EchoModule.cs | 107 +++++++ .../Commands/Sudo/EncounterModule.cs | 27 ++ .../Commands/Sudo/LogModule.cs | 96 ++++++ .../Commands/Sudo/RemoteControlModule.cs | 163 ++++++++++ .../Commands/Sudo/SudoModule.cs | 176 +++++++++++ .../Commands/Sudo/SudoModuleBase.cs | 9 + .../Commands/Sudo/TradeStartModule.cs | 111 +++++++ .../Commands/Trade/CloneModule.cs | 43 +++ .../Commands/Trade/DumpModule.cs | 49 +++ .../Commands/Trade/QueueModule.cs | 95 ++++++ .../Commands/Trade/SeedCheckModule.cs | 60 ++++ .../Commands/Trade/TradeModule.cs | 219 +++++++++++++ .../Helpers/AutoLegalityExtensionsDiscord.cs | 146 +++++---- .../Helpers/ChannelAction.cs | 10 +- .../Helpers/ChannelLogger.cs | 11 +- .../Helpers/DiscordManager.cs | 21 +- .../Helpers/DiscordTradeNotifier.cs | 78 +++-- SysBot.Pokemon.Discord/Helpers/NetUtil.cs | 13 +- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 179 ++++++----- .../Helpers/RequireQueueRoleAttribute.cs | 41 --- .../Helpers/RequireRoleAccessAttribute.cs | 37 --- .../Helpers/RequireRoleAttribute.cs | 31 -- .../Helpers/RequireSudoAttribute.cs | 28 -- .../Helpers/ReusableActions.cs | 108 +++---- .../SysBot.Pokemon.Discord.csproj | 6 +- .../SysBot.Pokemon.Discord.csproj.DotSettings | 2 + SysBot.Pokemon.Discord/SysCord.cs | 299 +++++++++--------- SysBot.Pokemon.Discord/SysCordSettings.cs | 19 ++ .../Helpers/TwitchCommandsHelper.cs | 48 +-- SysBot.Pokemon.Twitch/Helpers/TwitchQueue.cs | 8 +- .../Helpers/TwitchTradeNotifier.cs | 47 +-- SysBot.Pokemon.Twitch/TwitchBot.cs | 174 +++++----- SysBot.Pokemon.WinForms/ConfigLoader.cs | 2 +- .../Controls/BotController.cs | 18 +- SysBot.Pokemon.WinForms/InitUtil.cs | 18 +- SysBot.Pokemon.WinForms/Main.cs | 9 +- SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs | 6 +- .../SysBot.Pokemon.YouTube.csproj | 8 +- SysBot.Pokemon.YouTube/YouTubeBot.cs | 54 ++-- SysBot.Pokemon.Z3/Z3/Z3Search.cs | 2 +- SysBot.Pokemon.Z3/Z3/Z3SeedSearchHandler.cs | 20 +- SysBot.Pokemon/Actions/PokeRoutineExecutor.cs | 12 +- .../Actions/PokeRoutineExecutorBase.cs | 4 +- SysBot.Pokemon/Actions/PokeRoutineType.cs | 5 +- SysBot.Pokemon/BDSP/BotFactory8BS.cs | 2 +- .../BotRemoteControl/RemoteControlBotBS.cs | 2 +- .../BDSP/BotTrade/PokeTradeBotBS.cs | 41 ++- .../BDSP/BotTrade/TradePartnerBS.cs | 11 +- SysBot.Pokemon/BDSP/PokeRoutineExecutor8BS.cs | 20 +- SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs | 6 +- SysBot.Pokemon/Helpers/RemoteControlAccess.cs | 15 +- SysBot.Pokemon/LA/BotFactory8LA.cs | 2 +- .../LA/BotRemoteControl/RemoteControlBotLA.cs | 2 +- SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs | 62 ++-- SysBot.Pokemon/LA/BotTrade/TradePartnerLA.cs | 7 +- SysBot.Pokemon/LA/PokeRoutineExecutor8LA.cs | 20 +- SysBot.Pokemon/LZA/BotFactory9LZA.cs | 2 +- .../BotRemoteControl/RemoteControlBotLZA.cs | 2 +- .../LZA/BotTrade/PokeTradeBotLZA.cs | 62 ++-- .../LZA/BotTrade/TradePartnerLZA.cs | 7 +- SysBot.Pokemon/LZA/PokeRoutineExecutor9LZA.cs | 15 +- SysBot.Pokemon/Queues/QueueCheckResult.cs | 2 +- SysBot.Pokemon/Queues/TradeQueueInfo.cs | 51 +-- SysBot.Pokemon/Queues/TradeQueueManager.cs | 11 +- SysBot.Pokemon/SV/BotFactory9SV.cs | 2 +- .../SV/BotRemoteControl/RemoteControlBotSV.cs | 2 +- SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs | 53 ++-- SysBot.Pokemon/SV/BotTrade/TradePartnerSV.cs | 2 +- SysBot.Pokemon/SV/PokeRoutineExecutor9SV.cs | 15 +- .../SWSH/BotEncounter/EncounterBotDogSWSH.cs | 2 +- .../SWSH/BotEncounter/EncounterBotEggSWSH.cs | 2 +- .../EncounterBotFossilSWSH.cs | 4 +- .../EncounterBotFossil/FossilCount.cs | 2 +- .../SWSH/BotEncounter/EncounterBotLineSWSH.cs | 2 +- .../BotEncounter/EncounterBotResetSWSH.cs | 4 +- .../SWSH/BotEncounter/EncounterBotSWSH.cs | 24 +- .../SWSH/BotEncounter/EncounterSettings.cs | 2 +- SysBot.Pokemon/SWSH/BotFactory8SWSH.cs | 2 +- SysBot.Pokemon/SWSH/BotRaid/RaidBotSWSH.cs | 9 +- SysBot.Pokemon/SWSH/BotRaid/RaidSettings.cs | 4 +- .../BotRemoteControl/RemoteControlBotSWSH.cs | 2 +- .../SWSH/BotTrade/PokeTradeBotSWSH.cs | 79 ++--- .../SWSH/PokeRoutineExecutor8SWSH.cs | 19 +- .../Settings/DistributionSettings.cs | 4 +- .../Settings/FavoredPrioritySettings.cs | 31 +- .../Settings/Integrations/DiscordSettings.cs | 11 +- .../Settings/Integrations/StreamSettings.cs | 13 +- .../Settings/Integrations/TwitchSettings.cs | 3 +- .../Settings/Integrations/YouTubeSettings.cs | 3 +- SysBot.Pokemon/Settings/LegalitySettings.cs | 6 +- .../Settings/StopConditionSettings.cs | 4 +- SysBot.Pokemon/Settings/TradeSettings.cs | 4 +- .../Structures/ConcurrentPriorityQueue.cs | 9 +- .../Structures/ISeedSearchHandler.cs | 10 +- .../Structures/Ledy/LedyDistributor.cs | 2 +- SysBot.Pokemon/Structures/Ledy/PokemonPool.cs | 16 +- SysBot.Pokemon/Structures/PokeBotRunner.cs | 10 +- SysBot.Pokemon/Structures/PokeBotState.cs | 2 +- SysBot.Pokemon/Structures/ProgramConfig.cs | 2 +- SysBot.Pokemon/Structures/QueueMonitor.cs | 4 +- .../SysBot.Pokemon.csproj.DotSettings | 2 + SysBot.Pokemon/TradeHub/IPokeTradeNotifier.cs | 24 +- SysBot.Pokemon/TradeHub/PokeTradeDetail.cs | 61 ++-- SysBot.Pokemon/TradeHub/PokeTradeHub.cs | 4 +- .../TradeHub/PokeTradeLogNotifier.cs | 32 +- SysBot.Pokemon/TradeHub/PokeTradeQueue.cs | 4 +- SysBot.Pokemon/TradeHub/PokeTradeResult.cs | 5 +- .../TradeHub/PokeTradeTrainerInfo.cs | 2 +- .../TradeHub/RequestSignificance.cs | 12 +- SysBot.Pokemon/TradeHub/TradeEntry.cs | 2 +- SysBot.Pokemon/Util/LoadUtil.cs | 2 +- SysBot.Pokemon/Util/PokeTradeBotUtil.cs | 8 +- SysBot.Pokemon/Util/SeedSearchResult.cs | 19 +- SysBot.Pokemon/Util/SeedSearchUtil.cs | 2 +- SysBot.Pokemon/Util/TradeUtil.cs | 6 +- SysBot.Tests/GenerateTests.cs | 2 +- SysBot.Tests/MiscTests.cs | 6 +- SysBot.Tests/QueueTests.cs | 16 +- SysBot.Tests/SysBot.Tests.csproj | 4 +- 163 files changed, 3081 insertions(+), 3259 deletions(-) create mode 100644 SysBot.Base/SysBot.Base.csproj.DotSettings delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/CloneModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/DumpModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/QueueModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/RemoteControlModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/SeedCheckModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Bots/TradeModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/General/PingModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/BotModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/EchoModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/EncounterModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/LogModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/SudoModule.cs delete mode 100644 SysBot.Pokemon.Discord/Commands/Management/TradeStartModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs delete mode 100644 SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs delete mode 100644 SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs delete mode 100644 SysBot.Pokemon.Discord/Helpers/RequireRoleAttribute.cs delete mode 100644 SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs create mode 100644 SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj.DotSettings create mode 100644 SysBot.Pokemon.Discord/SysCordSettings.cs create mode 100644 SysBot.Pokemon/SysBot.Pokemon.csproj.DotSettings diff --git a/SysBot.Base/Connection/Console/IConsoleConnectionAsync.cs b/SysBot.Base/Connection/Console/IConsoleConnectionAsync.cs index 7f93109e3..55b97c99b 100644 --- a/SysBot.Base/Connection/Console/IConsoleConnectionAsync.cs +++ b/SysBot.Base/Connection/Console/IConsoleConnectionAsync.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; @@ -8,8 +9,8 @@ namespace SysBot.Base; /// public interface IConsoleConnectionAsync : IConsoleConnection { - ValueTask SendAsync(byte[] buffer, CancellationToken token); + ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken token = default); - Task ReadBytesAsync(uint offset, int length, CancellationToken token); - Task WriteBytesAsync(byte[] data, uint offset, CancellationToken token); + Task ReadBytesAsync(uint offset, int length, CancellationToken token = default); + Task WriteBytesAsync(ReadOnlyMemory data, uint offset, CancellationToken token = default); } diff --git a/SysBot.Base/Connection/Console/IConsoleConnectionSync.cs b/SysBot.Base/Connection/Console/IConsoleConnectionSync.cs index 615fa9c06..ba779f809 100644 --- a/SysBot.Base/Connection/Console/IConsoleConnectionSync.cs +++ b/SysBot.Base/Connection/Console/IConsoleConnectionSync.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace SysBot.Base; @@ -7,7 +7,7 @@ namespace SysBot.Base; /// public interface IConsoleConnectionSync : IConsoleConnection { - int Send(byte[] buffer); + int Send(ReadOnlySpan buffer); byte[] ReadBytes(uint offset, int length); void WriteBytes(ReadOnlySpan data, uint offset); diff --git a/SysBot.Base/Connection/Switch/ISwitchConnectionAsync.cs b/SysBot.Base/Connection/Switch/ISwitchConnectionAsync.cs index 5e1bfe286..36676f4c9 100644 --- a/SysBot.Base/Connection/Switch/ISwitchConnectionAsync.cs +++ b/SysBot.Base/Connection/Switch/ISwitchConnectionAsync.cs @@ -10,30 +10,30 @@ namespace SysBot.Base; /// public interface ISwitchConnectionAsync : IConsoleConnectionAsync { - Task GetMainNsoBaseAsync(CancellationToken token); - Task GetHeapBaseAsync(CancellationToken token); - Task GetTitleID(CancellationToken token); - Task GetBotbaseVersion(CancellationToken token); - Task GetGameInfo(string info, CancellationToken token); - Task IsProgramRunning(ulong pid, CancellationToken token); + Task GetMainNsoBaseAsync(CancellationToken token = default); + Task GetHeapBaseAsync(CancellationToken token = default); + Task GetTitleID(CancellationToken token = default); + Task GetBotbaseVersion(CancellationToken token = default); + Task GetGameInfo(string info, CancellationToken token = default); + Task IsProgramRunning(ulong pid, CancellationToken token = default); - Task ReadBytesMainAsync(ulong offset, int length, CancellationToken token); - Task ReadBytesAbsoluteAsync(ulong offset, int length, CancellationToken token); + Task ReadBytesMainAsync(ulong offset, int length, CancellationToken token = default); + Task ReadBytesAbsoluteAsync(ulong offset, int length, CancellationToken token = default); - Task ReadBytesMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token); - Task ReadBytesAbsoluteMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token); - Task ReadBytesMainMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token); + Task ReadBytesMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token = default); + Task ReadBytesAbsoluteMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token = default); + Task ReadBytesMainMultiAsync(IReadOnlyDictionary offsetSize, CancellationToken token = default); - Task WriteBytesMainAsync(Span data, ulong offset, CancellationToken token); - Task WriteBytesAbsoluteAsync(Span data, ulong offset, CancellationToken token); + Task WriteBytesMainAsync(ReadOnlyMemory data, ulong offset, CancellationToken token = default); + Task WriteBytesAbsoluteAsync(ReadOnlyMemory data, ulong offset, CancellationToken token = default); - Task ReadRaw(byte[] command, int length, CancellationToken token); - Task SendRaw(byte[] command, CancellationToken token); + Task ReadRaw(ReadOnlyMemory command, int length, CancellationToken token = default); + Task SendRaw(ReadOnlyMemory command, CancellationToken token = default); - Task PointerPeek(int size, IEnumerable jumps, CancellationToken token); - Task PointerPoke(byte[] data, IEnumerable jumps, CancellationToken token); - Task PointerAll(IEnumerable jumps, CancellationToken token); - Task PointerRelative(IEnumerable jumps, CancellationToken token); - Task<(bool Success, T Value)> TryReadMain(ulong offset, CancellationToken token) where T : unmanaged; - Task<(bool Success, T Value)> TryReadAbsolute(ulong offset, CancellationToken token) where T : unmanaged; + Task PointerPeek(int size, IEnumerable jumps, CancellationToken token = default); + Task PointerPoke(ReadOnlyMemory data, IEnumerable jumps, CancellationToken token = default); + Task PointerAll(IEnumerable jumps, CancellationToken token = default); + Task PointerRelative(IEnumerable jumps, CancellationToken token = default); + Task<(bool Success, T Value)> TryReadMain(ulong offset, CancellationToken token = default) where T : unmanaged; + Task<(bool Success, T Value)> TryReadAbsolute(ulong offset, CancellationToken token = default) where T : unmanaged; } diff --git a/SysBot.Base/Connection/Switch/SwitchConfigureParameter.cs b/SysBot.Base/Connection/Switch/SwitchConfigureParameter.cs index 4969d3cd3..59ad74cb9 100644 --- a/SysBot.Base/Connection/Switch/SwitchConfigureParameter.cs +++ b/SysBot.Base/Connection/Switch/SwitchConfigureParameter.cs @@ -1,4 +1,5 @@ -namespace SysBot.Base; +// ReSharper disable InconsistentNaming - match botbase naming +namespace SysBot.Base; /// /// Valid configuration request types for the Nintendo Switch to be sent as a . diff --git a/SysBot.Base/Connection/Switch/USB/SwitchUSB.cs b/SysBot.Base/Connection/Switch/USB/SwitchUSB.cs index a63e4b76b..a61fe10a3 100644 --- a/SysBot.Base/Connection/Switch/USB/SwitchUSB.cs +++ b/SysBot.Base/Connection/Switch/USB/SwitchUSB.cs @@ -1,9 +1,11 @@ -using LibUsbDotNet; -using LibUsbDotNet.Main; using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading; +using LibUsbDotNet; +using LibUsbDotNet.LibUsb; +using LibUsbDotNet.Main; +using static System.Buffers.Binary.BinaryPrimitives; namespace SysBot.Base; @@ -15,7 +17,7 @@ public abstract class SwitchUSB : IConsoleConnection public string Name { get; } public string Label { get; set; } public bool Connected { get; protected set; } - private readonly int Port; + private int Port { get; } protected SwitchUSB(int port) { @@ -27,16 +29,16 @@ protected SwitchUSB(int port) public void LogInfo(string message) => LogUtil.LogInfo(message, Label); public void LogError(string message) => LogUtil.LogError(message, Label); - private UsbDevice? SwDevice; - private UsbEndpointReader? reader; - private UsbEndpointWriter? writer; + private IUsbDevice? _device; + private UsbEndpointReader? _reader; + private UsbEndpointWriter? _writer; public int MaximumTransferSize { get; set; } = 0x1C0; public int BaseDelay { get; set; } = 1; public int DelayFactor { get; set; } = 1000; private readonly Lock _sync = new(); - private static readonly Lock _registry = new(); + private static readonly UsbContext UsbContext = new(); public void Reset() { @@ -46,16 +48,10 @@ public void Reset() public void Connect() { - SwDevice = TryFindUSB() ?? throw new Exception("USB device not found."); - if (SwDevice is not IUsbDevice usb) - throw new Exception("Device is using a WinUSB driver. Use libusbK and create a filter."); - + _device = TryFind() ?? throw new Exception("USB device not found."); + var usb = _device ?? throw new Exception("USB device not found."); lock (_sync) { - // UsbRegistryInfo is only supported on Windows. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !usb.UsbRegistryInfo!.IsAlive) - usb.ResetDevice(); - if (usb.IsOpen) usb.Close(); usb.Open(); @@ -68,33 +64,36 @@ public void Connect() usb.ClaimInterface(0); } - reader = SwDevice.OpenEndpointReader(ReadEndpointID.Ep01); - writer = SwDevice.OpenEndpointWriter(WriteEndpointID.Ep01); + _reader = usb.OpenEndpointReader(ReadEndpointID.Ep01); + _writer = usb.OpenEndpointWriter(WriteEndpointID.Ep01); } } - private UsbDevice? TryFindUSB() + private IUsbDevice? TryFind() { - lock (_registry) + lock (UsbContext) { - foreach (var device in UsbDevice.AllLibUsbDevices) + var finder = new UsbDeviceFinder { - if (device is not UsbRegistry ur) - continue; - if (ur.Vid != 0x057E) - continue; - if (ur.Pid != 0x3000) - continue; + Vid = 0x057E, + Pid = 0x3000, + }; - // Only Windows supports reading the port number from the registry. - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + using var devices = UsbContext.FindAll(finder); + foreach (var device in devices) + { + // LibUsbDotNet 3.x no longer exposes the Windows registry information used by the old API. + // LocationId.PortNumbers provides the USB topology instead; the final port number is the physical port number for the device. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + (device.LocationId.PortNumbers.Count == 0 || + device.LocationId.PortNumbers[^1] != Port)) { - ur.DeviceProperties.TryGetValue("Address", out var addr); - if (Port.ToString() != addr?.ToString()) - continue; + continue; } - return ur.Device; + // FindAll returns devices owned by the temporary collection. + // Clone the selected device so it remains valid after the collection is disposed. + return device.Clone(); } } return null; @@ -104,29 +103,28 @@ public void Disconnect() { lock (_sync) { - if (SwDevice is { IsOpen: true } x) + if (_device is { IsOpen: true } openDevice) { - if (x is IUsbDevice wholeUsbDevice) - { - if (!wholeUsbDevice.UsbRegistryInfo.IsAlive) - wholeUsbDevice.ResetDevice(); - wholeUsbDevice.ReleaseInterface(0); - } - x.Close(); + openDevice.ReleaseInterface(0); + openDevice.Close(); } - reader?.Dispose(); - writer?.Dispose(); + // LibUsbDotNet 3.x endpoint readers/writers do not expose Dispose(). + // Closing and disposing the device releases the underlying handle. + _reader = null; + _writer = null; + _device?.Dispose(); + _device = null; } } - public int Send(byte[] buffer) + public int Send(ReadOnlySpan buffer) { lock (_sync) return SendInternal(buffer); } - public int Read(byte[] buffer) + public int Read(Span buffer) { lock (_sync) return ReadInternal(buffer); @@ -155,43 +153,51 @@ protected byte[] ReadBulkUSB() { try { - if (reader == null) + if (_reader == null) throw new Exception("USB device not found or not connected."); // Let usb-botbase tell us the response size. - byte[] sizeOfReturn = new byte[4]; - var ec = reader.Read(sizeOfReturn, 5000, out int ret); - if (ec != ErrorCode.None && ret == 0) - throw new Exception(UsbDevice.LastErrorString); - - int size = BitConverter.ToInt32(sizeOfReturn, 0); - byte[] buffer = new byte[size]; + Span sizeOfReturn = stackalloc byte[4]; + var ec = _reader.Read(sizeOfReturn, 5000, out int ret); + if (ec != Error.Success && ret == 0) + throw new UsbException(ec); - // Loop until we have read everything. - int transfSize = 0; - while (transfSize < size) - { - Thread.Sleep(1); - ec = reader.Read(buffer, transfSize, Math.Min(reader.ReadBufferSize, size - transfSize), 5000, out int lenVal); - if (ec != ErrorCode.None) - throw new Exception(UsbDevice.LastErrorString); - - transfSize += lenVal; - } - return buffer; + int size = ReadInt32LittleEndian(sizeOfReturn); + return ReadResult(size, _reader); } catch (Exception ex) { // Win32Error is returned when the device aborts a transfer, which happens when, for example, readMem() is called with an invalid address. // As such, we ignore it to avoid log spam but still return a zero-buffer to avoid crashing the caller, and to maintain connection. - var lastError = UsbDevice.LastErrorNumber; - if (lastError is not (int)ErrorCode.Win32Error) + var error = ex is UsbException usbEx ? usbEx.ErrorCode : Error.Other; + if (error != Error.InvalidParam) Log($"{nameof(ReadBulkUSB)} failed: {ex.Message}"); return [0]; } } } + private static byte[] ReadResult(int size, UsbEndpointReader reader) + { + var buffer = new byte[size]; + ReadResult(size, reader, buffer); + return buffer; + } + + private static void ReadResult(int size, UsbEndpointReader reader, Span buffer) + { + // Loop until we have read everything. + int transfSize = 0; + while (transfSize < size) + { + Thread.Sleep(1); + var ec = reader.Read(buffer, transfSize, Math.Min(UsbEndpointReader.DefReadBufferSize, size - transfSize), 5000, out int lenVal); + if (ec != Error.Success) + throw new UsbException(ec); + transfSize += lenVal; + } + } + protected void Write(ICommandBuilder b, ReadOnlySpan data, ulong offset) { if (data.Length > MaximumTransferSize) @@ -210,21 +216,21 @@ public void WriteSmall(ICommandBuilder b, ReadOnlySpan data, ulong offset) } } - private int ReadInternal(byte[] buffer) + private int ReadInternal(Span buffer) { try { - byte[] sizeOfReturn = new byte[4]; - if (reader == null) + if (_reader == null) throw new Exception("USB device not found or not connected."); - var ec = reader.Read(sizeOfReturn, 5000, out int ret); - if (ec != ErrorCode.None && ret == 0) - throw new Exception(UsbDevice.LastErrorString); + Span sizeOfReturn = stackalloc byte[4]; + var ec = _reader.Read(sizeOfReturn, 5000, out int ret); + if (ec != Error.Success && ret == 0) + throw new UsbException(ec); - ec = reader.Read(buffer, 5000, out var lenVal); - if (ec != ErrorCode.None) - throw new Exception(UsbDevice.LastErrorString); + ec = _reader.Read(buffer, 5000, out var lenVal); + if (ec != Error.Success) + throw new UsbException(ec); return lenVal; } @@ -232,28 +238,31 @@ private int ReadInternal(byte[] buffer) { // Win32Error is returned when the device aborts a transfer, which happens when, for example, readMem() is called with an invalid address. // As such, we ignore it to avoid log spam, log other exceptions, and return 0 to maintain connection. - var lastError = UsbDevice.LastErrorNumber; - if (lastError is not (int)ErrorCode.Win32Error) + var error = ex is UsbException usbEx ? usbEx.ErrorCode : Error.Other; + if (error != Error.InvalidParam) Log($"{nameof(ReadInternal)} failed: {ex.Message}"); return 0; } } - private int SendInternal(byte[] buffer) + private int SendInternal(ReadOnlySpan buffer) { try { - if (writer == null) + if (_writer == null) throw new Exception("USB device not found or not connected."); uint pack = (uint)buffer.Length + 2; - var ec = writer.Write(BitConverter.GetBytes(pack), 2000, out int ret); - if (ec != ErrorCode.None && ret == 0) - throw new Exception(UsbDevice.LastErrorString); + Span tmp = stackalloc byte[4]; + WriteUInt32LittleEndian(tmp, pack); + + var ec = _writer.Write(tmp, 2000, out int ret); + if (ec != Error.Success && ret == 0) + throw new UsbException(ec); - ec = writer.Write(buffer, 2000, out var l); - if (ec != ErrorCode.None) - throw new Exception(UsbDevice.LastErrorString); + ec = _writer.Write(buffer, 2000, out var l); + if (ec != Error.Success) + throw new UsbException(ec); return l; } @@ -261,8 +270,8 @@ private int SendInternal(byte[] buffer) { // Win32Error is returned when the device aborts a transfer, which happens when, for example, readMem() is called with an invalid address. // As such, we ignore it to avoid log spam, log other exceptions, and return 0 to maintain connection. - var lastError = UsbDevice.LastErrorNumber; - if (lastError is not (int)ErrorCode.Win32Error) + var error = ex is UsbException usbEx ? usbEx.ErrorCode : Error.Other; + if (error != Error.InvalidParam) Log($"{nameof(SendInternal)} failed: {ex.Message}"); return 0; } diff --git a/SysBot.Base/Connection/Switch/USB/SwitchUSBAsync.cs b/SysBot.Base/Connection/Switch/USB/SwitchUSBAsync.cs index cb4c1fa8a..78ff4b266 100644 --- a/SysBot.Base/Connection/Switch/USB/SwitchUSBAsync.cs +++ b/SysBot.Base/Connection/Switch/USB/SwitchUSBAsync.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchOffsetTypeUtil; namespace SysBot.Base; @@ -17,10 +18,10 @@ namespace SysBot.Base; /// public sealed class SwitchUSBAsync(int Port) : SwitchUSB(Port), ISwitchConnectionAsync { - public ValueTask SendAsync(byte[] data, CancellationToken token) + public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken token) { Debug.Assert(data.Length < MaximumTransferSize); - var res = Task.Run(() => Send(data), token); + var res = Task.Run(() => Send(data.Span), token); return new ValueTask(res); } @@ -32,18 +33,16 @@ public ValueTask SendAsync(byte[] data, CancellationToken token) public Task ReadBytesMainMultiAsync(IReadOnlyDictionary offsetSizes, CancellationToken token) => Task.Run(() => ReadMulti(Main, offsetSizes), token); public Task ReadBytesAbsoluteMultiAsync(IReadOnlyDictionary offsetSizes, CancellationToken token) => Task.Run(() => ReadMulti(Absolute, offsetSizes), token); - public Task WriteBytesAsync(byte[] data, uint offset, CancellationToken token) => Task.Run(() => Write(Heap, data, offset), token); + public Task WriteBytesAsync(ReadOnlyMemory data, uint offset, CancellationToken token) => Task.Run(() => Write(Heap, data.Span, offset), token); - public Task WriteBytesMainAsync(Span data, ulong offset, CancellationToken token) + public Task WriteBytesMainAsync(ReadOnlyMemory data, ulong offset, CancellationToken token) { - var arr = data.ToArray(); - return Task.Run(() => Write(Main, arr, offset), token); + return Task.Run(() => Write(Main, data.Span, offset), token); } - public Task WriteBytesAbsoluteAsync(Span data, ulong offset, CancellationToken token) + public Task WriteBytesAbsoluteAsync(ReadOnlyMemory data, ulong offset, CancellationToken token) { - var arr = data.ToArray(); - return Task.Run(() => Write(Absolute, arr, offset), token); + return Task.Run(() => Write(Absolute, data.Span, offset), token); } public Task GetMainNsoBaseAsync(CancellationToken token) @@ -51,13 +50,13 @@ public Task GetMainNsoBaseAsync(CancellationToken token) return Task.Run(() => { Send(SwitchCommand.GetMainNsoBase(false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length < sizeof(ulong)) { Log($"{nameof(GetMainNsoBaseAsync)}: Invalid response length"); return 0; } - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); }, token); } @@ -66,37 +65,37 @@ public Task GetHeapBaseAsync(CancellationToken token) return Task.Run(() => { Send(SwitchCommand.GetHeapBase(false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length < sizeof(ulong)) { Log($"{nameof(GetHeapBaseAsync)}: Invalid response length"); return 0; } - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); }, token); } public Task GetTitleID(CancellationToken token) { - return Task.Run(() => + return Task.Run(() => { Send(SwitchCommand.GetTitleID(false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length == 0) { Log($"{nameof(GetTitleID)}: Invalid response"); return string.Empty; } - return BitConverter.ToUInt64(baseBytes, 0).ToString("X16").Trim(); + return ReadUInt64LittleEndian(baseBytes).ToString("X16").Trim(); }, token); } public Task GetBotbaseVersion(CancellationToken token) { - return Task.Run(() => + return Task.Run(() => { Send(SwitchCommand.GetBotbaseVersion(false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length == 0) { Log($"{nameof(GetBotbaseVersion)}: Invalid response"); @@ -108,10 +107,10 @@ public Task GetBotbaseVersion(CancellationToken token) public Task GetGameInfo(string info, CancellationToken token) { - return Task.Run(() => + return Task.Run(() => { Send(SwitchCommand.GetGameInfo(info, false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length == 0) { Log($"{nameof(GetGameInfo)}: Invalid response"); @@ -123,10 +122,10 @@ public Task GetGameInfo(string info, CancellationToken token) public Task IsProgramRunning(ulong pid, CancellationToken token) { - return Task.Run(() => + return Task.Run(() => { Send(SwitchCommand.IsProgramRunning(pid, false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length == 0) { Log($"{nameof(IsProgramRunning)}: Invalid response"); @@ -136,19 +135,14 @@ public Task IsProgramRunning(ulong pid, CancellationToken token) }, token); } - public Task ReadRaw(byte[] command, int length, CancellationToken token) + public Task ReadRaw(ReadOnlyMemory command, int length, CancellationToken token) => Task.Run(() => { - return Task.Run(() => - { - Send(command); - return ReadBulkUSB(); - }, token); - } + Send(command.Span); + return ReadBulkUSB(); + }, token); - public Task SendRaw(byte[] command, CancellationToken token) - { - return Task.Run(() => Send(command), token); - } + public Task SendRaw(ReadOnlyMemory command, CancellationToken token) + => Task.Run(() => Send(command.Span), token); public Task PointerPeek(int size, IEnumerable jumps, CancellationToken token) { @@ -159,9 +153,9 @@ public Task PointerPeek(int size, IEnumerable jumps, CancellationT }, token); } - public Task PointerPoke(byte[] data, IEnumerable jumps, CancellationToken token) + public Task PointerPoke(ReadOnlyMemory data, IEnumerable jumps, CancellationToken token) { - return Task.Run(() => Send(SwitchCommand.PointerPoke(jumps, data, false)), token); + return Task.Run(() => Send(SwitchCommand.PointerPoke(jumps, data.Span, false)), token); } public Task PointerAll(IEnumerable jumps, CancellationToken token) @@ -169,13 +163,13 @@ public Task PointerAll(IEnumerable jumps, CancellationToken token) return Task.Run(() => { Send(SwitchCommand.PointerAll(jumps, false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length < sizeof(ulong)) { - Log($"{nameof(PointerAll)}: Invalid response length {baseBytes?.Length ?? 0}"); + Log($"{nameof(PointerAll)}: Invalid response length {baseBytes.Length}"); return 0; } - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); }, token); } @@ -184,13 +178,13 @@ public Task PointerRelative(IEnumerable jumps, CancellationToken to return Task.Run(() => { Send(SwitchCommand.PointerRelative(jumps, false)); - byte[] baseBytes = ReadBulkUSB(); + var baseBytes = ReadBulkUSB(); if (baseBytes.Length < sizeof(ulong)) { - Log($"{nameof(PointerRelative)}: Invalid response length {baseBytes?.Length ?? 0}"); + Log($"{nameof(PointerRelative)}: Invalid response length {baseBytes.Length}"); return 0; } - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); }, token); } @@ -206,7 +200,7 @@ public Task PointerRelative(IEnumerable jumps, CancellationToken to if (!BitConverter.IsLittleEndian) Array.Reverse(data, 0, size); - T value = MemoryMarshal.Read(data); + var value = MemoryMarshal.Read(data); return (true, value); } catch (Exception ex) diff --git a/SysBot.Base/Connection/Switch/USB/SwitchUSBSync.cs b/SysBot.Base/Connection/Switch/USB/SwitchUSBSync.cs index 8eb7465ea..b28ddc33d 100644 --- a/SysBot.Base/Connection/Switch/USB/SwitchUSBSync.cs +++ b/SysBot.Base/Connection/Switch/USB/SwitchUSBSync.cs @@ -1,5 +1,6 @@ -using System; - using static SysBot.Base.SwitchOffsetTypeUtil; +using System; +using static System.Buffers.Binary.BinaryPrimitives; +using static SysBot.Base.SwitchOffsetTypeUtil; namespace SysBot.Base; @@ -23,13 +24,13 @@ public ulong GetMainNsoBase() { Send(SwitchCommand.GetMainNsoBase(false)); byte[] baseBytes = ReadBulkUSB(); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); } public ulong GetHeapBase() { Send(SwitchCommand.GetHeapBase(false)); byte[] baseBytes = ReadBulkUSB(); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64LittleEndian(baseBytes); } } diff --git a/SysBot.Base/Connection/Switch/Wireless/SwitchSocketAsync.cs b/SysBot.Base/Connection/Switch/Wireless/SwitchSocketAsync.cs index 04c12bea2..d3bb3da2b 100644 --- a/SysBot.Base/Connection/Switch/Wireless/SwitchSocketAsync.cs +++ b/SysBot.Base/Connection/Switch/Wireless/SwitchSocketAsync.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchOffsetTypeUtil; namespace SysBot.Base; @@ -34,8 +35,8 @@ public override void Connect() } Log("Connecting to device..."); - IAsyncResult result = Connection.BeginConnect(Info.IP, Info.Port, null, null); - bool success = result.AsyncWaitHandle.WaitOne(5000, true); + var result = Connection.BeginConnect(Info.IP, Info.Port, null, null); + var success = result.AsyncWaitHandle.WaitOne(5000, true); if (!success || !Connection.Connected) { InitializeSocket(); @@ -58,8 +59,8 @@ public override void Reset() public override void Disconnect() { Log("Disconnecting from device..."); - IAsyncResult result = Connection.BeginDisconnect(false, null, null); - bool success = result.AsyncWaitHandle.WaitOne(5000, true); + var result = Connection.BeginDisconnect(false, null, null); + var success = result.AsyncWaitHandle.WaitOne(5000, true); if (!success || Connection.Connected) { InitializeSocket(); @@ -71,9 +72,10 @@ public override void Disconnect() } /// Only call this if you are sending small commands. - public ValueTask SendAsync(byte[] buffer, CancellationToken token) => Connection.SendAsync(buffer, token); + public ValueTask SendAsync(ReadOnlyMemory buffer, CancellationToken token) + => Connection.SendAsync(buffer, token); - private async Task ReadBytesFromCmdAsync(byte[] cmd, int length, CancellationToken token) + private async Task ReadBytesFromCmdAsync(ReadOnlyMemory cmd, int length, CancellationToken token) { try { @@ -123,9 +125,9 @@ private static byte[] DecodeResult(ReadOnlyMemory buffer, int length) public Task ReadBytesMainMultiAsync(IReadOnlyDictionary offsetSizes, CancellationToken token) => ReadMulti(Main, offsetSizes, token); public Task ReadBytesAbsoluteMultiAsync(IReadOnlyDictionary offsetSizes, CancellationToken token) => ReadMulti(Absolute, offsetSizes, token); - public Task WriteBytesAsync(byte[] data, uint offset, CancellationToken token) => Write(Heap, data, offset, token); - public Task WriteBytesMainAsync(Span data, ulong offset, CancellationToken token) => Write(Main, data.ToArray(), offset, token); - public Task WriteBytesAbsoluteAsync(Span data, ulong offset, CancellationToken token) => Write(Absolute, data.ToArray(), offset, token); + public Task WriteBytesAsync(ReadOnlyMemory data, uint offset, CancellationToken token) => Write(Heap, data, offset, token); + public Task WriteBytesMainAsync(ReadOnlyMemory data, ulong offset, CancellationToken token) => Write(Main, data, offset, token); + public Task WriteBytesAbsoluteAsync(ReadOnlyMemory data, ulong offset, CancellationToken token) => Write(Absolute, data, offset, token); public async Task GetMainNsoBaseAsync(CancellationToken token) { @@ -137,8 +139,7 @@ public async Task GetMainNsoBaseAsync(CancellationToken token) Log($"{nameof(GetMainNsoBaseAsync)}: Invalid response length"); return 0; } - Array.Reverse(baseBytes, 0, sizeof(ulong)); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64BigEndian(baseBytes); } catch (Exception ex) { @@ -157,8 +158,7 @@ public async Task GetHeapBaseAsync(CancellationToken token) Log($"{nameof(GetHeapBaseAsync)}: Invalid response length"); return 0; } - Array.Reverse(baseBytes, 0, sizeof(ulong)); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64BigEndian(baseBytes); } catch (Exception ex) { @@ -275,11 +275,11 @@ private Task ReadMulti(ICommandBuilder b, IReadOnlyDictionary data, ulong offset, CancellationToken token) { if (data.Length <= MaximumTransferSize) { - var cmd = b.Poke(offset, data); + var cmd = b.Poke(offset, data.Span); await SendAsync(cmd, token).ConfigureAwait(false); return; } @@ -289,19 +289,19 @@ private async Task Write(ICommandBuilder b, byte[] data, ulong offset, Cancellat var length = byteCount - i; if (length > MaximumTransferSize) length = MaximumTransferSize; - var cmd = GetPoke(b, data, offset, i, length); + var cmd = GetPoke(b, data.Span, offset, i, length); await SendAsync(cmd, token).ConfigureAwait(false); await Task.Delay((MaximumTransferSize / DelayFactor) + BaseDelay, token).ConfigureAwait(false); } } - private static byte[] GetPoke(ICommandBuilder b, byte[] data, ulong offset, int i, int length) + private static byte[] GetPoke(ICommandBuilder b, ReadOnlySpan data, ulong offset, int i, int length) { - var slice = data.AsSpan(i, length); + var slice = data.Slice(i, length); return b.Poke(offset + (uint)i, slice); } - public async Task ReadRaw(byte[] command, int length, CancellationToken token) + public async Task ReadRaw(ReadOnlyMemory command, int length, CancellationToken token) { try { @@ -317,7 +317,7 @@ public async Task ReadRaw(byte[] command, int length, CancellationToken } } - public async Task SendRaw(byte[] command, CancellationToken token) + public async Task SendRaw(ReadOnlyMemory command, CancellationToken token) { try { @@ -334,9 +334,9 @@ public Task PointerPeek(int size, IEnumerable jumps, CancellationT return ReadBytesFromCmdAsync(SwitchCommand.PointerPeek(jumps, size), size, token); } - public async Task PointerPoke(byte[] data, IEnumerable jumps, CancellationToken token) + public async Task PointerPoke(ReadOnlyMemory data, IEnumerable jumps, CancellationToken token) { - await SendAsync(SwitchCommand.PointerPoke(jumps, data), token).ConfigureAwait(false); + await SendAsync(SwitchCommand.PointerPoke(jumps, data.Span), token).ConfigureAwait(false); } public async Task PointerAll(IEnumerable jumps, CancellationToken token) @@ -346,11 +346,11 @@ public async Task PointerAll(IEnumerable jumps, CancellationToken t var offsetBytes = await ReadBytesFromCmdAsync(SwitchCommand.PointerAll(jumps), sizeof(ulong), token).ConfigureAwait(false); if (offsetBytes.Length < sizeof(ulong)) { - Log($"{nameof(PointerAll)}: Invalid response length {offsetBytes?.Length ?? 0}"); + Log($"{nameof(PointerAll)}: Invalid response length {offsetBytes.Length}"); return 0; } - Array.Reverse(offsetBytes, 0, sizeof(ulong)); - return BitConverter.ToUInt64(offsetBytes, 0); + // Sent visually (big-endian, thanks); interpret as such. + return ReadUInt64BigEndian(offsetBytes); } catch (Exception ex) { @@ -366,11 +366,11 @@ public async Task PointerRelative(IEnumerable jumps, CancellationTo var offsetBytes = await ReadBytesFromCmdAsync(SwitchCommand.PointerRelative(jumps), sizeof(ulong), token).ConfigureAwait(false); if (offsetBytes.Length < sizeof(ulong)) { - Log($"{nameof(PointerRelative)}: Invalid response length {offsetBytes?.Length ?? 0}"); + Log($"{nameof(PointerRelative)}: Invalid response length {offsetBytes.Length}"); return 0; } - Array.Reverse(offsetBytes, 0, sizeof(ulong)); - return BitConverter.ToUInt64(offsetBytes, 0); + // Sent visually (big-endian, thanks); interpret as such. + return ReadUInt64BigEndian(offsetBytes); } catch (Exception ex) { @@ -391,7 +391,7 @@ public async Task PointerRelative(IEnumerable jumps, CancellationTo if (!BitConverter.IsLittleEndian) Array.Reverse(data, 0, size); - T value = MemoryMarshal.Read(data); + var value = MemoryMarshal.Read(data); return (true, value); } catch (Exception ex) diff --git a/SysBot.Base/Connection/Switch/Wireless/SwitchSocketSync.cs b/SysBot.Base/Connection/Switch/Wireless/SwitchSocketSync.cs index f9729c446..78f0ce1c4 100644 --- a/SysBot.Base/Connection/Switch/Wireless/SwitchSocketSync.cs +++ b/SysBot.Base/Connection/Switch/Wireless/SwitchSocketSync.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Threading; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchOffsetTypeUtil; namespace SysBot.Base; @@ -52,8 +53,8 @@ public override void Disconnect() InitializeSocket(); } - private int Read(byte[] buffer, int size) => Connection.Receive(buffer, size, 0); - public int Send(byte[] buffer) => Connection.Send(buffer); + private int Read(Span buffer) => Connection.Receive(buffer, 0); + public int Send(ReadOnlySpan buffer) => Connection.Send(buffer); private byte[] ReadResponse(int length) { @@ -61,16 +62,16 @@ private byte[] ReadResponse(int length) Thread.Sleep((MaximumTransferSize / DelayFactor) + BaseDelay); var size = (length * 2) + 1; var buffer = ArrayPool.Shared.Rent(size); - _ = Read(buffer, size); - var mem = buffer.AsMemory(0, size); + var mem = buffer.AsSpan(0, size); + _ = Read(mem); var result = DecodeResult(mem, length); ArrayPool.Shared.Return(buffer, true); return result; } - private static byte[] DecodeResult(ReadOnlyMemory buffer, int length) + private static byte[] DecodeResult(ReadOnlySpan buffer, int length) { var result = new byte[length]; - var span = buffer.Span[..^1]; // Last byte is always a terminator + var span = buffer[..^1]; // Last byte is always a terminator Decoder.LoadHexBytesTo(span, result); return result; } @@ -79,16 +80,14 @@ public ulong GetMainNsoBase() { Send(SwitchCommand.GetMainNsoBase()); byte[] baseBytes = ReadResponse(8); - Array.Reverse(baseBytes, 0, 8); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64BigEndian(baseBytes); } public ulong GetHeapBase() { Send(SwitchCommand.GetHeapBase()); byte[] baseBytes = ReadResponse(8); - Array.Reverse(baseBytes, 0, 8); - return BitConverter.ToUInt64(baseBytes, 0); + return ReadUInt64BigEndian(baseBytes); } public byte[] ReadBytes(uint offset, int length) => Read(Heap, offset, length); diff --git a/SysBot.Base/Control/BotSource.cs b/SysBot.Base/Control/BotSource.cs index b0299dd52..b5b47d901 100644 --- a/SysBot.Base/Control/BotSource.cs +++ b/SysBot.Base/Control/BotSource.cs @@ -25,7 +25,7 @@ public void Stop() Task.Run(async () => await Bot.HardStop() .ContinueWith(ReportFailure, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously) - .ContinueWith(_ => IsPaused = IsRunning = IsStopping = false)); + .ContinueWith(_ => IsPaused = IsRunning = IsStopping = false).ConfigureAwait(false)); } public void Pause() @@ -50,7 +50,7 @@ public void Start() IsRunning = true; Task.Run(async () => await Bot.RunAsync(Source.Token) .ContinueWith(ReportFailure, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously) - .ContinueWith(_ => IsRunning = false)); + .ContinueWith(_ => IsRunning = false).ConfigureAwait(false)); } public void Restart() diff --git a/SysBot.Base/Control/RoutineExecutor.cs b/SysBot.Base/Control/RoutineExecutor.cs index 7e5c6d987..74d0e7a30 100644 --- a/SysBot.Base/Control/RoutineExecutor.cs +++ b/SysBot.Base/Control/RoutineExecutor.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; diff --git a/SysBot.Base/Control/SwitchRoutineExecutor.cs b/SysBot.Base/Control/SwitchRoutineExecutor.cs index 9dfa09261..cd13ae3ca 100644 --- a/SysBot.Base/Control/SwitchRoutineExecutor.cs +++ b/SysBot.Base/Control/SwitchRoutineExecutor.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -15,7 +16,7 @@ protected SwitchRoutineExecutor(IConsoleBotManaged - public Task ReadUntilChanged(uint offset, byte[] comparison, int waitms, int waitInterval, bool match, CancellationToken token) => + /// + public Task ReadUntilChanged(uint offset, ReadOnlyMemory comparison, int waitms, int waitInterval, bool match, CancellationToken token) => ReadUntilChanged(offset, comparison, waitms, waitInterval, match, false, token); /// /// Reads an offset until it changes to either match or differ from the comparison value. /// /// If is set to true, then the function returns true when the offset matches the given value.
Otherwise, it returns true when the offset no longer matches the given value.
- public async Task ReadUntilChanged(ulong offset, byte[] comparison, int waitms, int waitInterval, bool match, bool absolute, CancellationToken token) + public async Task ReadUntilChanged(ulong offset, ReadOnlyMemory comparison, int waitms, int waitInterval, bool match, bool absolute, CancellationToken token) { var sw = new Stopwatch(); sw.Start(); @@ -85,7 +86,7 @@ public async Task ReadUntilChanged(ulong offset, byte[] comparison, int wa ? SwitchConnection.ReadBytesAbsoluteAsync(offset, comparison.Length, token) : SwitchConnection.ReadBytesAsync((uint)offset, comparison.Length, token); var result = await task.ConfigureAwait(false); - if (match == result.SequenceEqual(comparison)) + if (match == result.AsSpan().SequenceEqual(comparison.Span)) return true; await Task.Delay(waitInterval, token).ConfigureAwait(false); diff --git a/SysBot.Base/SysBot.Base.csproj b/SysBot.Base/SysBot.Base.csproj index 541aea56e..a207d9a7d 100644 --- a/SysBot.Base/SysBot.Base.csproj +++ b/SysBot.Base/SysBot.Base.csproj @@ -1,8 +1,8 @@ - - + + diff --git a/SysBot.Base/SysBot.Base.csproj.DotSettings b/SysBot.Base/SysBot.Base.csproj.DotSettings new file mode 100644 index 000000000..89316e414 --- /dev/null +++ b/SysBot.Base/SysBot.Base.csproj.DotSettings @@ -0,0 +1,2 @@ + + Library \ No newline at end of file diff --git a/SysBot.Base/Util/EchoUtil.cs b/SysBot.Base/Util/EchoUtil.cs index c2a70ba7e..c88b7d14f 100644 --- a/SysBot.Base/Util/EchoUtil.cs +++ b/SysBot.Base/Util/EchoUtil.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace SysBot.Base; @@ -17,10 +17,10 @@ public static void Echo(string message) } catch (Exception ex) { - LogUtil.LogInfo($"Exception: {ex} occurred while trying to echo: {message} to the forwarder: {fwd}", "Echo"); - LogUtil.LogSafe(ex, "Echo"); + LogUtil.LogInfo($"Exception: {ex} occurred while trying to echo: {message} to the forwarder: {fwd}"); + LogUtil.LogSafe(ex); } } - LogUtil.LogInfo(message, "Echo"); + LogUtil.LogInfo(message); } } diff --git a/SysBot.Base/Util/Logging/LogUtil.cs b/SysBot.Base/Util/Logging/LogUtil.cs index 82a7c443f..2e2e241f9 100644 --- a/SysBot.Base/Util/Logging/LogUtil.cs +++ b/SysBot.Base/Util/Logging/LogUtil.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.CompilerServices; using System.Text; namespace SysBot.Base; @@ -45,19 +46,19 @@ static LogUtil() public static DateTime LastLogged { get; private set; } = DateTime.Now; - public static void LogError(string message, string identity) + public static void LogError(string message, [CallerMemberName] string identity ="") { Logger.Log(LogLevel.Error, $"{identity} {message}"); Log(message, identity); } - public static void LogInfo(string message, string identity) + public static void LogInfo(string message, [CallerMemberName] string identity = "") { Logger.Log(LogLevel.Info, $"{identity} {message}"); Log(message, identity); } - private static void Log(string message, string identity) + private static void Log(string message, [CallerMemberName] string identity = "") { foreach (var fwd in Forwarders) { @@ -75,7 +76,7 @@ private static void Log(string message, string identity) LastLogged = DateTime.Now; } - public static void LogSafe(Exception exception, string identity) + public static void LogSafe(Exception exception, [CallerMemberName] string identity = "") { Logger.Log(LogLevel.Error, $"Exception from {identity}:"); Logger.Log(LogLevel.Error, exception); diff --git a/SysBot.Base/Util/RecordUtil.cs b/SysBot.Base/Util/RecordUtil.cs index 9bba99086..b5cc84dc4 100644 --- a/SysBot.Base/Util/RecordUtil.cs +++ b/SysBot.Base/Util/RecordUtil.cs @@ -1,7 +1,7 @@ +using System.IO; using NLog; using NLog.Config; using NLog.Targets; -using System.IO; namespace SysBot.Base; diff --git a/SysBot.Base/Util/SwitchCommand.cs b/SysBot.Base/Util/SwitchCommand.cs index eafe956fd..41f105467 100644 --- a/SysBot.Base/Util/SwitchCommand.cs +++ b/SysBot.Base/Util/SwitchCommand.cs @@ -19,8 +19,6 @@ private static byte[] Encode(string command, bool crlf = true) return Encoder.GetBytes(command); } - private static string ToHex(byte[] data) - => string.Concat(data.Select(z => $"{z:X2}")); private static string Encode(IEnumerable jumps) => string.Concat(jumps.Select(z => $" {z}")); private static string Encode(IReadOnlyDictionary offsetSizeDictionary) @@ -248,7 +246,7 @@ public static byte[] PointerPeek(IEnumerable jumps, int count, bool crlf = /// Data to write /// Line terminator (unused by USB protocol) /// Encoded command bytes - public static byte[] PointerPoke(IEnumerable jumps, byte[] data, bool crlf = true) + public static byte[] PointerPoke(IEnumerable jumps, ReadOnlySpan data, bool crlf = true) => Encode($"pointerPoke 0x{ToHex(data)}{Encode(jumps)}", crlf); /// diff --git a/SysBot.Pokemon.ConsoleApp/PokeBotRunnerImpl.cs b/SysBot.Pokemon.ConsoleApp/PokeBotRunnerImpl.cs index 48108bcef..ba2af5fe6 100644 --- a/SysBot.Pokemon.ConsoleApp/PokeBotRunnerImpl.cs +++ b/SysBot.Pokemon.ConsoleApp/PokeBotRunnerImpl.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Pokemon.Discord; -using SysBot.Pokemon.Twitch; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Pokemon.Discord; +using SysBot.Pokemon.Twitch; namespace SysBot.Pokemon.ConsoleApp; diff --git a/SysBot.Pokemon.ConsoleApp/Program.cs b/SysBot.Pokemon.ConsoleApp/Program.cs index 96834ce60..009ecb441 100644 --- a/SysBot.Pokemon.ConsoleApp/Program.cs +++ b/SysBot.Pokemon.ConsoleApp/Program.cs @@ -1,9 +1,9 @@ -using PKHeX.Core; -using SysBot.Base; -using SysBot.Pokemon.Z3; using System; using System.IO; using System.Text.Json; +using PKHeX.Core; +using SysBot.Base; +using SysBot.Pokemon.Z3; namespace SysBot.Pokemon.ConsoleApp; diff --git a/SysBot.Pokemon.Discord/Commands/Bots/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/CloneModule.cs deleted file mode 100644 index ba1bb9b6d..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/CloneModule.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Queues new Clone trades")] -public class CloneModule : ModuleBase where T : PKM, new() -{ - private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - - [Command("clone")] - [Alias("c")] - [Summary("Clones the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesClone))] - public Task CloneAsync(int code) - { - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, code, Context.User.Username, sig, new T(), PokeRoutineType.Clone, PokeTradeType.Clone); - } - - [Command("clone")] - [Alias("c")] - [Summary("Clones the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesClone))] - public Task CloneAsync([Summary("Trade Code")][Remainder] string code) - { - int tradeCode = Util.ToInt32(code); - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, tradeCode == 0 ? Info.GetRandomTradeCode() : tradeCode, Context.User.Username, sig, new T(), PokeRoutineType.Clone, PokeTradeType.Clone); - } - - [Command("clone")] - [Alias("c")] - [Summary("Clones the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesClone))] - public Task CloneAsync() - { - var code = Info.GetRandomTradeCode(); - return CloneAsync(code); - } - - [Command("cloneList")] - [Alias("cl", "cq")] - [Summary("Prints the users in the Clone queue.")] - [RequireSudo] - public async Task GetListAsync() - { - string msg = Info.GetTradeList(PokeRoutineType.Clone); - var embed = new EmbedBuilder(); - embed.AddField(x => - { - x.Name = "Pending Trades"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Bots/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/DumpModule.cs deleted file mode 100644 index 55647b204..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/DumpModule.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Queues new Dump trades")] -public class DumpModule : ModuleBase where T : PKM, new() -{ - private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - - [Command("dump")] - [Alias("d")] - [Summary("Dumps the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesDump))] - public Task DumpAsync(int code) - { - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, code, Context.User.Username, sig, new T(), PokeRoutineType.Dump, PokeTradeType.Dump); - } - - [Command("dump")] - [Alias("d")] - [Summary("Dumps the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesDump))] - public Task DumpAsync([Summary("Trade Code")][Remainder] string code) - { - int tradeCode = Util.ToInt32(code); - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, tradeCode == 0 ? Info.GetRandomTradeCode() : tradeCode, Context.User.Username, sig, new T(), PokeRoutineType.Dump, PokeTradeType.Dump); - } - - [Command("dump")] - [Alias("d")] - [Summary("Dumps the Pokémon you show via Link Trade.")] - [RequireQueueRole(nameof(DiscordManager.RolesDump))] - public Task DumpAsync() - { - var code = Info.GetRandomTradeCode(); - return DumpAsync(code); - } - - [Command("dumpList")] - [Alias("dl", "dq")] - [Summary("Prints the users in the Dump queue.")] - [RequireSudo] - public async Task GetListAsync() - { - string msg = Info.GetTradeList(PokeRoutineType.Dump); - var embed = new EmbedBuilder(); - embed.AddField(x => - { - x.Name = "Pending Trades"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Bots/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/QueueModule.cs deleted file mode 100644 index 575f213c1..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/QueueModule.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Clears and toggles Queue features.")] -public class QueueModule : ModuleBase where T : PKM, new() -{ - private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - - [Command("queueStatus")] - [Alias("qs", "ts")] - [Summary("Checks the user's position in the queue.")] - public async Task GetTradePositionAsync() - { - var msg = Context.User.Mention + " - " + Info.GetPositionString(Context.User.Id); - await ReplyAsync(msg).ConfigureAwait(false); - } - - [Command("queueClear")] - [Alias("qc", "tc")] - [Summary("Clears the user from the trade queues. Will not remove a user if they are being processed.")] - public async Task ClearTradeAsync() - { - string msg = ClearTrade(); - await ReplyAsync(msg).ConfigureAwait(false); - } - - [Command("queueClearUser")] - [Alias("qcu", "tcu")] - [Summary("Clears the user from the trade queues. Will not remove a user if they are being processed.")] - [RequireSudo] - public async Task ClearTradeUserAsync([Summary("Discord user ID")] ulong id) - { - string msg = ClearTrade(id); - await ReplyAsync(msg).ConfigureAwait(false); - } - - [Command("queueClearUser")] - [Alias("qcu", "tcu")] - [Summary("Clears the user from the trade queues. Will not remove a user if they are being processed.")] - [RequireSudo] - public async Task ClearTradeUserAsync([Summary("Username of the person to clear")] string _) - { - foreach (var user in Context.Message.MentionedUsers) - { - string msg = ClearTrade(user.Id); - await ReplyAsync(msg).ConfigureAwait(false); - } - } - - [Command("queueClearUser")] - [Alias("qcu", "tcu")] - [Summary("Clears the user from the trade queues. Will not remove a user if they are being processed.")] - [RequireSudo] - public async Task ClearTradeUserAsync() - { - var users = Context.Message.MentionedUsers; - if (users.Count == 0) - { - await ReplyAsync("No users mentioned").ConfigureAwait(false); - return; - } - foreach (var u in users) - await ClearTradeUserAsync(u.Id).ConfigureAwait(false); - } - - [Command("queueClearAll")] - [Alias("qca", "tca")] - [Summary("Clears all users from the trade queues.")] - [RequireSudo] - public async Task ClearAllTradesAsync() - { - Info.ClearAllQueues(); - await ReplyAsync("Cleared all in the queue.").ConfigureAwait(false); - } - - [Command("queueToggle")] - [Alias("qt", "tt")] - [Summary("Toggles on/off the ability to join the trade queue.")] - [RequireSudo] - public Task ToggleQueueTradeAsync() - { - var state = Info.ToggleQueue(); - var msg = state - ? "Users are now able to join the trade queue." - : "Changed queue settings: **Users CANNOT join the queue until it is turned back on.**"; - - return Context.Channel.EchoAndReply(msg); - } - - [Command("queueMode")] - [Alias("qm")] - [Summary("Changes how queueing is controlled (manual/threshold/interval).")] - [RequireSudo] - public async Task ChangeQueueModeAsync([Summary("Queue mode")] QueueOpening mode) - { - SysCord.Runner.Hub.Config.Queues.QueueToggleMode = mode; - await ReplyAsync($"Changed queue mode to {mode}.").ConfigureAwait(false); - } - - [Command("queueList")] - [Alias("ql")] - [Summary("Private messages the list of users in the queue.")] - [RequireSudo] - public async Task ListUserQueue() - { - var lines = SysCord.Runner.Hub.Queues.Info.GetUserList("(ID {0}) - Code: {1} - {2} - {3}"); - var msg = string.Join("\n", lines); - if (msg.Length < 3) - await ReplyAsync("Queue list is empty.").ConfigureAwait(false); - else - await Context.User.SendMessageAsync(msg).ConfigureAwait(false); - } - - private string ClearTrade() - { - var userID = Context.User.Id; - return ClearTrade(userID); - } - - //private static string ClearTrade(string username) - //{ - // var result = Info.ClearTrade(username); - // return GetClearTradeMessage(result); - //} - - private static string ClearTrade(ulong userID) - { - var result = Info.ClearTrade(userID); - return GetClearTradeMessage(result); - } - - private static string GetClearTradeMessage(QueueResultRemove result) - { - return result switch - { - QueueResultRemove.CurrentlyProcessing => "Looks like you're currently being processed! Did not remove from all queues.", - QueueResultRemove.CurrentlyProcessingRemoved => "Looks like you're currently being processed!", - QueueResultRemove.Removed => "Removed you from the queue.", - _ => "Sorry, you are not currently in the queue.", - }; - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Bots/RemoteControlModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/RemoteControlModule.cs deleted file mode 100644 index 16c5f40fd..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/RemoteControlModule.cs +++ /dev/null @@ -1,145 +0,0 @@ -using Discord.Commands; -using PKHeX.Core; -using SysBot.Base; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Remotely controls a bot.")] -public class RemoteControlModule : ModuleBase where T : PKM, new() -{ - [Command("click")] - [Summary("Clicks the specified button.")] - [RequireRoleAccess(nameof(DiscordManager.RolesRemoteControl))] - public async Task ClickAsync(SwitchButton b) - { - var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); - if (bot == null) - { - await ReplyAsync($"No bot is available to execute your command: {b}").ConfigureAwait(false); - return; - } - - await ClickAsyncImpl(b, bot).ConfigureAwait(false); - } - - [Command("click")] - [Summary("Clicks the specified button.")] - [RequireSudo] - public async Task ClickAsync(string ip, SwitchButton b) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot is available to execute your command: {b}").ConfigureAwait(false); - return; - } - - await ClickAsyncImpl(b, bot).ConfigureAwait(false); - } - - [Command("setStick")] - [Summary("Sets the stick to the specified position.")] - [RequireRoleAccess(nameof(DiscordManager.RolesRemoteControl))] - public async Task SetStickAsync(SwitchStick s, short x, short y, ushort ms = 1_000) - { - var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); - if (bot == null) - { - await ReplyAsync($"No bot is available to execute your command: {s}").ConfigureAwait(false); - return; - } - - await SetStickAsyncImpl(s, x, y, ms, bot).ConfigureAwait(false); - } - - [Command("setStick")] - [Summary("Sets the stick to the specified position.")] - [RequireSudo] - public async Task SetStickAsync(string ip, SwitchStick s, short x, short y, ushort ms = 1_000) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - await SetStickAsyncImpl(s, x, y, ms, bot).ConfigureAwait(false); - } - - [Command("setScreenOn")] - [Alias("screenOn", "scrOn")] - [Summary("Turns the screen on")] - [RequireSudo] - public Task SetScreenOnAsync([Remainder] string ip) - { - return SetScreen(true, ip); - } - - [Command("setScreenOff")] - [Alias("screenOff", "scrOff")] - [Summary("Turns the screen off")] - [RequireSudo] - public Task SetScreenOffAsync([Remainder] string ip) - { - return SetScreen(false, ip); - } - - private async Task SetScreen(bool on, string ip) - { - var bot = GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - var b = bot.Bot; - var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; - await b.Connection.SendAsync(SwitchCommand.SetScreen(on ? ScreenState.On : ScreenState.Off, crlf), CancellationToken.None).ConfigureAwait(false); - await ReplyAsync("Screen state set to: " + (on ? "On" : "Off")).ConfigureAwait(false); - } - - private static BotSource? GetBot(string ip) - { - var r = SysCord.Runner; - return r.GetBot(ip) ?? r.Bots.Find(x => x.IsRunning); // safe fallback for users who mistype IP address for single bot instances - } - - private async Task ClickAsyncImpl(SwitchButton button, BotSource bot) - { - if (!Enum.IsDefined(button)) - { - await ReplyAsync($"Unknown button value: {button}").ConfigureAwait(false); - return; - } - - var b = bot.Bot; - var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; - await b.Connection.SendAsync(SwitchCommand.Click(button, crlf), CancellationToken.None).ConfigureAwait(false); - await ReplyAsync($"{b.Connection.Name} has performed: {button}").ConfigureAwait(false); - } - - private async Task SetStickAsyncImpl(SwitchStick s, short x, short y, ushort ms, BotSource bot) - { - if (!Enum.IsDefined(s)) - { - await ReplyAsync($"Unknown stick: {s}").ConfigureAwait(false); - return; - } - - var b = bot.Bot; - var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; - await b.Connection.SendAsync(SwitchCommand.SetStick(s, x, y, crlf), CancellationToken.None).ConfigureAwait(false); - await ReplyAsync($"{b.Connection.Name} has performed: {s}").ConfigureAwait(false); - await Task.Delay(ms).ConfigureAwait(false); - await b.Connection.SendAsync(SwitchCommand.ResetStick(s, crlf), CancellationToken.None).ConfigureAwait(false); - await ReplyAsync($"{b.Connection.Name} has reset the stick position.").ConfigureAwait(false); - } - - private static bool IsRemoteControlBot(RoutineExecutor botstate) - => botstate is RemoteControlBotSWSH or RemoteControlBotBS or RemoteControlBotLA or RemoteControlBotSV or RemoteControlBotLZA; -} diff --git a/SysBot.Pokemon.Discord/Commands/Bots/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/SeedCheckModule.cs deleted file mode 100644 index 6d04fce99..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/SeedCheckModule.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Queues new Seed Check trades")] -public class SeedCheckModule : ModuleBase where T : PKM, new() -{ - private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - - [Command("seedCheck")] - [Alias("checkMySeed", "checkSeed", "seed", "s", "sc")] - [Summary("Checks the seed for a Pokémon.")] - [RequireQueueRole(nameof(DiscordManager.RolesSeed))] - public Task SeedCheckAsync(int code) - { - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, code, Context.User.Username, sig, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed); - } - - [Command("seedCheck")] - [Alias("checkMySeed", "checkSeed", "seed", "s", "sc")] - [Summary("Checks the seed for a Pokémon.")] - [RequireQueueRole(nameof(DiscordManager.RolesSeed))] - public Task SeedCheckAsync([Summary("Trade Code")][Remainder] string code) - { - int tradeCode = Util.ToInt32(code); - var sig = Context.User.GetFavor(); - return QueueHelper.AddToQueueAsync(Context, tradeCode == 0 ? Info.GetRandomTradeCode() : tradeCode, Context.User.Username, sig, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed); - } - - [Command("seedCheck")] - [Alias("checkMySeed", "checkSeed", "seed", "s", "sc")] - [Summary("Checks the seed for a Pokémon.")] - [RequireQueueRole(nameof(DiscordManager.RolesSeed))] - public Task SeedCheckAsync() - { - var code = Info.GetRandomTradeCode(); - return SeedCheckAsync(code); - } - - [Command("seedList")] - [Alias("sl", "scq", "seedCheckQueue", "seedQueue", "seedList")] - [Summary("Prints the users in the Seed Check queue.")] - [RequireSudo] - public async Task GetSeedListAsync() - { - string msg = Info.GetTradeList(PokeRoutineType.SeedCheck); - var embed = new EmbedBuilder(); - embed.AddField(x => - { - x.Name = "Pending Trades"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); - } - - [Command("findFrame")] - [Alias("ff", "getFrameData")] - [Summary("Prints the next shiny frame from the provided seed.")] - public async Task FindFrameAsync([Remainder] string seedString) - { - var me = SysCord.Runner; - var hub = me.Hub; - - seedString = seedString.ToLower(); - if (seedString.StartsWith("0x")) - seedString = seedString[2..]; - - var seed = Util.GetHexValue64(seedString); - - var r = new SeedSearchResult(Z3SearchResult.Success, seed, -1, hub.Config.SeedCheckSWSH.ResultDisplayMode); - var msg = r.ToString(); - - var embed = new EmbedBuilder { Color = Color.LighterGrey }; - - embed.AddField(x => - { - x.Name = $"Seed: {seed:X16}"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Bots/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Bots/TradeModule.cs deleted file mode 100644 index 02c471e3a..000000000 --- a/SysBot.Pokemon.Discord/Commands/Bots/TradeModule.cs +++ /dev/null @@ -1,246 +0,0 @@ -using Discord; -using Discord.Commands; -using Discord.WebSocket; -using PKHeX.Core; -using SysBot.Base; -using System; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -[Summary("Queues new Link Code trades")] -public class TradeModule : ModuleBase where T : PKM, new() -{ - private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - - [Command("tradeList")] - [Alias("tl")] - [Summary("Prints the users in the trade queues.")] - [RequireSudo] - public async Task GetTradeListAsync() - { - string msg = Info.GetTradeList(PokeRoutineType.LinkTrade); - var embed = new EmbedBuilder(); - embed.AddField(x => - { - x.Name = "Pending Trades"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); - } - - [Command("trade")] - [Alias("t")] - [Summary("Makes the bot trade you the provided Pokémon file.")] - [RequireQueueRole(nameof(DiscordManager.RolesTrade))] - public Task TradeAsyncAttach([Summary("Trade Code")] int code) - { - var sig = Context.User.GetFavor(); - return TradeAsyncAttach(code, sig, Context.User); - } - - [Command("trade")] - [Alias("t")] - [Summary("Makes the bot trade you a Pokémon converted from the provided Showdown Set.")] - [RequireQueueRole(nameof(DiscordManager.RolesTrade))] - public async Task TradeAsync([Summary("Trade Code")] int code, [Summary("Showdown Set")][Remainder] string content) - { - content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); - var template = AutoLegalityWrapper.GetTemplate(set); - if (set.InvalidLines.Count != 0 || set.Species is 0) - { - var sb = new StringBuilder(128); - sb.AppendLine("Unable to parse Showdown Set."); - var invalidlines = set.InvalidLines; - if (invalidlines.Count != 0) - { - var localization = BattleTemplateParseErrorLocalization.Get(); - sb.AppendLine("Invalid lines detected:\n```"); - foreach (var line in invalidlines) - { - var error = line.Humanize(localization); - sb.AppendLine(error); - } - sb.AppendLine("```"); - } - if (set.Species is 0) - sb.AppendLine("Species could not be identified. Check your spelling."); - - var msg = sb.ToString(); - await ReplyAsync(msg).ConfigureAwait(false); - return; - } - - try - { - var sav = AutoLegalityWrapper.GetTrainerInfo(); - var pkm = sav.GetLegal(template, out var result); - var la = new LegalityAnalysis(pkm); - var spec = GameInfo.Strings.Species[template.Species]; - pkm = EntityConverter.ConvertToType(pkm, typeof(T), out _) ?? pkm; - if (pkm is not T pk || !la.Valid) - { - var reason = result switch - { - "Timeout" => $"That {spec} set took too long to generate.", - "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", - _ => $"I wasn't able to create a {spec} from that set.", - }; - var imsg = $"Oops! {reason}"; - if (result == "Failed") - imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; - await ReplyAsync(imsg).ConfigureAwait(false); - return; - } - pk.ResetPartyStats(); - - var sig = Context.User.GetFavor(); - await AddTradeToQueueAsync(code, Context.User.Username, pk, sig, Context.User).ConfigureAwait(false); - } - catch (Exception ex) - { - LogUtil.LogSafe(ex, nameof(TradeModule)); - var msg = $"Oops! An unexpected problem happened with this Showdown Set:\n```{string.Join("\n", set.GetSetLines())}```"; - await ReplyAsync(msg).ConfigureAwait(false); - } - } - - [Command("trade")] - [Alias("t")] - [Summary("Makes the bot trade you a Pokémon converted from the provided Showdown Set.")] - [RequireQueueRole(nameof(DiscordManager.RolesTrade))] - public Task TradeAsync([Summary("Showdown Set")][Remainder] string content) - { - var code = Info.GetRandomTradeCode(); - return TradeAsync(code, content); - } - - [Command("trade")] - [Alias("t")] - [Summary("Makes the bot trade you the attached file.")] - [RequireQueueRole(nameof(DiscordManager.RolesTrade))] - public Task TradeAsyncAttach() - { - var code = Info.GetRandomTradeCode(); - return TradeAsyncAttach(code); - } - - [Command("banTrade")] - [Alias("bt")] - [RequireSudo] - public async Task BanTradeAsync([Summary("Online ID")] ulong nnid, string comment) - { - SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew([GetReference(nnid, comment)]); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - private RemoteControlAccess GetReference(ulong id, string comment) => new() - { - ID = id, - Name = id.ToString(), - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss} ({comment})", - }; - - [Command("tradeUser")] - [Alias("tu", "tradeOther")] - [Summary("Makes the bot trade the mentioned user the attached file.")] - [RequireSudo] - public async Task TradeAsyncAttachUser([Summary("Trade Code")] int code, [Remainder] string _) - { - if (Context.Message.MentionedUsers.Count > 1) - { - await ReplyAsync("Too many mentions. Queue one user at a time.").ConfigureAwait(false); - return; - } - - if (Context.Message.MentionedUsers.Count == 0) - { - await ReplyAsync("A user must be mentioned in order to do this.").ConfigureAwait(false); - return; - } - - var usr = Context.Message.MentionedUsers.ElementAt(0); - var sig = usr.GetFavor(); - await TradeAsyncAttach(code, sig, usr).ConfigureAwait(false); - } - - [Command("tradeUser")] - [Alias("tu", "tradeOther")] - [Summary("Makes the bot trade the mentioned user the attached file.")] - [RequireSudo] - public Task TradeAsyncAttachUser([Remainder] string _) - { - var code = Info.GetRandomTradeCode(); - return TradeAsyncAttachUser(code, _); - } - - private async Task TradeAsyncAttach(int code, RequestSignificance sig, SocketUser usr) - { - var attachment = Context.Message.Attachments.FirstOrDefault(); - if (attachment == null) - { - await ReplyAsync("No attachment provided!").ConfigureAwait(false); - return; - } - - var att = await NetUtil.DownloadPKMAsync(attachment).ConfigureAwait(false); - var pk = GetRequest(att); - if (pk == null) - { - await ReplyAsync("Attachment provided is not compatible with this module!").ConfigureAwait(false); - return; - } - - await AddTradeToQueueAsync(code, usr.Username, pk, sig, usr).ConfigureAwait(false); - } - - private static T? GetRequest(Download dl) - { - if (!dl.Success) - return null; - return dl.Data switch - { - null => null, - T pk => pk, - _ => EntityConverter.ConvertToType(dl.Data, typeof(T), out _) as T, - }; - } - - private async Task AddTradeToQueueAsync(int code, string trainerName, T pk, RequestSignificance sig, SocketUser usr) - { - var la = new LegalityAnalysis(pk); - if (!la.Valid) - { - // Disallow trading illegal Pokémon. - await ReplyAsync($"{typeof(T).Name} attachment is not legal, and cannot be traded!").ConfigureAwait(false); - return; - } - - var enc = la.EncounterOriginal; - if (!pk.CanBeTraded(enc)) - { - // Disallow anything that cannot be traded from the game (e.g. Fusions). - await ReplyAsync("Provided Pokémon content is blocked from trading!").ConfigureAwait(false); - return; - } - var cfg = Info.Hub.Config.Trade; - if (cfg.DisallowNonNatives && (enc.Context != pk.Context || pk.GO)) - { - // Allow the owner to prevent trading entities that require a HOME Tracker even if the file has one already. - await ReplyAsync($"{typeof(T).Name} attachment is not native, and cannot be traded!").ConfigureAwait(false); - return; - } - if (cfg.DisallowTracked && pk is IHomeTrack { HasTracker: true }) - { - // Allow the owner to prevent trading entities that already have a HOME Tracker. - await ReplyAsync($"{typeof(T).Name} attachment is tracked by HOME, and cannot be traded!").ConfigureAwait(false); - return; - } - - await QueueHelper.AddToQueueAsync(Context, code, trainerName, sig, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific, usr).ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs index d03f69b86..c20a85fc4 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs @@ -1,42 +1,78 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; -// ReSharper disable once UnusedType.Global -public class BatchEditingModule : ModuleBase +[Group("batch", "Batch editing commands.")] +[RequireContext(ContextType.Guild)] +public class BatchEditingModule : SlashModuleBase { - [Command("batchInfo"), Alias("bei")] - [Summary("Tries to get info about the requested property.")] - public async Task GetBatchInfo(string propertyName) + [SlashCommand("info", "Gets info about a requested property.")] + public async Task GetBatchInfo( + [Summary(nameof(propertyName), "The name of the property to get info about.")] string propertyName) { if (EntityBatchEditor.Instance.TryGetPropertyType(propertyName, out var result)) - await ReplyAsync($"{propertyName}: {result}").ConfigureAwait(false); + await RespondAsync($"{propertyName}: {result}").ConfigureAwait(false); else - await ReplyAsync($"Unable to find info for {propertyName}.").ConfigureAwait(false); + await RespondAsync($"Unable to find info for {propertyName}.").ConfigureAwait(false); + } + + [SlashCommand("validate", "Validates batch editor instructions.")] + public async Task ValidateBatchInfo( + [Summary(nameof(instructions), "The batch editor instructions to validate.")] string instructions) + { + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var isValid = IsValidInstructionSet(instructions, out var invalid); + if (isValid) + { + await FollowupAsync("All line(s) are valid.").ConfigureAwait(false); + return; + } + + var msg = invalid.Select(z => $"{z.PropertyName}, {z.PropertyValue}"); + var block = Format.Code(string.Join(Environment.NewLine, msg)); + await FollowupAsync($"Invalid Lines Detected:{block}").ConfigureAwait(false); } - [Command("batchValidate"), Alias("bev")] - [Summary("Tries to get info about the requested property.")] - public async Task ValidateBatchInfo(string instructions) + [SlashCommand("apply", "Applies batch editor instructions to the attachment.")] + public async Task ApplyBatchInfo( + [Summary(nameof(instructions), "The batch editor instructions to validate.")] string instructions, + [Summary(nameof(attachment), "Attachment PKM file to edit.")] IAttachment attachment) { - bool valid = IsValidInstructionSet(instructions, out var invalid); + await DeferAsync(ephemeral: true).ConfigureAwait(false); - if (!valid) + var isValid = IsValidInstructionSet(instructions, out var invalid); + if (!isValid) { var msg = invalid.Select(z => $"{z.PropertyName}, {z.PropertyValue}"); - await ReplyAsync($"Invalid Lines Detected:\r\n{Format.Code(string.Join(Environment.NewLine, msg))}") - .ConfigureAwait(false); + var block = Format.Code(string.Join(Environment.NewLine, msg)); + await FollowupAsync($"Invalid Lines Detected:{block}").ConfigureAwait(false); + return; } - else + + var download = await attachment.DownloadEntityAsync().ConfigureAwait(false); + if (!download.Success || download.Data is not { } pk) { - await ReplyAsync($"{invalid.Count} line(s) are invalid.").ConfigureAwait(false); + await FollowupAsync(download.ErrorMessage).ConfigureAwait(false); + return; } + + var set = new StringInstructionSet(instructions); + var result = EntityBatchEditor.Instance.TryModify(pk, set.Filters, set.Instructions); + if (result != ModifyResult.Modified) + { + await FollowupAsync($"Not modified: {result}").ConfigureAwait(false); + return; + } + + await Context.SendFilePrivatelyAsync(pk, "Modified result attached:").ConfigureAwait(false); + await FollowupAsync("Result sent via direct message.").ConfigureAwait(false); } private static bool IsValidInstructionSet(ReadOnlySpan split, out List invalid) @@ -48,7 +84,6 @@ private static bool IsValidInstructionSet(ReadOnlySpan split, out List +[Group("legality", "Legality check commands.")] +[RequireContext(ContextType.Guild)] +public class LegalityCheckModule : SlashModuleBase { - [Command("lc"), Alias("check", "validate", "verify")] - [Summary("Verifies the attachment for legality.")] - public async Task LegalityCheck() - { - var attachments = Context.Message.Attachments; - foreach (var att in attachments) - await LegalityCheck(att, false).ConfigureAwait(false); - } + [SlashCommand("legality", "Verifies an attached Pokémon file for legality.")] + public Task LegalityCheck( + [Summary(nameof(file), "Pokémon file to check.")] IAttachment file, + [Summary(nameof(verbose), "Whether to provide a detailed report.")] bool? verbose = null) + => CheckAsync(file, verbose ?? false); - [Command("lcv"), Alias("verbose")] - [Summary("Verifies the attachment for legality with a verbose output.")] - public async Task LegalityCheckVerbose() + private async Task CheckAsync(IAttachment attachment, bool verbose) { - var attachments = Context.Message.Attachments; - foreach (var att in attachments) - await LegalityCheck(att, true).ConfigureAwait(false); - } + await DeferAsync(ephemeral: true).ConfigureAwait(false); - private async Task LegalityCheck(IAttachment att, bool verbose) - { - var download = await NetUtil.DownloadPKMAsync(att).ConfigureAwait(false); + var download = await attachment.DownloadEntityAsync().ConfigureAwait(false); if (!download.Success) { - await ReplyAsync(download.ErrorMessage).ConfigureAwait(false); + await FollowupAsync(download.ErrorMessage).ConfigureAwait(false); return; } - var pkm = download.Data!; - var la = new LegalityAnalysis(pkm); - var builder = new EmbedBuilder - { - Color = la.Valid ? Color.Green : Color.Red, - Description = $"Legality Report for {download.SanitizedFileName}:", - }; - - builder.AddField(x => - { - x.Name = la.Valid ? "Valid" : "Invalid"; - x.Value = la.Report(verbose); - x.IsInline = false; - }); + var la = new LegalityAnalysis(download.Data!); + var builder = new EmbedBuilder { Color = la.Valid ? Color.Green : Color.Red, Description = $"Legality Report for {download.SanitizedFileName}:" }; + builder.AddField(la.Valid ? "Valid" : "Invalid", la.Report(verbose)); - await ReplyAsync("Here's the legality report!", false, builder.Build()).ConfigureAwait(false); + await FollowupAsync("Here's the legality report!", embed: builder.Build()).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs index e1a7f8ade..a82ffc325 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs @@ -1,33 +1,30 @@ -using Discord.Commands; -using PKHeX.Core; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; -public class LegalizerModule : ModuleBase where T : PKM, new() +[RequireContext(ContextType.Guild)] +public class LegalizerModule : SlashModuleBase where T : PKM, new() { - [Command("legalize"), Alias("alm")] - [Summary("Tries to legalize the attached pkm data.")] - public async Task LegalizeAsync() - { - var attachments = Context.Message.Attachments; - foreach (var att in attachments) - await Context.Channel.ReplyWithLegalizedSetAsync(att).ConfigureAwait(false); - } - - [Command("convert"), Alias("showdown")] - [Summary("Tries to convert the Showdown Set to pkm data.")] - [Priority(1)] - public Task ConvertShowdown([Summary("Generation/Format")] byte gen, [Remainder][Summary("Showdown Set")] string content) + [SlashCommand("legalize", "Tries to legalize an attached PKM file.")] + public async Task LegalizeAsync( + [Summary(nameof(file), "The file to legalize.")] IAttachment file) { - return Context.Channel.ReplyWithLegalizedSetAsync(content, gen); + await DeferAsync().ConfigureAwait(false); + await Context.ReplyWithLegalizedSetAsync(file).ConfigureAwait(false); } - [Command("convert"), Alias("showdown")] - [Summary("Tries to convert the Showdown Set to pkm data.")] - [Priority(0)] - public Task ConvertShowdown([Remainder][Summary("Showdown Set")] string content) + [SlashCommand("convert", "Converts a Showdown Set to PKM data.")] + public async Task ConvertShowdownAsync( + [Summary(nameof(content), "The Showdown set to convert.")] string content, + [Summary(nameof(generation), "Optional")] byte? generation = null) { - return Context.Channel.ReplyWithLegalizedSetAsync(content); + await DeferAsync().ConfigureAwait(false); + if (generation is not { } gen) // assume current format if no generation is specified + await Context.ReplyWithLegalizedSetAsync(content).ConfigureAwait(false); + else + await Context.ReplyWithLegalizedSetAsync(content, gen).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Commands/General/HelloModule.cs b/SysBot.Pokemon.Discord/Commands/General/HelloModule.cs index 736f71a59..7a29e2bfb 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelloModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelloModule.cs @@ -1,17 +1,14 @@ -using Discord.Commands; using System.Threading.Tasks; +using Discord.Interactions; namespace SysBot.Pokemon.Discord; -public class HelloModule : ModuleBase +[RequireContext(ContextType.Guild)] +public class HelloModule : SlashModuleBase { - [Command("hello")] - [Alias("hi")] - [Summary("Say hello to the bot and get a response.")] - public async Task PingAsync() - { - var str = SysCordSettings.Settings.HelloResponse; - var msg = string.Format(str, Context.User.Mention); - await ReplyAsync(msg).ConfigureAwait(false); - } + [SlashCommand("hello", "Say hello to the bot and get a response.")] + public Task HelloAsync() => RespondAsync(string.Format(SysCordSettings.Settings.HelloResponse, Context.User.Mention)); + + [SlashCommand("ping", "Makes the bot respond, indicating that it is running.")] + public Task PingAsync() => RespondAsync("Pong!", ephemeral: true); } diff --git a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs index 3ff5f1345..4b59629ad 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs @@ -1,116 +1,56 @@ -using Discord; -using Discord.Commands; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; namespace SysBot.Pokemon.Discord; -public class HelpModule(CommandService Service) : ModuleBase +[RequireContext(ContextType.Guild)] +public class HelpModule(InteractionService service) : SlashModuleBase { - [Command("help")] - [Summary("Lists available commands.")] - public async Task HelpAsync() + [SlashCommand("help", "Lists available slash commands.")] + public async Task HelpAsync(string? command = null) { - var builder = new EmbedBuilder + var builder = new EmbedBuilder { Color = Color.Blue }; + if (string.IsNullOrWhiteSpace(command)) { - Color = new Color(114, 137, 218), - Description = "These are the commands you can use:", - }; - - var mgr = SysCordSettings.Manager; - var app = await Context.Client.GetApplicationInfoAsync().ConfigureAwait(false); - var owner = app.Owner.Id; - var uid = Context.User.Id; - - foreach (var module in Service.Modules) - { - string? description = null; - HashSet mentioned = []; - foreach (var cmd in module.Commands) + builder.Description = "These are the commands you can use:"; + foreach (var group in service.SlashCommands.GroupBy(x => x.Module.Name)) { - var name = cmd.Name; - if (mentioned.Contains(name)) - continue; - if (cmd.Attributes.Any(z => z is RequireOwnerAttribute) && owner != uid) - continue; - if (cmd.Attributes.Any(z => z is RequireSudoAttribute) && !mgr.CanUseSudo(uid)) - continue; - - mentioned.Add(name); - var result = await cmd.CheckPreconditionsAsync(Context).ConfigureAwait(false); - if (result.IsSuccess) - description += $"{cmd.Aliases[0]}\n"; + var names = group.Select(x => x.Name).Distinct().Order(); + var value = string.Join('\n', names); + if (!string.IsNullOrWhiteSpace(value)) + builder.AddField(group.Key.Replace("Module", string.Empty).Replace("`1", string.Empty), value); } - if (string.IsNullOrWhiteSpace(description)) - continue; - - var moduleName = module.Name; - var gen = moduleName.IndexOf('`'); - if (gen != -1) - moduleName = moduleName[..gen]; - - builder.AddField(x => - { - x.Name = moduleName; - x.Value = description; - x.IsInline = false; - }); } - - await ReplyAsync("Help has arrived!", false, builder.Build()).ConfigureAwait(false); - } - - [Command("help")] - [Summary("Lists information about a specific command.")] - public async Task HelpAsync([Summary("The command you want help for")] string command) - { - var result = Service.Search(Context, command); - - if (!result.IsSuccess) + else { - await ReplyAsync($"Sorry, I couldn't find a command like **{command}**.").ConfigureAwait(false); - return; - } - - var builder = new EmbedBuilder - { - Color = new Color(114, 137, 218), - Description = $"Here are some commands like **{command}**:", - }; - - foreach (var match in result.Commands) - { - var cmd = match.Command; + var matches = service.SlashCommands.Where(x => + x.Name.Equals(command, StringComparison.OrdinalIgnoreCase) || + x.Name.Contains(command, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) + { + await RespondAsync($"Sorry, I couldn't find a command like **{command}**.", ephemeral: true).ConfigureAwait(false); + return; + } - builder.AddField(x => + // Use a different description. + builder.Description = $"Here are some commands like **{command}**:"; + foreach (var cmd in matches) { - x.Name = string.Join(", ", cmd.Aliases); - x.Value = GetCommandSummary(cmd); - x.IsInline = false; - }); + var parameters = GetParameters(cmd.Parameters); + builder.AddField(cmd.Name, $"Summary: {cmd.Description}\nParameters:\n{parameters}"); + } } - - await ReplyAsync("Help has arrived!", false, builder.Build()).ConfigureAwait(false); + await RespondAsync("Help has arrived!", ephemeral: true, embed: builder.Build()).ConfigureAwait(false); } - private static string GetCommandSummary(CommandInfo cmd) + private static string GetParameters(IReadOnlyList para) { - return $"Summary: {cmd.Summary}\nParameters: {GetParameterSummary(cmd.Parameters)}"; - } - - private static string GetParameterSummary(IReadOnlyList p) - { - if (p.Count == 0) + if (para.Count == 0) return "None"; - return $"{p.Count}\n- " + string.Join("\n- ", p.Select(GetParameterSummary)); - } - - private static string GetParameterSummary(ParameterInfo z) - { - var result = z.Name; - if (!string.IsNullOrWhiteSpace(z.Summary)) - result += $" ({z.Summary})"; - return result; + return string.Join('\n', para.Select(p => $"- {p.Name} ({p.Description})")); } } diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index a3698808f..51b17549b 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -1,5 +1,3 @@ -using Discord; -using Discord.Commands; using System; using System.Diagnostics; using System.Globalization; @@ -7,49 +5,54 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; namespace SysBot.Pokemon.Discord; -// src: https://github.com/foxbot/patek/blob/master/src/Patek/Modules/InfoModule.cs +// src: https://github.com/foxbot/patek/blob/169a4d93b099a843bc0ddce1a30f14132c073fe4/src/Patek/Modules/InfoModule.cs // ISC License (ISC) // Copyright 2017, Christopher F. -public class InfoModule : ModuleBase +// Adapted for SysBot.NET by kwsch 2020; updated for Slash commands 2026. + +[RequireContext(ContextType.Guild)] +public class InfoModule : SlashModuleBase { - private const string detail = "I am an open-source Discord bot powered by PKHeX.Core and other open-source software."; - private const string repo = "https://github.com/kwsch/SysBot.NET"; + private const string Description = "I am an open-source Discord bot powered by PKHeX.Core and other open-source software."; + private const string Repo = "https://github.com/kwsch/SysBot.NET"; - [Command("info")] - [Alias("about", "whoami", "owner")] + [SlashCommand("info", "Displays information about the bot.")] public async Task InfoAsync() { var app = await Context.Client.GetApplicationInfoAsync().ConfigureAwait(false); var builder = new EmbedBuilder { - Color = new Color(114, 137, 218), - Description = detail, + Color = Color.Blue, + Description = Description }; builder.AddField("Info", - $"- [Source Code]({repo})\n" + - $"- {Format.Bold("Owner")}: {app.Owner} ({app.Owner.Id})\n" + - $"- {Format.Bold("Library")}: Discord.Net ({DiscordConfig.Version})\n" + - $"- {Format.Bold("Uptime")}: {GetUptime()}\n" + - $"- {Format.Bold("Runtime")}: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture} " + - $"({RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture})\n" + - $"- {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Base", false)}\n" + - $"- {Format.Bold("Core Version")}: {GetVersionInfo("PKHeX.Core")}\n" + - $"- {Format.Bold("AutoLegality Version")}: {GetVersionInfo("PKHeX.Core.AutoMod")}\n" - ); - +$""" +- [Source Code]({Repo}) +- {Format.Bold("Owner")}: {app.Owner} ({app.Owner.Id}) +- {Format.Bold("Library")}: Discord.Net ({DiscordConfig.Version}) +- {Format.Bold("Uptime")}: {GetUptime()} +- {Format.Bold("Runtime")}: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}) +- {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Base", false)} +- {Format.Bold("Core Version")}: {GetVersionInfo("PKHeX.Core")} +- {Format.Bold("AutoLegality Version")}: {GetVersionInfo("PKHeX.Core.AutoMod")} +- {Format.Bold("Command Count")}: {SysCordSettings.CommandsRegistered} @ {SysCordSettings.CommandsRegisteredTime:yyyy.MM.dd-hh:mm:ss} UTC +""" + ); builder.AddField("Stats", - $"- {Format.Bold("Heap Size")}: {GetHeapSize()}MiB\n" + - $"- {Format.Bold("Guilds")}: {Context.Client.Guilds.Count}\n" + - $"- {Format.Bold("Channels")}: {Context.Client.Guilds.Sum(g => g.Channels.Count)}\n" + - $"- {Format.Bold("Users")}: {Context.Client.Guilds.Sum(g => g.MemberCount)}\n" - ); - - await ReplyAsync("Here's a bit about me!", embed: builder.Build()).ConfigureAwait(false); +$""" +- {Format.Bold("Heap Size")}: {GetHeapSize()}MiB +- {Format.Bold("Guilds")}: {Context.Client.Guilds.Count} +- {Format.Bold("Channels")}: {Context.Client.Guilds.Sum(g => g.Channels.Count)} +- {Format.Bold("Users")}: {Context.Client.Guilds.Sum(g => g.MemberCount)} +"""); + await RespondAsync("Here's a bit about me!", embed: builder.Build()).ConfigureAwait(false); } private static string GetUptime() => (DateTime.Now - Process.GetCurrentProcess().StartTime).ToString(@"dd\.hh\:mm\:ss"); @@ -57,23 +60,24 @@ public async Task InfoAsync() private static string GetVersionInfo(string assemblyName, bool inclVersion = true) { - const string _default = "Unknown"; + const string unknownVersion = "Unknown"; + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); var assembly = Array.Find(assemblies, x => x.GetName().Name == assemblyName); var attribute = assembly?.GetCustomAttribute(); if (attribute is null) - return _default; + return unknownVersion; var info = attribute.InformationalVersion; var split = info.Split('+'); if (split.Length < 2) - return _default; + return unknownVersion; var version = split[0]; var revision = split[1]; if (DateTime.TryParseExact(revision, "yyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var buildTime)) return (inclVersion ? $"{version} " : "") + $@"{buildTime:yy-MM-dd\.hh\:mm}"; - return !inclVersion ? _default : version; + return !inclVersion ? unknownVersion : version; } } diff --git a/SysBot.Pokemon.Discord/Commands/General/PingModule.cs b/SysBot.Pokemon.Discord/Commands/General/PingModule.cs deleted file mode 100644 index 7204d46ea..000000000 --- a/SysBot.Pokemon.Discord/Commands/General/PingModule.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Discord.Commands; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class PingModule : ModuleBase -{ - [Command("ping")] - [Summary("Makes the bot respond, indicating that it is running.")] - public async Task PingAsync() - { - await ReplyAsync("Pong!").ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/BotModule.cs b/SysBot.Pokemon.Discord/Commands/Management/BotModule.cs deleted file mode 100644 index a9f7dfc2a..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/BotModule.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System.Text; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -// ReSharper disable once UnusedType.Global -public class BotModule : ModuleBase where T : PKM, new() -{ - [Command("botStatus")] - [Summary("Gets the status of the bots.")] - [RequireSudo] - public async Task GetStatusAsync() - { - var me = SysCord.Runner; - var sb = new StringBuilder(); - foreach (var bot in me.Bots) - { - if (bot.Bot is not PokeRoutineExecutorBase b) - continue; - sb.AppendLine(GetDetailedSummary(b)); - } - if (sb.Length == 0) - { - await ReplyAsync("No bots configured.").ConfigureAwait(false); - return; - } - await ReplyAsync(Format.Code(sb.ToString())).ConfigureAwait(false); - } - - private static string GetDetailedSummary(TBot z) where TBot: PokeRoutineExecutorBase - { - return $"- {z.Connection.Name} | {z.Connection.Label} - {z.Config.CurrentRoutineType} ~ {z.LastTime:hh:mm:ss} | {z.LastLogged}"; - } - - [Command("botStart")] - [Summary("Starts a bot by IP address/port.")] - [RequireSudo] - public async Task StartBotAsync(string ip) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - bot.Start(); - await Context.Channel.EchoAndReply($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Start.").ConfigureAwait(false); - } - - [Command("botStop")] - [Summary("Stops a bot by IP address/port.")] - [RequireSudo] - public async Task StopBotAsync(string ip) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - bot.Stop(); - await Context.Channel.EchoAndReply($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Stop.").ConfigureAwait(false); - } - - [Command("botIdle")] - [Alias("botPause")] - [Summary("Commands a bot to Idle by IP address/port.")] - [RequireSudo] - public async Task IdleBotAsync(string ip) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - bot.Pause(); - await Context.Channel.EchoAndReply($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Idle.").ConfigureAwait(false); - } - - [Command("botChange")] - [Summary("Changes the routine of a bot (trades).")] - [RequireSudo] - public async Task ChangeTaskAsync(string ip, [Summary("Routine enum name")] PokeRoutineType task) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - bot.Bot.Config.Initialize(task); - await Context.Channel.EchoAndReply($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to do {task} as its next task.").ConfigureAwait(false); - } - - [Command("botRestart")] - [Summary("Restarts the bot(s) by IP address(es), separated by commas.")] - [RequireSudo] - public async Task RestartBotAsync(string ipAddressesCommaSeparated) - { - var ips = ipAddressesCommaSeparated.Split(','); - foreach (var ip in ips) - { - var bot = SysCord.Runner.GetBot(ip); - if (bot == null) - { - await ReplyAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); - return; - } - - var c = bot.Bot.Connection; - c.Reset(); - bot.Start(); - await Context.Channel.EchoAndReply($"The bot at {ip} ({c.Label}) has been commanded to Restart.").ConfigureAwait(false); - } - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/EchoModule.cs b/SysBot.Pokemon.Discord/Commands/Management/EchoModule.cs deleted file mode 100644 index 46f3fe057..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/EchoModule.cs +++ /dev/null @@ -1,119 +0,0 @@ -using Discord; -using Discord.Commands; -using Discord.WebSocket; -using SysBot.Base; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class EchoModule : ModuleBase -{ - private class EchoChannel(ulong ChannelId, string ChannelName, Action Action) - { - public readonly ulong ChannelID = ChannelId; - public readonly string ChannelName = ChannelName; - public readonly Action Action = Action; - } - - private static readonly Dictionary Channels = []; - - public static void RestoreChannels(DiscordSocketClient discord, DiscordSettings cfg) - { - foreach (var ch in cfg.EchoChannels) - { - if (discord.GetChannel(ch.ID) is ISocketMessageChannel c) - AddEchoChannel(c, ch.ID); - } - - EchoUtil.Echo("Added echo notification to Discord channel(s) on Bot startup."); - } - - [Command("echoHere")] - [Summary("Makes the echo special messages to the channel.")] - [RequireSudo] - public async Task AddEchoAsync() - { - var c = Context.Channel; - var cid = c.Id; - if (Channels.TryGetValue(cid, out _)) - { - await ReplyAsync("Already notifying here.").ConfigureAwait(false); - return; - } - - AddEchoChannel(c, cid); - - // Add to discord global loggers (saves on program close) - SysCordSettings.Settings.EchoChannels.AddIfNew([GetReference(Context.Channel)]); - await ReplyAsync("Added Echo output to this channel!").ConfigureAwait(false); - } - - private static void AddEchoChannel(ISocketMessageChannel c, ulong cid) - { - void Echo(string msg) => c.SendMessageAsync(msg); - - Action l = Echo; - EchoUtil.Forwarders.Add(l); - var entry = new EchoChannel(cid, c.Name, l); - Channels.Add(cid, entry); - } - - public static bool IsEchoChannel(ISocketMessageChannel c) - { - var cid = c.Id; - return Channels.TryGetValue(cid, out _); - } - - [Command("echoInfo")] - [Summary("Dumps the special message (Echo) settings.")] - [RequireSudo] - public async Task DumpEchoInfoAsync() - { - foreach (var c in Channels) - await ReplyAsync($"{c.Key} - {c.Value}").ConfigureAwait(false); - } - - [Command("echoClear")] - [Summary("Clears the special message echo settings in that specific channel.")] - [RequireSudo] - public async Task ClearEchosAsync() - { - var id = Context.Channel.Id; - if (!Channels.TryGetValue(id, out var echo)) - { - await ReplyAsync("Not echoing in this channel.").ConfigureAwait(false); - return; - } - EchoUtil.Forwarders.Remove(echo.Action); - Channels.Remove(Context.Channel.Id); - SysCordSettings.Settings.EchoChannels.RemoveAll(z => z.ID == id); - await ReplyAsync($"Echoes cleared from channel: {Context.Channel.Name}").ConfigureAwait(false); - } - - [Command("echoClearAll")] - [Summary("Clears all the special message Echo channel settings.")] - [RequireSudo] - public async Task ClearEchosAllAsync() - { - foreach (var l in Channels) - { - var entry = l.Value; - await ReplyAsync($"Echoing cleared from {entry.ChannelName} ({entry.ChannelID}!").ConfigureAwait(false); - EchoUtil.Forwarders.Remove(entry.Action); - } - EchoUtil.Forwarders.RemoveAll(y => Channels.Select(x => x.Value.Action).Contains(y)); - Channels.Clear(); - SysCordSettings.Settings.EchoChannels.Clear(); - await ReplyAsync("Echoes cleared from all channels!").ConfigureAwait(false); - } - - private RemoteControlAccess GetReference(IChannel channel) => new() - { - ID = channel.Id, - Name = channel.Name, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/EncounterModule.cs b/SysBot.Pokemon.Discord/Commands/Management/EncounterModule.cs deleted file mode 100644 index 0a41c66db..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/EncounterModule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Discord.Commands; -using PKHeX.Core; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class EchoModule : ModuleBase where T : PKM, new() -{ - [Command("toss")] - [Summary("Makes all bots that are currently waiting for a go-ahead continue operation.")] - [RequireSudo] - public async Task TossAsync(string name = "") - { - var bots = SysCord.Runner.Bots.Select(z => z.Bot); - foreach (var b in bots) - { - if (b is not IEncounterBot x) - continue; - if (!b.Connection.Name.Contains(name) && !b.Connection.Label.Contains(name)) - continue; - x.Acknowledge(); - } - - await ReplyAsync("Done.").ConfigureAwait(false); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs index 24cb073f3..c22a03373 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs @@ -1,19 +1,18 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon.Discord; -public class HubModule : ModuleBase where T : PKM, new() +[RequireContext(ContextType.Guild)] +public class HubModule : SlashModuleBase where T : PKM, new() { - [Command("status")] - [Alias("stats")] - [Summary("Gets the status of the bot environment.")] + [SlashCommand("status", "Gets the status of the bot environment.")] public async Task GetStatusAsync() { var me = SysCord.Runner; @@ -23,50 +22,38 @@ public async Task GetStatusAsync() { Color = Color.Gold, }; - - var runner = SysCord.Runner; - var allBots = runner.Bots.ConvertAll(z => z.Bot); - var botCount = allBots.Count; + var all = me.Bots.ConvertAll(z => z.Bot); builder.AddField(x => { x.Name = "Summary"; x.Value = - $"Bot Count: {botCount}\n" + - $"Bot State: {SummarizeBots(allBots)}\n" + + $"Bot Count: {all.Count}\n" + + $"Bot State: {SummarizeBots(all)}\n" + $"Pool Count: {hub.Ledy.Pool.Count}\n"; x.IsInline = false; }); builder.AddField(x => { - var bots = allBots.OfType(); - var lines = bots.SelectMany(z => z.Counts.GetNonZeroCounts()).Distinct(); - var msg = string.Join("\n", lines); - if (string.IsNullOrWhiteSpace(msg)) - msg = "Nothing counted yet!"; + var lines = all.OfType().SelectMany(z => z.Counts.GetNonZeroCounts()).Distinct(); x.Name = "Counts"; - x.Value = msg; + x.Value = string.Join('\n', lines) is { Length: > 0 } msg ? msg : "Nothing counted yet!"; x.IsInline = false; }); - var queues = hub.Queues.AllQueues; int count = 0; - foreach (var q in queues) + foreach (var q in hub.Queues.AllQueues) { - var c = q.Count; - if (c == 0) + if (q.Count == 0) continue; - - var nextMsg = GetNextName(q); + var next = GetNextName(q); builder.AddField(x => { x.Name = $"{q.Type} Queue"; - x.Value = - $"Next: {nextMsg}\n" + - $"Count: {c}\n"; + x.Value = $"Next: {next}\nCount: {q.Count}\n"; x.IsInline = false; }); - count += c; + count += q.Count; } if (count == 0) @@ -79,25 +66,22 @@ public async Task GetStatusAsync() }); } - await ReplyAsync("Bot Status", false, builder.Build()).ConfigureAwait(false); + await RespondAsync("Bot Status", ephemeral: true, embed: builder.Build()).ConfigureAwait(false); } private static string GetNextName(PokeTradeQueue q) { - var next = q.TryPeek(out var detail, out _); - if (!next) + if (!q.TryPeek(out var detail, out _)) return "None!"; var name = detail.Trainer.TrainerName; // show detail of trade if possible var nick = detail.TradeData.Nickname; - if (!string.IsNullOrEmpty(nick)) - name += $" - {nick}"; - return name; + return string.IsNullOrEmpty(nick) ? name : $"{name} - {nick}"; } - private static string SummarizeBots(IReadOnlyCollection> bots) + private static string SummarizeBots(List> bots) { if (bots.Count == 0) return "No bots configured."; diff --git a/SysBot.Pokemon.Discord/Commands/Management/LogModule.cs b/SysBot.Pokemon.Discord/Commands/Management/LogModule.cs deleted file mode 100644 index eb6d44f4a..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/LogModule.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Discord; -using Discord.Commands; -using Discord.WebSocket; -using SysBot.Base; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class LogModule : ModuleBase -{ - private static readonly Dictionary Channels = []; - - public static void RestoreLogging(DiscordSocketClient discord, DiscordSettings settings) - { - foreach (var ch in settings.LoggingChannels) - { - if (discord.GetChannel(ch.ID) is ISocketMessageChannel c) - AddLogChannel(c, ch.ID); - } - - LogUtil.LogInfo("Added logging to Discord channel(s) on Bot startup.", "Discord"); - } - - [Command("logHere")] - [Summary("Makes the bot log to the channel.")] - [RequireSudo] - public async Task AddLogAsync() - { - var c = Context.Channel; - var cid = c.Id; - if (Channels.TryGetValue(cid, out _)) - { - await ReplyAsync("Already logging here.").ConfigureAwait(false); - return; - } - - AddLogChannel(c, cid); - - // Add to discord global loggers (saves on program close) - SysCordSettings.Settings.LoggingChannels.AddIfNew([GetReference(Context.Channel)]); - await ReplyAsync("Added logging output to this channel!").ConfigureAwait(false); - } - - private static void AddLogChannel(ISocketMessageChannel c, ulong cid) - { - var logger = new ChannelLogger(cid, c); - LogUtil.Forwarders.Add(logger); - Channels.Add(cid, logger); - } - - [Command("logInfo")] - [Summary("Dumps the logging settings.")] - [RequireSudo] - public async Task DumpLogInfoAsync() - { - foreach (var c in Channels) - await ReplyAsync($"{c.Key} - {c.Value}").ConfigureAwait(false); - } - - [Command("logClear")] - [Summary("Clears the logging settings in that specific channel.")] - [RequireSudo] - public async Task ClearLogsAsync() - { - var id = Context.Channel.Id; - if (!Channels.TryGetValue(id, out var log)) - { - await ReplyAsync("Not echoing in this channel.").ConfigureAwait(false); - return; - } - LogUtil.Forwarders.Remove(log); - Channels.Remove(Context.Channel.Id); - SysCordSettings.Settings.LoggingChannels.RemoveAll(z => z.ID == id); - await ReplyAsync($"Logging cleared from channel: {Context.Channel.Name}").ConfigureAwait(false); - } - - [Command("logClearAll")] - [Summary("Clears all the logging settings.")] - [RequireSudo] - public async Task ClearLogsAllAsync() - { - foreach (var l in Channels) - { - var entry = l.Value; - await ReplyAsync($"Logging cleared from {entry.ChannelName} ({entry.ChannelID}!").ConfigureAwait(false); - LogUtil.Forwarders.Remove(entry); - } - - LogUtil.Forwarders.RemoveAll(y => Channels.Select(z => z.Value).Contains(y)); - Channels.Clear(); - SysCordSettings.Settings.LoggingChannels.Clear(); - await ReplyAsync("Logging cleared from all channels!").ConfigureAwait(false); - } - - private RemoteControlAccess GetReference(IChannel channel) => new() - { - ID = channel.Id, - Name = channel.Name, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs index 3eec12e8a..d7911b6f1 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs @@ -1,128 +1,88 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; using System; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; namespace SysBot.Pokemon.Discord; -public class OwnerModule : SudoModule where T : PKM, new() +[Group("owner", "Commands usable by the bot owner.")] +[DefaultMemberPermissions(GuildPermission.Administrator)] // hide these commands from the majority of users; bot Owners must have admin on server to manage. +[RequireOwner] +public class OwnerModule : SlashModuleBase { - [Command("addSudo")] - [Summary("Adds mentioned user to global sudo")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global - public async Task SudoUsers([Remainder] string _) + [SlashCommand("add-sudo", "Adds a user to global sudo.")] + [CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)] + public async Task AddSudo(IUser user) { - var users = Context.Message.MentionedUsers; - var objects = users.Select(GetReference); - SysCordSettings.Settings.GlobalSudoList.AddIfNew(objects); - await ReplyAsync("Done.").ConfigureAwait(false); + SysCordSettings.Settings.GlobalSudoList.AddIfNew(GetReference(user)); + await RespondAsync("Done.").ConfigureAwait(false); } - [Command("removeSudo")] - [Summary("Removes mentioned user from global sudo")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global - public async Task RemoveSudoUsers([Remainder] string _) + [SlashCommand("remove-sudo", "Removes a user from global sudo.")] + [CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)] + public async Task RemoveSudo(IUser user) { - var users = Context.Message.MentionedUsers; - var objects = users.Select(GetReference); - SysCordSettings.Settings.GlobalSudoList.RemoveAll(z => objects.Any(o => o.ID == z.ID)); - await ReplyAsync("Done.").ConfigureAwait(false); + SysCordSettings.Settings.GlobalSudoList.RemoveAll(z => z.ID == user.Id); + await RespondAsync("Done.").ConfigureAwait(false); } - [Command("addChannel")] - [Summary("Adds a channel to the list of channels that are accepting commands.")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global + [SlashCommand("add-channel", "Adds this channel to the command whitelist.")] + [CommandContextType(InteractionContextType.Guild)] public async Task AddChannel() { - var obj = GetReference(Context.Message.Channel); - SysCordSettings.Settings.ChannelWhitelist.AddIfNew([obj]); - await ReplyAsync("Done.").ConfigureAwait(false); + var c = Context.Interaction.Channel; + SysCordSettings.Settings.ChannelWhitelist.AddIfNew(GetReference(c)); + await RespondAsync("Done.").ConfigureAwait(false); } - [Command("removeChannel")] - [Summary("Removes a channel from the list of channels that are accepting commands.")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global + [SlashCommand("remove-channel", "Removes this channel from the command whitelist.")] + [CommandContextType(InteractionContextType.Guild)] public async Task RemoveChannel() { - var obj = GetReference(Context.Message.Channel); - SysCordSettings.Settings.ChannelWhitelist.RemoveAll(z => z.ID == obj.ID); - await ReplyAsync("Done.").ConfigureAwait(false); + SysCordSettings.Settings.ChannelWhitelist.RemoveAll(z => z.ID == Context.Interaction.Channel.Id); + await RespondAsync("Done.").ConfigureAwait(false); } - [Command("leave")] - [Alias("bye")] - [Summary("Leaves the current server.")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global + [SlashCommand("leave", "Leaves the current server.")] + [CommandContextType(InteractionContextType.Guild)] public async Task Leave() { - await ReplyAsync("Goodbye.").ConfigureAwait(false); - await Context.Guild.LeaveAsync().ConfigureAwait(false); + await RespondAsync("Goodbye.").ConfigureAwait(false); + if (Context.Guild is not null) await Context.Guild.LeaveAsync().ConfigureAwait(false); } - [Command("leaveguild")] - [Alias("lg")] - [Summary("Leaves guild based on supplied ID.")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global - public async Task LeaveGuild(string userInput) + [SlashCommand("leave-guild", "Leaves a guild by ID.")] + public async Task LeaveGuild(string guildId) { - if (!ulong.TryParse(userInput, out ulong id)) + if (!ulong.TryParse(guildId, out var id)) { - await ReplyAsync("Please provide a valid Guild ID.").ConfigureAwait(false); + await RespondAsync("Please provide a valid Guild ID.").ConfigureAwait(false); return; } var guild = Context.Client.Guilds.FirstOrDefault(x => x.Id == id); if (guild is null) { - await ReplyAsync($"Provided input ({userInput}) is not a valid guild ID or the bot is not in the specified guild.").ConfigureAwait(false); + await RespondAsync($"Provided input ({guildId}) is not a valid guild ID or the bot is not in the specified guild.").ConfigureAwait(false); return; } - await ReplyAsync($"Leaving {guild}.").ConfigureAwait(false); + await RespondAsync($"Leaving {guild}.").ConfigureAwait(false); await guild.LeaveAsync().ConfigureAwait(false); } - [Command("leaveall")] - [Summary("Leaves all servers the bot is currently in.")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global + [SlashCommand("leave-all", "Leaves all servers the bot is currently in.")] public async Task LeaveAll() { - await ReplyAsync("Leaving all servers.").ConfigureAwait(false); - foreach (var guild in Context.Client.Guilds) - await guild.LeaveAsync().ConfigureAwait(false); + await RespondAsync("Leaving all servers.").ConfigureAwait(false); + foreach (var guild in Context.Client.Guilds) await guild.LeaveAsync().ConfigureAwait(false); } - [Command("sudoku")] - [Alias("kill", "shutdown")] - [Summary("Causes the entire process to end itself!")] - [RequireOwner] - // ReSharper disable once UnusedParameter.Global + [SlashCommand("shutdown", "Causes the entire process to end itself.")] public async Task ExitProgram() { - await Context.Channel.EchoAndReply("Shutting down... goodbye! **Bot services are going offline.**").ConfigureAwait(false); + await RespondAsync("Shutting down... goodbye! **Bot services are going offline.**").ConfigureAwait(false); Environment.Exit(0); } - - private RemoteControlAccess GetReference(IUser channel) => new() - { - ID = channel.Id, - Name = channel.Username, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; - - private RemoteControlAccess GetReference(IChannel channel) => new() - { - ID = channel.Id, - Name = channel.Name, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; } diff --git a/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs b/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs index 3f4ef0924..853f08519 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs @@ -1,54 +1,33 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; -[Summary("Distribution Pool Module")] -public class PoolModule : ModuleBase where T : PKM, new() +[RequireContext(ContextType.Guild)] +public class PoolModule : SlashModuleBase where T : PKM, new() { - [Command("poolReload")] - [Summary("Reloads the bot pool from the setting's folder.")] - [RequireSudo] - public async Task ReloadPoolAsync() - { - var me = SysCord.Runner; - var hub = me.Hub; - - var pool = hub.Ledy.Pool.Reload(hub.Config.Folder.DistributeFolder); - if (!pool) - await ReplyAsync("Failed to reload from folder.").ConfigureAwait(false); - else - await ReplyAsync($"Reloaded from folder. Pool count: {hub.Ledy.Pool.Count}").ConfigureAwait(false); - } - [Command("pool")] - [Summary("Displays the details of Pokémon files in the random pool.")] + [SlashCommand("pool", "Displays the details of Pokémon files in the random pool.")] public async Task DisplayPoolCountAsync() { - var me = SysCord.Runner; - var hub = me.Hub; - var pool = hub.Ledy.Pool; + var pool = SysCord.Runner.Hub.Ledy.Pool; var count = pool.Count; - if (count is > 0 and < 20) + if (count is <= 0 or >= 20) { - var lines = pool.Files.Select((z, i) => $"{i + 1:00}: {z.Key} = {(Species)z.Value.RequestInfo.Species}"); - var msg = string.Join("\n", lines); - - var embed = new EmbedBuilder(); - embed.AddField(x => - { - x.Name = $"Count: {count}"; - x.Value = msg; - x.IsInline = false; - }); - await ReplyAsync("Pool Details", embed: embed.Build()).ConfigureAwait(false); - } - else - { - await ReplyAsync($"Pool Count: {count}").ConfigureAwait(false); + await RespondAsync($"Pool Count: {count}", ephemeral: true).ConfigureAwait(false); + return; } + + // Display the details of each Pokémon in the pool + var entries = pool.Files.Select((z, i) + => $"{i + 1:00}: {z.Key} = {(Species)z.Value.RequestInfo.Species}"); + + var msg = string.Join('\n', entries); + var embed = new EmbedBuilder(); + embed.AddField($"Count: {count}", msg); + await RespondAsync("Pool Details", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Commands/Management/SudoModule.cs b/SysBot.Pokemon.Discord/Commands/Management/SudoModule.cs deleted file mode 100644 index 72d289759..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/SudoModule.cs +++ /dev/null @@ -1,205 +0,0 @@ -using Discord; -using Discord.Commands; -using PKHeX.Core; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class SudoModule : ModuleBase where T : PKM, new() -{ - [Command("blacklist")] - [Summary("Blacklists a mentioned Discord user.")] - [RequireSudo] - // ReSharper disable once UnusedParameter.Global - public async Task BlackListUsers([Remainder] string _) - { - var users = Context.Message.MentionedUsers; - var objects = users.Select(GetReference); - SysCordSettings.Settings.UserBlacklist.AddIfNew(objects); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("blacklistComment")] - [Summary("Adds a comment for a blacklisted Discord user ID.")] - [RequireSudo] - // ReSharper disable once UnusedParameter.Global - public async Task BlackListUsers(ulong id, [Remainder] string comment) - { - var obj = SysCordSettings.Settings.UserBlacklist.List.Find(z => z.ID == id); - if (obj is null) - { - await ReplyAsync($"Unable to find a user with that ID ({id}).").ConfigureAwait(false); - return; - } - - var oldComment = obj.Comment; - obj.Comment = comment; - await ReplyAsync($"Done. Changed existing comment ({oldComment}) to ({comment}).").ConfigureAwait(false); - } - - [Command("unblacklist")] - [Summary("Removes a mentioned Discord user from the blacklist.")] - [RequireSudo] - // ReSharper disable once UnusedParameter.Global - public async Task UnBlackListUsers([Remainder] string _) - { - var users = Context.Message.MentionedUsers; - var objects = users.Select(GetReference); - SysCordSettings.Settings.UserBlacklist.RemoveAll(z => objects.Any(o => o.ID == z.ID)); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("blacklistId")] - [Summary("Blacklists Discord user IDs. (Useful if user is not in the server).")] - [RequireSudo] - public async Task BlackListIDs([Summary("Comma Separated Discord IDs")][Remainder] string content) - { - var IDs = GetIDs(content); - var objects = IDs.Select(GetReference); - SysCordSettings.Settings.UserBlacklist.AddIfNew(objects); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("unBlacklistId")] - [Summary("Removes Discord user IDs from the blacklist. (Useful if user is not in the server).")] - [RequireSudo] - public async Task UnBlackListIDs([Summary("Comma Separated Discord IDs")][Remainder] string content) - { - var IDs = GetIDs(content); - SysCordSettings.Settings.UserBlacklist.RemoveAll(z => IDs.Any(o => o == z.ID)); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("blacklistSummary")] - [Alias("printBlacklist", "blacklistPrint")] - [Summary("Prints the list of blacklisted Discord users.")] - [RequireSudo] - public async Task PrintBlacklist() - { - var lines = SysCordSettings.Settings.UserBlacklist.Summarize(); - var msg = string.Join("\n", lines); - await ReplyAsync(Format.Code(msg)).ConfigureAwait(false); - } - - [Command("banID")] - [Summary("Bans online user IDs.")] - [RequireSudo] - public async Task BanOnlineIDs([Summary("Comma Separated Online IDs")][Remainder] string content) - { - var IDs = GetIDs(content); - var objects = IDs.Select(GetReference); - - var me = SysCord.Runner; - var hub = me.Hub; - hub.Config.TradeAbuse.BannedIDs.AddIfNew(objects); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("bannedIDComment")] - [Summary("Adds a comment for a banned online user ID.")] - [RequireSudo] - public async Task BanOnlineIDs(ulong id, [Remainder] string comment) - { - var me = SysCord.Runner; - var hub = me.Hub; - var obj = hub.Config.TradeAbuse.BannedIDs.List.Find(z => z.ID == id); - if (obj is null) - { - await ReplyAsync($"Unable to find a user with that online ID ({id}).").ConfigureAwait(false); - return; - } - - var oldComment = obj.Comment; - obj.Comment = comment; - await ReplyAsync($"Done. Changed existing comment ({oldComment}) to ({comment}).").ConfigureAwait(false); - } - - [Command("unbanID")] - [Summary("Bans online user IDs.")] - [RequireSudo] - public async Task UnBanOnlineIDs([Summary("Comma Separated Online IDs")][Remainder] string content) - { - var IDs = GetIDs(content); - var me = SysCord.Runner; - var hub = me.Hub; - hub.Config.TradeAbuse.BannedIDs.RemoveAll(z => IDs.Any(o => o == z.ID)); - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("bannedIDSummary")] - [Alias("printBannedID", "bannedIDPrint")] - [Summary("Prints the list of banned online IDs.")] - [RequireSudo] - public async Task PrintBannedOnlineIDs() - { - var me = SysCord.Runner; - var hub = me.Hub; - var lines = hub.Config.TradeAbuse.BannedIDs.Summarize(); - var msg = string.Join("\n", lines); - await ReplyAsync(Format.Code(msg)).ConfigureAwait(false); - } - - [Command("forgetUser")] - [Alias("forget")] - [Summary("Forgets users that were previously encountered.")] - [RequireSudo] - public async Task ForgetPreviousUser([Summary("Comma Separated Online IDs")][Remainder] string content) - { - var IDs = GetIDs(content); - foreach (var ID in IDs) - { - PokeRoutineExecutorBase.PreviousUsers.RemoveAllNID(ID); - PokeRoutineExecutorBase.PreviousUsersDistribution.RemoveAllNID(ID); - } - await ReplyAsync("Done.").ConfigureAwait(false); - } - - [Command("previousUserSummary")] - [Alias("prevUsers")] - [Summary("Prints a list of previously encountered users.")] - [RequireSudo] - public async Task PrintPreviousUsers() - { - bool found = false; - var lines = PokeRoutineExecutorBase.PreviousUsers.Summarize().ToList(); - if (lines.Count != 0) - { - found = true; - var msg = "Previous Users:\n" + string.Join("\n", lines); - await ReplyAsync(Format.Code(msg)).ConfigureAwait(false); - } - - lines = PokeRoutineExecutorBase.PreviousUsersDistribution.Summarize().ToList(); - if (lines.Count != 0) - { - found = true; - var msg = "Previous Distribution Users:\n" + string.Join("\n", lines); - await ReplyAsync(Format.Code(msg)).ConfigureAwait(false); - } - if (!found) - await ReplyAsync("No previous users found.").ConfigureAwait(false); - } - - private RemoteControlAccess GetReference(IUser channel) => new() - { - ID = channel.Id, - Name = channel.Username, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; - - private RemoteControlAccess GetReference(ulong id) => new() - { - ID = id, - Name = "Manual", - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; - - protected static IEnumerable GetIDs(string content) - { - return content.Split([",", ", ", " "], StringSplitOptions.RemoveEmptyEntries) - .Select(z => ulong.TryParse(z, out var x) ? x : 0).Where(z => z != 0); - } -} diff --git a/SysBot.Pokemon.Discord/Commands/Management/TradeStartModule.cs b/SysBot.Pokemon.Discord/Commands/Management/TradeStartModule.cs deleted file mode 100644 index 67e52309b..000000000 --- a/SysBot.Pokemon.Discord/Commands/Management/TradeStartModule.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Discord; -using Discord.Commands; -using Discord.WebSocket; -using PKHeX.Core; -using SysBot.Base; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public class TradeStartModule : ModuleBase where T : PKM, new() -{ - private class TradeStartAction(ulong ChannelId, Action> messager, string channel) - : ChannelAction>(ChannelId, messager, channel); - - private static readonly Dictionary Channels = []; - - private static void Remove(TradeStartAction entry) - { - Channels.Remove(entry.ChannelID); - SysCord.Runner.Hub.Queues.Forwarders.Remove(entry.Action); - } - -#pragma warning disable RCS1158 // Static member in generic type should use a type parameter. - public static void RestoreTradeStarting(DiscordSocketClient discord) - { - var cfg = SysCordSettings.Settings; - foreach (var ch in cfg.TradeStartingChannels) - { - if (discord.GetChannel(ch.ID) is ISocketMessageChannel c) - AddLogChannel(c, ch.ID); - } - - LogUtil.LogInfo("Added Trade Start Notification to Discord channel(s) on Bot startup.", "Discord"); - } - - public static bool IsStartChannel(ulong cid) -#pragma warning restore RCS1158 // Static member in generic type should use a type parameter. - { - return Channels.TryGetValue(cid, out _); - } - - [Command("startHere")] - [Summary("Makes the bot log trade starts to the channel.")] - [RequireSudo] - public async Task AddLogAsync() - { - var c = Context.Channel; - var cid = c.Id; - if (Channels.TryGetValue(cid, out _)) - { - await ReplyAsync("Already logging here.").ConfigureAwait(false); - return; - } - - AddLogChannel(c, cid); - - // Add to discord global loggers (saves on program close) - SysCordSettings.Settings.TradeStartingChannels.AddIfNew([GetReference(Context.Channel)]); - await ReplyAsync("Added Start Notification output to this channel!").ConfigureAwait(false); - } - - private static void AddLogChannel(ISocketMessageChannel c, ulong cid) - { - void Logger(PokeRoutineExecutorBase bot, PokeTradeDetail detail) - { - if (detail.Type == PokeTradeType.Random) - return; - c.SendMessageAsync(GetMessage(bot, detail)); - } - - Action> l = Logger; - SysCord.Runner.Hub.Queues.Forwarders.Add(l); - static string GetMessage(PokeRoutineExecutorBase bot, PokeTradeDetail detail) => $"> [{DateTime.Now:hh:mm:ss}] - {bot.Connection.Label} is now trading (ID {detail.ID}) {detail.Trainer.TrainerName}"; - - var entry = new TradeStartAction(cid, l, c.Name); - Channels.Add(cid, entry); - } - - [Command("startInfo")] - [Summary("Dumps the Start Notification settings.")] - [RequireSudo] - public async Task DumpLogInfoAsync() - { - foreach (var c in Channels) - await ReplyAsync($"{c.Key} - {c.Value}").ConfigureAwait(false); - } - - [Command("startClear")] - [Summary("Clears the Start Notification settings in that specific channel.")] - [RequireSudo] - public async Task ClearLogsAsync() - { - var cfg = SysCordSettings.Settings; - if (Channels.TryGetValue(Context.Channel.Id, out var entry)) - Remove(entry); - cfg.TradeStartingChannels.RemoveAll(z => z.ID == Context.Channel.Id); - await ReplyAsync($"Start Notifications cleared from channel: {Context.Channel.Name}").ConfigureAwait(false); - } - - [Command("startClearAll")] - [Summary("Clears all the Start Notification settings.")] - [RequireSudo] - public async Task ClearLogsAllAsync() - { - foreach (var l in Channels) - { - var entry = l.Value; - await ReplyAsync($"Logging cleared from {entry.ChannelName} ({entry.ChannelID}!").ConfigureAwait(false); - SysCord.Runner.Hub.Queues.Forwarders.Remove(entry.Action); - } - Channels.Clear(); - SysCordSettings.Settings.TradeStartingChannels.Clear(); - await ReplyAsync("Start Notifications cleared from all channels!").ConfigureAwait(false); - } - - private RemoteControlAccess GetReference(IChannel channel) => new() - { - ID = channel.Id, - Name = channel.Name, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; -} diff --git a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs new file mode 100644 index 000000000..ea895a329 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs @@ -0,0 +1,130 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; + +namespace SysBot.Pokemon.Discord; + +[DefaultMemberPermissions(GuildPermission.SendMessages)] +[RequireBotPermission(GuildPermission.SendMessages)] +public abstract class SlashModuleBase : InteractionModuleBase +{ + private static DiscordManager Manager => SysCordSettings.Manager; + + protected bool CheckSudo([NotNullWhen(false)] out string? error) + { + if (Manager.Config.AllowGlobalSudo && Manager.CanUseSudo(Context.User.Id)) + { + error = null; + return true; + } + + if (Context.User is not SocketGuildUser guildUser) + { + error = "You must be in a guild to run this command."; + return false; + } + + if (Manager.CanUseSudo(guildUser.Roles.Select(z => z.Name))) + { + error = null; + return true; + } + + error = "You are not permitted to run this command."; + return false; + } + + protected bool CheckRoleAccess(PokeRoutineType type, [NotNullWhen(false)] out string? error) + { + if (Context.User is not SocketGuildUser guildUser) + { + error = "You must be in a guild to run this command."; + return false; + } + + if (Manager.GetHasRoleAccess(type, guildUser.Roles.Select(z => z.Name))) + { + error = null; + return true; + } + + error = "You do not have the required role to run this command."; + return false; + } + + protected bool CheckQueueAccess(PokeRoutineType type, [NotNullWhen(false)] out string? error) + { + if (Manager.Config.AllowGlobalSudo && Manager.CanUseSudo(Context.User.Id)) + { + error = null; + return true; + } + + if (Context.User is not SocketGuildUser guildUser) + { + error = "You must be in a guild to run this command."; + return false; + } + + var roles = guildUser.Roles.Select(z => z.Name).ToArray(); + if (Manager.CanUseSudo(roles)) + { + error = null; + return true; + } + + if (!SysCordSettings.HubConfig.Queues.CanQueue) + { + error = "Sorry, I am not currently accepting queue requests!"; + return false; + } + + if (!Manager.GetHasRoleAccess(type, roles)) + { + error = "You do not have the required role to run this command."; + return false; + } + + error = null; + return true; + } + + protected async Task RequireAsync(bool allowed, string? error) + { + if (allowed) + return true; + + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + return false; + } + + // ReSharper disable once MemberCanBeMadeStatic.Global + protected RequestSignificance GetSignificance(SocketUser user) + { + // Check user ID. + var userId = user.Id; + if (userId == Manager.Owner) + return RequestSignificance.Owner; + if (Manager.CanUseSudo(userId)) + return RequestSignificance.Favored; + + // Check roles, might be a special role granted. + // Stringy names are for user convenience; must trust externally managed guilds the bot is added to (else we should use role IDs). + return user is SocketGuildUser g + ? Manager.GetSignificance(g.Roles.Select(z => z.Name)) + : RequestSignificance.None; + } + + protected RemoteControlAccess GetReference(IChannel channel) => GetReference(channel.Id, channel.Name); + protected RemoteControlAccess GetReference(IUser user) => GetReference(user.Id, user.Username); + protected RemoteControlAccess GetReference(ulong id, string name = "Manual") => new() + { + ID = id, + Name = name, + Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", + }; +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs new file mode 100644 index 000000000..4453cddf2 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[Group("bot", "Bot manual control commands.")] +public class BotModule : SudoModuleBase where T : PKM, new() +{ + [SlashCommand("status", "Gets the status of the bots.")] + public async Task GetStatusAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var sb = new StringBuilder(); + foreach (var bot in SysCord.Runner.Bots) + { + if (bot.Bot is PokeRoutineExecutorBase b) + sb.AppendLine(GetDetailedSummary(b)); + } + + await RespondAsync(sb.Length == 0 ? "No bots configured." : Format.Code(sb.ToString())).ConfigureAwait(false); + } + + [SlashCommand("start", "Starts a bot by IP address/port.")] + public async Task StartBotAsync(string ip) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + bot.Start(); + await RespondAsync($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Start.").ConfigureAwait(false); + } + + [SlashCommand("stop", "Stops a bot by IP address/port.")] + public async Task StopBotAsync(string ip) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + bot.Stop(); + await RespondAsync($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Stop.").ConfigureAwait(false); + } + + [SlashCommand("idle", "Commands a bot to Idle by IP address/port.")] + public async Task IdleBotAsync(string ip) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + bot.Pause(); + await RespondAsync($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to Idle.").ConfigureAwait(false); + } + + [SlashCommand("change", "Changes the routine of a bot.")] + public async Task ChangeTaskAsync(string ip, PokeRoutineType task) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + bot.Bot.Config.Initialize(task); + await RespondAsync($"The bot at {ip} ({bot.Bot.Connection.Label}) has been commanded to do {task} as its next task.").ConfigureAwait(false); + } + + [SlashCommand("restart", "Restarts bots by comma-separated IP addresses.")] + public async Task RestartBotAsync(string ipAddressesCommaSeparated) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var messages = new List(); + foreach (var ip in ipAddressesCommaSeparated.Split(',')) + { + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + messages.Add($"No bot has that IP address ({ip})."); + continue; + } + + var c = bot.Bot.Connection; + c.Reset(); + bot.Start(); + messages.Add($"The bot at {ip} ({c.Label}) has been commanded to Restart."); + } + + await RespondAsync(string.Join('\n', messages)).ConfigureAwait(false); + } + private static string GetDetailedSummary(TBot z) where TBot : PokeRoutineExecutorBase => + $"- {z.Connection.Name} | {z.Connection.Label} - {z.Config.CurrentRoutineType} ~ {z.LastTime:hh:mm:ss} | {z.LastLogged}"; +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs new file mode 100644 index 000000000..6623d1682 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord.Interactions; +using Discord.WebSocket; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("echo", "Control over bot echoes.")] +public class EchoModule : SudoModuleBase +{ + // ReSharper disable NotAccessedPositionalProperty.Local + private record EchoChannel(ulong ChannelId, string ChannelName, Action Action); + // ReSharper enable NotAccessedPositionalProperty.Local + + private static readonly Dictionary Channels = []; + + public static void RestoreChannels(DiscordSocketClient discord, DiscordSettings cfg) + { + foreach (var ch in cfg.EchoChannels) + { + if (discord.GetChannel(ch.ID) is ISocketMessageChannel channel) + AddEchoChannel(channel, ch.ID); + } + + EchoUtil.Echo("Added echo notification to Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot echo special messages to this channel.")] + public async Task AddEchoAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + if (Context.Interaction.Channel is not { } channel) + { + await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); + return; + } + + var channelId = channel.Id; + if (Channels.ContainsKey(channelId)) + { + await RespondAsync("Already notifying here.").ConfigureAwait(false); + return; + } + + AddEchoChannel(channel, channelId); + SysCordSettings.Settings.EchoChannels.AddIfNew(GetReference(channel)); + await RespondAsync("Added Echo output to this channel!").ConfigureAwait(false); + } + private static void AddEchoChannel(ISocketMessageChannel channel, ulong channelId) + { + var l = Echo; + EchoUtil.Forwarders.Add(l); + Channels.Add(channelId, new EchoChannel(channelId, channel.Name, l)); + return; + + void Echo(string message) => channel.SendMessageAsync(message); + } + + public static bool IsEchoChannel(ISocketMessageChannel channel) => Channels.ContainsKey(channel.Id); + + [SlashCommand("info", "Dumps the Echo settings.")] + public async Task DumpEchoInfoAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); + } + + [SlashCommand("clear", "Clears Echo settings from this channel.")] + public async Task ClearEchosAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var channelId = Context.Interaction.Channel.Id; + if (!Channels.TryGetValue(channelId, out var echo)) + { + await RespondAsync("Not echoing in this channel.").ConfigureAwait(false); + return; + } + + EchoUtil.Forwarders.Remove(echo.Action); + Channels.Remove(channelId); + SysCordSettings.Settings.EchoChannels.RemoveAll(z => z.ID == channelId); + await RespondAsync($"Echoes cleared from channel: {Context.Interaction.Channel.Name}").ConfigureAwait(false); + } + + [SlashCommand("clear-all", "Clears all Echo channel settings.")] + public async Task ClearEchosAllAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + foreach (var l in Channels.Values) + EchoUtil.Forwarders.Remove(l.Action); + + Channels.Clear(); + SysCordSettings.Settings.EchoChannels.Clear(); + await RespondAsync("Echoes cleared from all channels!").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs new file mode 100644 index 000000000..dc9098de8 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs @@ -0,0 +1,27 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[Group("encounter", "Control over an encounter bot.")] +public class EncounterModule : SudoModuleBase where T : PKM, new() +{ + [SlashCommand("toss", "Makes waiting bots continue operation.")] + public async Task TossAsync(string name = "") + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + foreach (var b in SysCord.Runner.Bots.Select(z => z.Bot)) + { + if (!b.Connection.Name.Contains(name) && !b.Connection.Label.Contains(name)) + continue; + if (b is IEncounterBot enc) + enc.Acknowledge(); + } + + await RespondAsync("Done.").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs new file mode 100644 index 000000000..8d98f93c3 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord.Interactions; +using Discord.WebSocket; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("log", "Logging commands.")] +public class LogModule : SudoModuleBase +{ + private static readonly Dictionary Channels=[]; + public static void RestoreLogging(DiscordSocketClient discord, DiscordSettings settings) + { + foreach (var controlAccess in settings.LoggingChannels) + { + if (discord.GetChannel(controlAccess.ID) is ISocketMessageChannel c) + AddLogChannel(c, controlAccess.ID); + } + + LogUtil.LogInfo("Added logging to Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot log to this channel.")] + public async Task AddLogAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + if (Context.Interaction.Channel is not { } channel) + { + await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); + return; + } + + var channelId = channel.Id; + if (Channels.ContainsKey(channelId)) + { + await RespondAsync("Already logging here.").ConfigureAwait(false); + return; + } + + AddLogChannel(channel, channelId); + SysCordSettings.Settings.LoggingChannels.AddIfNew(GetReference(channel)); + await RespondAsync("Added logging output to this channel!").ConfigureAwait(false); + } + + private static void AddLogChannel(ISocketMessageChannel channel, ulong channelId) + { + var logger = new ChannelLogger(channel); + LogUtil.Forwarders.Add(logger); + Channels.Add(channelId, logger); + } + + [SlashCommand("info", "Dumps the logging settings.")] + public async Task DumpLogInfoAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); + } + + [SlashCommand("clear", "Clears logging from this channel.")] + public async Task ClearLogsAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var channelId = Context.Interaction.Channel.Id; + if (!Channels.TryGetValue(channelId, out var log)) + { + await RespondAsync("Not echoing in this channel.").ConfigureAwait(false); + return; + } + LogUtil.Forwarders.Remove(log); + Channels.Remove(channelId); + SysCordSettings.Settings.LoggingChannels.RemoveAll(z => z.ID == channelId); + await RespondAsync($"Logging cleared from channel: {Context.Interaction.Channel.Name}").ConfigureAwait(false); + } + + [SlashCommand("clear-all", "Clears all logging settings.")] + public async Task ClearLogsAllAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + foreach (var l in Channels.Values) + LogUtil.Forwarders.Remove(l); + + Channels.Clear(); + SysCordSettings.Settings.LoggingChannels.Clear(); + await RespondAsync("Logging cleared from all channels!").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs new file mode 100644 index 000000000..1831ddf98 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs @@ -0,0 +1,163 @@ +using System; +using System.Threading.Tasks; +using Discord.Interactions; +using PKHeX.Core; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("control", "Commands related to controlling the console itself.")] +public class RemoteControlModule : SudoModuleBase where T : PKM, new() +{ + // Roughly requires Sudo permissions, but checks a separate role for remote control commands. + + private async Task CheckPermission() + { + var check = CheckRoleAccess(PokeRoutineType.RemoteControl, out var e); + return await RequireAsync(check, e).ConfigureAwait(false); + } + + [SlashCommand("click", "Clicks the specified button.")] + public async Task ClickAsync( + [Summary(nameof(button), "The button to press.")] SwitchButton button) + { + if (!await CheckPermission().ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); + if (bot == null) + { + await RespondAsync($"No bot is available to execute your command: {button}").ConfigureAwait(false); + return; + } + + await ClickAsyncImpl(button, bot).ConfigureAwait(false); + } + + [SlashCommand("click-ip", "Clicks a button on a specific bot.")] + public async Task ClickAsync( + [Summary(nameof(ip), "Which bot to perform the command on.")] string ip, + [Summary(nameof(button), "The button to press.")] SwitchButton button) + { + if (await CheckPermission().ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot is available to execute your command: {button}").ConfigureAwait(false); + return; + } + + await ClickAsyncImpl(button, bot).ConfigureAwait(false); + } + + [SlashCommand("set-stick", "Sets the stick to the specified position.")] + public async Task SetStickAsync( + [Summary(nameof(stick), "Which control stick to adjust.")] SwitchStick stick = SwitchStick.LEFT, + [Summary(nameof(x), "The X position of the stick angle.")] short x = 0, + [Summary(nameof(y), "The Y position of the stick angle.")] short y = 0, + [Summary(nameof(ms), "The duration to hold the stick in the position. Leave blank for infinite duration until changed")] ushort? ms = 1000) + { + if (await CheckPermission().ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); + if (bot == null) + { + await RespondAsync($"No bot is available to execute your command: {stick}").ConfigureAwait(false); + return; + } + + await SetStickAsyncImpl(stick, x, y, ms, bot).ConfigureAwait(false); + } + + [SlashCommand("set-stick-ip", "Sets a stick on a specific bot.")] + public async Task SetStickAsync( + [Summary(nameof(ip), "Which bot to perform the command on.")] string ip, + [Summary(nameof(stick), "Which control stick to adjust.")] SwitchStick stick = SwitchStick.LEFT, + [Summary(nameof(x), "The X position of the stick angle.")] short x = 0, + [Summary(nameof(y), "The Y position of the stick angle.")] short y = 0, + [Summary(nameof(ms), "The duration to hold the stick in the position. Leave blank for infinite duration until changed.")] ushort? ms = 1000) + { + if (await CheckPermission().ConfigureAwait(false)) + return; + + var bot = SysCord.Runner.GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + await SetStickAsyncImpl(stick, x, y, ms, bot).ConfigureAwait(false); + } + + [SlashCommand("screen", "Updates the console screen powered-on state.")] + public Task SetScreenAsync( + [Summary(nameof(ip), "Which bot to perform the command on.")] string ip, + [Summary(nameof(on), "The desired screen powered-on state.")] bool on = true) + => SetScreenGuarded(on, ip); + + private async Task SetScreenGuarded(bool on, string ip) + { + if (await CheckPermission().ConfigureAwait(false)) + return; + + var bot = GetBot(ip); + if (bot == null) + { + await RespondAsync($"No bot has that IP address ({ip}).").ConfigureAwait(false); + return; + } + + var b = bot.Bot; + var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; + var cmd = SwitchCommand.SetScreen(on ? ScreenState.On : ScreenState.Off, crlf); + await b.Connection.SendAsync(cmd).ConfigureAwait(false); + await RespondAsync("Screen state set to: " + (on ? "On" : "Off")).ConfigureAwait(false); + } + + private async Task ClickAsyncImpl(SwitchButton button, BotSource bot) + { + if (!Enum.IsDefined(button)) + { + await RespondAsync($"Unknown button value: {button}").ConfigureAwait(false); + return; + } + + var b = bot.Bot; + var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; + await b.Connection.SendAsync(SwitchCommand.Click(button, crlf)).ConfigureAwait(false); + await RespondAsync($"{b.Connection.Name} has performed: {button}").ConfigureAwait(false); + } + + private async Task SetStickAsyncImpl(SwitchStick s, short x, short y, ushort? ms, BotSource bot) + { + if (!Enum.IsDefined(s)) + { + await RespondAsync($"Unknown stick: {s}").ConfigureAwait(false); + return; + } + + var b = bot.Bot; + var crlf = b is SwitchRoutineExecutor { UseCRLF: true }; + await b.Connection.SendAsync(SwitchCommand.SetStick(s, x, y, crlf)).ConfigureAwait(false); + if (ms is not { } value) + { + await RespondAsync($"{b.Connection.Name} has performed: {s} and will hold the position until changed.").ConfigureAwait(false); + return; + } + await Task.Delay(value).ConfigureAwait(false); + await b.Connection.SendAsync(SwitchCommand.ResetStick(s, crlf)).ConfigureAwait(false); + await RespondAsync($"{b.Connection.Name} has performed: {s} and reset the stick position.").ConfigureAwait(false); + } + + private static BotSource? GetBot(string ip) + { + var r = SysCord.Runner; + return r.GetBot(ip) ?? r.Bots.Find(x => x.IsRunning); + } + + private static bool IsRemoteControlBot(RoutineExecutor b) => b is RemoteControlBotSWSH or RemoteControlBotBS or RemoteControlBotLA or RemoteControlBotSV or RemoteControlBotLZA; +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs new file mode 100644 index 000000000..3728f25d4 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[Group("sudo", "Power-user commands.")] +public class SudoModule : SudoModuleBase where T : PKM, new() +{ + [SlashCommand("blacklist-user", "Blacklists a Discord user.")] + public async Task BlackListUser(IUser user) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + SysCordSettings.Settings.UserBlacklist.AddIfNew(GetReference(user)); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("blacklist-comment", "Adds a comment for a blacklisted Discord user ID.")] + public async Task BlackListComment(ulong id, string comment) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var obj = SysCordSettings.Settings.UserBlacklist.List.Find(z => z.ID == id); + if (obj is null) + { + await RespondAsync($"Unable to find a user with that ID ({id}).").ConfigureAwait(false); + return; + } + + var old = obj.Comment; + obj.Comment = comment; + await RespondAsync($"Done. Changed existing comment ({old}) to ({comment}).").ConfigureAwait(false); + } + + [SlashCommand("unblacklist-user", "Removes a Discord user from the blacklist.")] + public async Task UnBlackListUser(IUser user) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + SysCordSettings.Settings.UserBlacklist.RemoveAll(z => z.ID == user.Id); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("blacklist-ids", "Blacklists comma-separated Discord user IDs.")] + public async Task BlackListIDs(string ids) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + SysCordSettings.Settings.UserBlacklist.AddIfNew(GetIDs(ids).Select(z => GetReference(z, nameof(BlackListIDs)))); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("unblacklist-ids", "Removes comma-separated Discord user IDs from the blacklist.")] + public async Task UnBlackListIDs(string ids) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var set = GetIDs(ids).ToHashSet(); + SysCordSettings.Settings.UserBlacklist.RemoveAll(z => set.Contains(z.ID)); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("blacklist-summary", "Prints the list of blacklisted Discord users.")] + public async Task PrintBlacklist() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + await RespondAsync(Format.Code(string.Join('\n', SysCordSettings.Settings.UserBlacklist.Summarize()))).ConfigureAwait(false); + } + + [SlashCommand("ban-ids", "Bans comma-separated online user IDs.")] + public async Task BanOnlineIDs(string ids) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.AddIfNew(GetIDs(ids).Select(z => GetReference(z, nameof(BanOnlineIDs)))); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("banned-id-comment", "Adds a comment for a banned online user ID.")] + public async Task BanOnlineIDComment(ulong id, string comment) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var obj = SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.List.Find(z => z.ID == id); + if (obj is null) + { + await RespondAsync($"Unable to find a user with that online ID ({id}).").ConfigureAwait(false); + return; + } + var old = obj.Comment; + obj.Comment = comment; + await RespondAsync($"Done. Changed existing comment ({old}) to ({comment}).").ConfigureAwait(false); + } + + [SlashCommand("unban-ids", "Removes comma-separated online IDs from the ban list.")] + public async Task UnBanOnlineIDs(string ids) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + var set = GetIDs(ids).ToHashSet(); + SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.RemoveAll(z => set.Contains(z.ID)); + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("banned-id-summary", "Prints the list of banned online IDs.")] + public async Task PrintBannedOnlineIDs() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + await RespondAsync(Format.Code(string.Join('\n', SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.Summarize()))).ConfigureAwait(false); + } + + [SlashCommand("forget-user", "Forgets previously encountered online IDs.")] + public async Task ForgetPreviousUser(string ids) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + foreach (var id in GetIDs(ids)) + { + PokeRoutineExecutorBase.PreviousUsers.RemoveAllNID(id); + PokeRoutineExecutorBase.PreviousUsersDistribution.RemoveAllNID(id); + } + await RespondAsync("Done.").ConfigureAwait(false); + } + + [SlashCommand("previous-user-summary", "Prints previously encountered users.")] + public async Task PrintPreviousUsers() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var messages = new List(); + List lines = [.. PokeRoutineExecutorBase.PreviousUsers.Summarize()]; + if (lines.Count != 0) + messages.Add(Format.Code("Previous Users:\n" + string.Join('\n', lines))); + + lines = [.. PokeRoutineExecutorBase.PreviousUsersDistribution.Summarize()]; + if (lines.Count != 0) + messages.Add(Format.Code("Previous Distribution Users:\n" + string.Join('\n', lines))); + + await RespondAsync(messages.Count == 0 ? "No previous users found." : string.Join('\n', messages)).ConfigureAwait(false); + } + + private static IEnumerable GetIDs(string content) + { + return content.Split([",", ", ", " "], StringSplitOptions.RemoveEmptyEntries) + .Select(z => ulong.TryParse(z, out var x) ? x : 0).Where(z => z != 0); + } + + [SlashCommand("pool-reload", "Reloads the bot pool from the configured folder.")] + public async Task ReloadPoolAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var hub = SysCord.Runner.Hub; + var ok = hub.Ledy.Pool.Reload(hub.Config.Folder.DistributeFolder); + await RespondAsync(ok + ? $"Reloaded from folder. Pool count: {hub.Ledy.Pool.Count}" + : "Failed to reload from folder.").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs new file mode 100644 index 000000000..6758993bd --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs @@ -0,0 +1,9 @@ +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +[RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check +[CommandContextType(InteractionContextType.Guild)] // must run these inside a guild, not in DMs (more auditable). +[RequireContext(ContextType.Guild)] +public abstract class SudoModuleBase : SlashModuleBase; diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs new file mode 100644 index 000000000..70cc7f07e --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Discord.Interactions; +using Discord.WebSocket; +using PKHeX.Core; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("start", "Trade Start notification command settings.")] +public class TradeStartModule : SudoModuleBase where T : PKM, new() +{ + private record TradeStartAction(ulong ChannelId, Action> Messager, string ChannelName) + : ChannelAction>(ChannelId, Messager, ChannelName); + + private static readonly Dictionary Channels = []; + public static bool IsStartChannel(ulong channelId) => Channels.ContainsKey(channelId); + + private static void Remove(TradeStartAction e) + { + Channels.Remove(e.ChannelId); + SysCord.Runner.Hub.Queues.Forwarders.Remove(e.Messager); + } + + public static void RestoreTradeStarting(DiscordSocketClient discord) + { + foreach (var channelAccess in SysCordSettings.Settings.TradeStartingChannels) + { + if (discord.GetChannel(channelAccess.ID) is ISocketMessageChannel channel) + AddLogChannel(channel, channelAccess.ID); + } + + LogUtil.LogInfo("Added Trade Start Notification to Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot log trade starts to this channel.")] + public async Task AddLogAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + if (Context.Interaction.Channel is not { } channel) + { + await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); + return; + } + + var channelId = channel.Id; + if (Channels.ContainsKey(channelId)) + { + await RespondAsync("Already logging here.").ConfigureAwait(false); + return; + } + + AddLogChannel(channel, channelId); + SysCordSettings.Settings.TradeStartingChannels.AddIfNew(GetReference(channel)); + await RespondAsync("Added Start Notification output to this channel!").ConfigureAwait(false); + } + + private static void AddLogChannel(ISocketMessageChannel c, ulong channelId) + { + var l = Logger; + SysCord.Runner.Hub.Queues.Forwarders.Add(l); + Channels.Add(channelId, new TradeStartAction(channelId, l, c.Name)); + return; + + void Logger(PokeRoutineExecutorBase bot, PokeTradeDetail detail) + { + if (detail.Type != PokeTradeType.Random) + _ = c.SendMessageAsync($"> [{DateTime.Now:hh:mm:ss}] - {bot.Connection.Label} is now trading (ID {detail.Id}) {detail.Trainer.TrainerName}"); + } + } + + [SlashCommand("info", "Dumps the Start Notification settings.")] + public async Task DumpLogInfoAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); + } + + [SlashCommand("clear", "Clears Start Notification settings from this channel.")] + public async Task ClearLogsAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var id = Context.Interaction.Channel.Id; + if (Channels.TryGetValue(id, out var entry)) + Remove(entry); + SysCordSettings.Settings.TradeStartingChannels.RemoveAll(z => z.ID == id); + await RespondAsync($"Start Notifications cleared from channel: {Context.Interaction.Channel.Name}").ConfigureAwait(false); + } + + [SlashCommand("clear-all", "Clears all Start Notification settings.")] + public async Task ClearLogsAllAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + foreach (var entry in Channels.Values) + SysCord.Runner.Hub.Queues.Forwarders.Remove(entry.Messager); + + Channels.Clear(); + SysCordSettings.Settings.TradeStartingChannels.Clear(); + await RespondAsync("Start Notifications cleared from all channels!").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs new file mode 100644 index 000000000..1f3ec0afc --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[RequireContext(ContextType.Guild)] +public class CloneModule : SlashModuleBase where T : PKM, new() +{ + private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + + [SlashCommand("clone", "Clones the Pokémon you show via Link Trade.")] + public Task CloneAsync(int? code = null) => JoinAsync(code); + private async Task JoinAsync(int? code) + { + if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Clone, out var e), e).ConfigureAwait(false)) + return; + + var sig = GetSignificance(Context.User); + code ??= Info.GetRandomTradeCode(); + await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Clone, PokeTradeType.Clone, Context).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("clone-list", "Prints the users in the Clone queue.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task GetListAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var embed = new EmbedBuilder(); + embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.Clone)); + await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); + } + +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs new file mode 100644 index 000000000..3f86d1387 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -0,0 +1,49 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[RequireContext(ContextType.Guild)] +public class DumpModule : SlashModuleBase where T : PKM, new() +{ + private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + + [SlashCommand("dump", "Dumps the Pokémon you show via Link Trade.")] + public async Task DumpAsync( + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) + { + if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Dump, out var error), error).ConfigureAwait(false)) + return; + + var sig = GetSignificance(Context.User); + code ??= Info.GetRandomTradeCode(); + await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Dump, PokeTradeType.Dump, Context).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("dump-list", "Prints the users in the Dump queue.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task GetListAsync() + { + if (!await RequireAsync(CheckSudo(out var error), error).ConfigureAwait(false)) + return; + + string msg = Info.GetTradeList(PokeRoutineType.Dump); + var embed = new EmbedBuilder(); + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = msg; + x.IsInline = false; + }); + + await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs new file mode 100644 index 000000000..9e99e7007 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs @@ -0,0 +1,95 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[Group("queue", "Commands to interact with the trade queue.")] +[RequireContext(ContextType.Guild)] +public class QueueModule : SlashModuleBase where T : PKM, new() +{ + private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + + [SlashCommand("status", "Checks the user's position in the queue.")] + public Task GetTradePositionAsync() + => RespondAsync(Context.User.Mention + " - " + Info.GetPositionString(Context.User.Id)); + + [SlashCommand("clear", "Clears yourself from the trade queues.")] + public Task ClearTradeAsync() + => RespondAsync(GetClearTradeMessage(Info.ClearTrade(Context.User.Id))); + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("clear-user", "Clears a user from the trade queues.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task ClearTradeUserAsync( + [Summary(nameof(user), "The user to clear from the trade queues.")] IUser user) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var message = GetClearTradeMessage(Info.ClearTrade(user.Id)); + await RespondAsync(message).ConfigureAwait(false); + } + + [SlashCommand("clear-all", "Clears all users from the trade queues.")] + public async Task ClearAllTradesAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + Info.ClearAllQueues(); + await RespondAsync("Cleared all in the queue.").ConfigureAwait(false); + } + + [SlashCommand("toggle", "Toggles on/off the ability to join the trade queue.")] + public async Task ToggleQueueTradeAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var state = Info.ToggleQueue(); + var message = state + ? "Users are now able to join the trade queue." + : "Changed queue settings: **Users CANNOT join the queue until it is turned back on.**"; + + await RespondAsync(message).ConfigureAwait(false); + } + + [SlashCommand("mode", "Changes how queueing is controlled.")] + public async Task ChangeQueueModeAsync( + [Summary(nameof(mode), "The mode to set for queueing.")] QueueOpening mode) + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + SysCord.Runner.Hub.Config.Queues.QueueToggleMode = mode; + await RespondAsync($"Changed queue mode to {mode}.").ConfigureAwait(false); + } + + [SlashCommand("list", "Sends the list of users in the queue by direct message.")] + public async Task ListUserQueue() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var message = string.Join('\n', Info.GetUserList("(ID {0}) - Code: {1} - {2} - {3}")); + if (message.Length < 3) + message = "Queue list is empty."; + + await RespondAsync(message, ephemeral: true).ConfigureAwait(false); + } + + private static string GetClearTradeMessage(QueueResultRemove result) => result switch + { + QueueResultRemove.CurrentlyProcessing => "Looks like you're currently being processed! Did not remove from all queues.", + QueueResultRemove.CurrentlyProcessingRemoved => "Looks like you're currently being processed!", + QueueResultRemove.Removed => "Removed you from the queue.", + _ => "Sorry, you are not currently in the queue.", + }; +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs new file mode 100644 index 000000000..b8c7eee56 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +[RequireContext(ContextType.Guild)] +public class SeedCheckModule : SlashModuleBase where T : PKM, new() +{ + private static TradeQueueInfo Info=>SysCord.Runner.Hub.Queues.Info; + + [SlashCommand("seed-check", "Checks the seed for a Pokémon.")] + public async Task SeedCheckAsync( + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) + { + if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.SeedCheck, out var e), e).ConfigureAwait(false)) + return; + + var sig = GetSignificance(Context.User); + code ??= Info.GetRandomTradeCode(); + await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed, Context).ConfigureAwait(false); + } + + [SlashCommand("find-frame", "Prints the next shiny frame from a seed.")] + public async Task FindFrameAsync( + [Summary(nameof(seed), "The seed to find the next shiny frame from.")] string seed) + { + var s = seed.ToLowerInvariant().AsSpan(); + if (s.StartsWith("0x")) + s = s[2..]; + var value = Util.GetHexValue64(s); + + var hub = SysCord.Runner.Hub; + var r = new SeedSearchResult(Z3SearchResult.Success, value, -1, hub.Config.SeedCheckSWSH.ResultDisplayMode); + var embed = new EmbedBuilder { Color = Color.LighterGrey }; + embed.AddField($"Seed: {value:X16}", r.ToString()); + await RespondAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("seed-list", "Prints the users in the Seed Check queue.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task GetSeedListAsync() + { + if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) + return; + + var embed = new EmbedBuilder(); + embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.SeedCheck)); + await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); + } + +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs new file mode 100644 index 000000000..f166b2fac --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -0,0 +1,219 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[RequireContext(ContextType.Guild)] +public class TradeModule : SlashModuleBase where T : PKM, new() +{ + private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + + [SlashCommand("trade", "Trade a Pokémon file or a Showdown Set.")] + public async Task TradeAsync( + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null, + [Summary(nameof(set), "Provide if you want to convert text.")] string? set = null, + [Summary(nameof(file), "Provide if you want to trade a file.")] IAttachment? file = null) + { + if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) + { + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + if (set is not null && file is not null) + { + await RespondAsync("Provide either a Showdown Set or a Pokémon file, not both.", ephemeral: true).ConfigureAwait(false); + return; + } + + if (set is null && file is null) + { + await RespondAsync("Provide a Showdown Set or attach a Pokémon file.", ephemeral: true).ConfigureAwait(false); + return; + } + + // Generating from a set might take longer than 3 seconds for some hosts with slower computers. + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var tradeCode = code ?? Info.GetRandomTradeCode(); + + if (file is not null) + { + await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); + return; + } + + await TradeShowdownAsync(tradeCode, set!, Context).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("trade-list", "Prints the users in the trade queues.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task GetTradeListAsync() + { + if (!CheckSudo(out var error)) + { + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + var embed = new EmbedBuilder(); + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = Info.GetTradeList(PokeRoutineType.LinkTrade); + x.IsInline = false; + }); + + await RespondAsync("These are the users who are currently waiting:", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); + } + + [SlashCommand("ban-trade", "Ban an Online ID from trading.")] + [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + public async Task BanTradeAsync( + [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, + [Summary(nameof(reason), "The reason for banning the user.")] string reason) + { + if (!CheckSudo(out var error)) + { + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + await DeferAsync().ConfigureAwait(false); + SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew(GetReference(nnid, reason)); + await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); + } + + private async Task TradeShowdownAsync(int code, string content, SocketInteractionContext user) + { + content = ReusableActions.StripCodeBlock(content); + var set = new ShowdownSet(content); + var template = AutoLegalityWrapper.GetTemplate(set); + if (set.InvalidLines.Count != 0 || set.Species is 0) + { + var sb = new StringBuilder(128); + sb.AppendLine("Unable to parse Showdown Set."); + var invalidlines = set.InvalidLines; + if (invalidlines.Count != 0) + { + var localization = BattleTemplateParseErrorLocalization.Get(); + sb.AppendLine("Invalid lines detected:\n```yml"); + foreach (var line in invalidlines) + { + var error = line.Humanize(localization); + sb.AppendLine(error); + } + sb.AppendLine("```"); + } + if (set.Species is 0) + sb.AppendLine("Species could not be identified. Check your spelling."); + + var msg = sb.ToString(); + await RespondAsync(msg, ephemeral: true).ConfigureAwait(false); + return; + } + + try + { + var sav = AutoLegalityWrapper.GetTrainerInfo(); + var pkm = sav.GetLegal(template, out var result); + var la = new LegalityAnalysis(pkm); + var spec = GameInfo.Strings.Species[template.Species]; + pkm = EntityConverter.ConvertToType(pkm, typeof(T), out _) ?? pkm; + if (pkm is not T pk || !la.Valid) + { + var reason = result switch + { + "Timeout" => $"That {spec} set took too long to generate.", + "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", + _ => $"I wasn't able to create a {spec} from that set.", + }; + var imsg = $"Oops! {reason}"; + if (result == "Failed") + imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; + await RespondAsync(imsg, ephemeral: true).ConfigureAwait(false); + return; + } + + pk.ResetPartyStats(); + await AddTradeToQueueAsync(code, pk, user).ConfigureAwait(false); + } + catch (Exception ex) + { + LogUtil.LogSafe(ex); + var msg = $""" + Oops! An unexpected problem happened with this Showdown Set: + {Format.Code(string.Join('\n', set.GetSetLines()),"yml")} + """; + await RespondAsync(msg, ephemeral: true).ConfigureAwait(false); + } + } + + private async Task TradeAttachmentAsync(int code, IAttachment attachment, SocketInteractionContext user) + { + var att = await attachment.DownloadEntityAsync().ConfigureAwait(false); + var pk = GetRequest(att); + if (pk == null) + { + await RespondAsync("Attachment provided is not compatible with this module!", ephemeral: true).ConfigureAwait(false); + return; + } + + await AddTradeToQueueAsync(code, pk, user).ConfigureAwait(false); + } + + private static T? GetRequest(Download dl) + { + if (!dl.Success) + return null; + return dl.Data switch + { + null => null, + T pk => pk, + _ => EntityConverter.ConvertToType(dl.Data, typeof(T), out _) as T, + }; + } + + private async Task AddTradeToQueueAsync(int code, T pk, SocketInteractionContext context) + { + var la = new LegalityAnalysis(pk); + if (!la.Valid) + { + await RespondAsync($"{typeof(T).Name} attachment is not legal, and cannot be traded!").ConfigureAwait(false); + return; + } + + var enc = la.EncounterOriginal; + if (!pk.CanBeTraded(enc)) + { + await RespondAsync("Provided Pokémon content is blocked from trading!").ConfigureAwait(false); + return; + } + var cfg = Info.Hub.Config.Trade; + if (cfg.DisallowNonNatives && (enc.Context != pk.Context || pk.GO)) + { + await RespondAsync($"{typeof(T).Name} attachment is not native, and cannot be traded!").ConfigureAwait(false); + return; + } + + if (cfg.DisallowTracked && pk is IHomeTrack { HasTracker: true }) + { + await RespondAsync($"{typeof(T).Name} attachment is tracked by HOME, and cannot be traded!").ConfigureAwait(false); + return; + } + + var sig = GetSignificance(context.User); + await QueueHelper.AddToQueueAsync(Context, code, sig, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific, context).ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs index 81c4e7848..220f251a2 100644 --- a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs +++ b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs @@ -1,96 +1,106 @@ +using System; +using System.Threading.Tasks; using Discord; -using Discord.WebSocket; +using Discord.Interactions; using PKHeX.Core; using SysBot.Base; -using System; -using System.Threading.Tasks; namespace SysBot.Pokemon.Discord; public static class AutoLegalityExtensionsDiscord { - public static async Task ReplyWithLegalizedSetAsync(this ISocketMessageChannel channel, ITrainerInfo sav, ShowdownSet set) + extension(SocketInteractionContext context) { - if (set.Species == 0) + public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) { - await channel.SendMessageAsync("Oops! I wasn't able to interpret your message! If you intended to convert something, please double check what you're pasting!").ConfigureAwait(false); - return; - } - - try - { - var template = AutoLegalityWrapper.GetTemplate(set); - var pkm = sav.GetLegal(template, out var result); - var la = new LegalityAnalysis(pkm); - var spec = GameInfo.Strings.Species[template.Species]; - if (!la.Valid) + if (set.Species == 0) { - var reason = result switch - { - "Timeout" => $"That {spec} set took too long to generate.", - "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", - _ => $"I wasn't able to create a {spec} from that set.", - }; - var imsg = $"Oops! {reason}"; - if (result == "Failed") - imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; - await channel.SendMessageAsync(imsg).ConfigureAwait(false); + await context.Interaction.RespondAsync("Oops! I wasn't able to interpret your message! If you intended to convert something, please double check what you're pasting!").ConfigureAwait(false); return; } - var msg = $"Here's your ({result}) legalized PKM for {spec} ({la.EncounterOriginal.Name})!"; - await channel.SendPKMAsync(pkm, msg + $"\n{ReusableActions.GetFormattedShowdownText(pkm)}").ConfigureAwait(false); - } - catch (Exception ex) - { - LogUtil.LogSafe(ex, nameof(AutoLegalityExtensionsDiscord)); - var msg = $"Oops! An unexpected problem happened with this Showdown Set:\n```{string.Join("\n", set.GetSetLines())}```"; - await channel.SendMessageAsync(msg).ConfigureAwait(false); - } - } + try + { + var template = AutoLegalityWrapper.GetTemplate(set); + var pk = sav.GetLegal(template, out var result); + var la = new LegalityAnalysis(pk); + var species = GameInfo.Strings.Species[template.Species]; + if (!la.Valid) + { + var reason = result switch + { + "Timeout" => $"That {species} set took too long to generate.", + "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", + _ => $"I wasn't able to create a {species} from that set.", + }; + var issue = $"Oops! {reason}"; + if (result == "Failed") + issue += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pk)}"; - public static Task ReplyWithLegalizedSetAsync(this ISocketMessageChannel channel, string content, byte gen) - { - content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); - var sav = AutoLegalityWrapper.GetTrainerInfo(gen); - return channel.ReplyWithLegalizedSetAsync(sav, set); - } + await context.Interaction.RespondAsync(issue).ConfigureAwait(false); + return; + } - public static Task ReplyWithLegalizedSetAsync(this ISocketMessageChannel channel, string content) where T : PKM, new() - { - content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); - var sav = AutoLegalityWrapper.GetTrainerInfo(); - return channel.ReplyWithLegalizedSetAsync(sav, set); - } + var message = $"Here's your ({result}) legalized PKM for {species} ({la.EncounterOriginal.Name})!"; + var formatted = ReusableActions.GetFormattedShowdownText(pk); + await context.SendFileAsync(pk, $"{message}\n{formatted}").ConfigureAwait(false); + } + catch (Exception ex) + { + LogUtil.LogSafe(ex); + var lines = string.Join('\n', set.GetSetLines()); + var formatted = Format.Code(lines, "yml"); + var message = $"Oops! An unexpected problem happened with this Showdown Set:\n{formatted}"; + // No need for everyone to see their goofy set. + await context.Interaction.RespondAsync(message, ephemeral: true).ConfigureAwait(false); + } + } - public static async Task ReplyWithLegalizedSetAsync(this ISocketMessageChannel channel, IAttachment att) - { - var download = await NetUtil.DownloadPKMAsync(att).ConfigureAwait(false); - if (!download.Success) + public Task ReplyWithLegalizedSetAsync(string content, byte generation) { - await channel.SendMessageAsync(download.ErrorMessage).ConfigureAwait(false); - return; + content = ReusableActions.StripCodeBlock(content); + var set = new ShowdownSet(content); + var sav = AutoLegalityWrapper.GetTrainerInfo(generation); + return context.ReplyWithLegalizedSetAsync(sav, set); } - var pkm = download.Data!; - if (new LegalityAnalysis(pkm).Valid) + public async Task ReplyWithLegalizedSetAsync(string content) where T : PKM, new() { - await channel.SendMessageAsync($"{download.SanitizedFileName}: Already legal.").ConfigureAwait(false); - return; + content = ReusableActions.StripCodeBlock(content); + var set = new ShowdownSet(content); + var sav = AutoLegalityWrapper.GetTrainerInfo(); + await context.ReplyWithLegalizedSetAsync(sav, set).ConfigureAwait(false); } - var legal = pkm.LegalizePokemon(); - if (!new LegalityAnalysis(legal).Valid) + public async Task ReplyWithLegalizedSetAsync(IAttachment attachment) { - await channel.SendMessageAsync($"{download.SanitizedFileName}: Unable to legalize.").ConfigureAwait(false); - return; - } + var download = await attachment.DownloadEntityAsync().ConfigureAwait(false); + if (!download.Success) + { + await context.Interaction.RespondAsync(download.ErrorMessage, ephemeral: true).ConfigureAwait(false); + return; + } - legal.RefreshChecksum(); + var pk = download.Data!; + var fileName = download.SanitizedFileName; + if (new LegalityAnalysis(pk).Valid) + { + await context.Interaction.RespondAsync($"{fileName}: Already legal.", ephemeral: true).ConfigureAwait(false); + return; + } - var msg = $"Here's your legalized PKM for {download.SanitizedFileName}!\n{ReusableActions.GetFormattedShowdownText(legal)}"; - await channel.SendPKMAsync(legal, msg).ConfigureAwait(false); + var legal = pk.LegalizePokemon(); + if (!new LegalityAnalysis(legal).Valid) + { + await context.Interaction.RespondAsync($"{fileName}: Unable to legalize.").ConfigureAwait(false); + return; + } + + legal.RefreshChecksum(); + + var paste = ReusableActions.GetFormattedShowdownText(legal); + var message = $"Here's your legalized PKM for {fileName}!\n{paste}"; + await context.SendFileAsync(legal, message).ConfigureAwait(false); + } } } diff --git a/SysBot.Pokemon.Discord/Helpers/ChannelAction.cs b/SysBot.Pokemon.Discord/Helpers/ChannelAction.cs index 91df5d50f..77fa88e3d 100644 --- a/SysBot.Pokemon.Discord/Helpers/ChannelAction.cs +++ b/SysBot.Pokemon.Discord/Helpers/ChannelAction.cs @@ -1,10 +1,6 @@ -using System; +using System; namespace SysBot.Pokemon.Discord; -public class ChannelAction(ulong ChannelID, Action Messager, string ChannelName) -{ - public readonly ulong ChannelID = ChannelID; - public readonly string ChannelName = ChannelName; - public readonly Action Action = Messager; -} +// ReSharper disable once NotAccessedPositionalProperty.Global +public abstract record ChannelAction(ulong ChannelId, Action Messager, string ChannelName); diff --git a/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs b/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs index e13ee6bb6..14e4f7fc3 100644 --- a/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs +++ b/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs @@ -1,15 +1,13 @@ -using System; using Discord.WebSocket; using SysBot.Base; +using System; +using System.Runtime.CompilerServices; namespace SysBot.Pokemon.Discord; -public class ChannelLogger(ulong ChannelID, ISocketMessageChannel Channel) : ILogForwarder +public sealed record ChannelLogger(ISocketMessageChannel Channel) : ILogForwarder { - public ulong ChannelID { get; } = ChannelID; - public string ChannelName => Channel.Name; - - public void Forward(string message, string identity) + public void Forward(string message, [CallerMemberName] string identity = "") { try { @@ -21,6 +19,7 @@ public void Forward(string message, string identity) LogUtil.LogSafe(ex, identity); } } + private static string GetMessage(ReadOnlySpan msg, string identity) => $"> [{DateTime.Now:hh:mm:ss}] - {identity}: {msg}"; } diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs b/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs index 1d2590580..da565f516 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs @@ -4,9 +4,8 @@ namespace SysBot.Pokemon.Discord; -public class DiscordManager(DiscordSettings Config) +public sealed record DiscordManager(DiscordSettings Config) { - public readonly DiscordSettings Config = Config; public ulong Owner { get; internal set; } public RemoteControlAccessList BlacklistedUsers => Config.UserBlacklist; @@ -22,11 +21,11 @@ public class DiscordManager(DiscordSettings Config) public RemoteControlAccessList RolesDump => Config.RoleCanDump; public RemoteControlAccessList RolesRemoteControl => Config.RoleRemoteControl; - public bool CanUseSudo(ulong uid) => SudoDiscord.Contains(uid); + public bool CanUseSudo(ulong uid) => uid == Owner || SudoDiscord.Contains(uid); public bool CanUseSudo(IEnumerable roles) => roles.Any(SudoRoles.Contains); public bool CanUseCommandChannel(ulong channel) => (WhitelistedChannels.List.Count == 0 && WhitelistedChannels.AllowIfEmpty) || WhitelistedChannels.Contains(channel); - public bool CanUseCommandUser(ulong uid) => !BlacklistedUsers.Contains(uid); + public bool CanUseCommandUser(ulong uid) => uid == Owner || !BlacklistedUsers.Contains(uid); public RequestSignificance GetSignificance(IEnumerable roles) { @@ -41,19 +40,19 @@ public RequestSignificance GetSignificance(IEnumerable roles) return result; } - public bool GetHasRoleAccess(string type, IEnumerable roles) + public bool GetHasRoleAccess(PokeRoutineType type, IEnumerable roles) { var set = GetSet(type); return set is { AllowIfEmpty: true, List.Count: 0 } || roles.Any(set.Contains); } - private RemoteControlAccessList GetSet(string type) => type switch + private RemoteControlAccessList GetSet(PokeRoutineType type) => type switch { - nameof(RolesClone) => RolesClone, - nameof(RolesTrade) => RolesTrade, - nameof(RolesSeed) => RolesSeed, - nameof(RolesDump) => RolesDump, - nameof(RolesRemoteControl) => RolesRemoteControl, + PokeRoutineType.Clone => RolesClone, + PokeRoutineType.LinkTrade => RolesTrade, + PokeRoutineType.SeedCheck => RolesSeed, + PokeRoutineType.Dump => RolesDump, + PokeRoutineType.RemoteControl => RolesRemoteControl, _ => throw new ArgumentOutOfRangeException(nameof(type)), }; } diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs index e87e0830f..01669584c 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs @@ -1,88 +1,102 @@ -using Discord; -using Discord.WebSocket; -using PKHeX.Core; using System; using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; -public class DiscordTradeNotifier(T Data, PokeTradeTrainerInfo Info, int Code, SocketUser Trader) +public sealed record DiscordTradeNotifier(T Data, PokeTradeTrainerInfo Info, int Code, SocketInteractionContext Trader) : IPokeTradeNotifier where T : PKM, new() { private T Data { get; } = Data; private PokeTradeTrainerInfo Info { get; } = Info; private int Code { get; } = Code; - private SocketUser Trader { get; } = Trader; + private SocketInteractionContext Trader { get; } = Trader; public Action>? OnFinish { private get; set; } public readonly PokeTradeHub Hub = SysCord.Runner.Hub; - public void TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) + public async Task TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) { var receive = Data.Species == 0 ? string.Empty : $" ({Data.Nickname})"; - Trader.SendMessageAsync($"Initializing trade{receive}. Please be ready. Your code is **{Code:0000 0000}**.").ConfigureAwait(false); + var message = $"Initializing trade{receive}. Please be ready. Your code is **{Code:0000 0000}**."; + + await SendNotification(message).ConfigureAwait(false); } - public void TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) + public async Task TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) { var name = Info.TrainerName; var trainer = string.IsNullOrEmpty(name) ? string.Empty : $", {name}"; - Trader.SendMessageAsync($"I'm waiting for you{trainer}! Your code is **{Code:0000 0000}**. My IGN is **{routine.InGameName}**.").ConfigureAwait(false); + var message = $"I'm waiting for you{trainer}! Your code is **{Code:0000 0000}**. My IGN is **{routine.InGameName}**."; + + await SendNotification(message).ConfigureAwait(false); } - public void TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) + public async Task TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) { OnFinish?.Invoke(routine); - Trader.SendMessageAsync($"Trade canceled: {msg}").ConfigureAwait(false); + var message = $"Trade canceled: {msg}"; + + await SendNotification(message).ConfigureAwait(false); } - public void TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) + public async Task TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) { OnFinish?.Invoke(routine); var tradedToUser = Data.Species; var message = tradedToUser != 0 ? $"Trade finished. Enjoy your {(Species)tradedToUser}!" : "Trade finished!"; - Trader.SendMessageAsync(message).ConfigureAwait(false); + + await SendNotification(message).ConfigureAwait(false); if (result.Species != 0 && Hub.Config.Discord.ReturnPKMs) - Trader.SendPKMAsync(result, "Here's what you traded me!").ConfigureAwait(false); + await Trader.SendFilePrivatelyAsync(result, "Here's what you traded me!").ConfigureAwait(false); } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) + => await SendNotification(message).ConfigureAwait(false); + private async Task SendNotification(string message, Embed? embed = null) { - Trader.SendMessageAsync(message).ConfigureAwait(false); + // Discord makes all interaction modals stale after 15 minutes. + // Depending on how long we take to start and complete the trade (queued users), this might be called >= 15 minutes after command issued. + // So, we do the standard behavior: direct message the user. + await Trader.Interaction.User.SendMessageAsync(message, embed: embed).ConfigureAwait(false); } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary trade) { - if (message.ExtraInfo is SeedSearchResult r) + if (trade.ExtraInfo is SeedSearchResult r) { - SendNotificationZ3(r); + await SendNotificationZ3(r).ConfigureAwait(false); return; } - var msg = message.Summary; - if (message.Details.Count > 0) - msg += ", " + string.Join(", ", message.Details.Select(z => $"{z.Heading}: {z.Detail}")); - Trader.SendMessageAsync(msg).ConfigureAwait(false); + var message = trade.Summary; + if (trade.Details.Count > 0) + message += ", " + string.Join(", ", trade.Details.Select(z => $"{z.Heading}: {z.Detail}")); + + await SendNotification(message).ConfigureAwait(false); } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) { if (result.Species != 0 && (Hub.Config.Discord.ReturnPKMs || info.Type == PokeTradeType.Dump)) - Trader.SendPKMAsync(result, message).ConfigureAwait(false); + await Trader.SendFilePrivatelyAsync(result, message).ConfigureAwait(false); } - private void SendNotificationZ3(SeedSearchResult r) + private async Task SendNotificationZ3(SeedSearchResult searchResult) { - var lines = r.ToString(); - + var message = $"Here are the details for `{searchResult.Seed:X16}`:"; var embed = new EmbedBuilder { Color = Color.LighterGrey }; embed.AddField(x => { - x.Name = $"Seed: {r.Seed:X16}"; - x.Value = lines; + x.Name = $"Seed: {searchResult.Seed:X16}"; + x.Value = searchResult.ToString(); x.IsInline = false; }); - var msg = $"Here are the details for `{r.Seed:X16}`:"; - Trader.SendMessageAsync(msg, embed: embed.Build()).ConfigureAwait(false); + + // Seed check might be more than 15 minutes stale. Just DM them, not like the public needs to see their seeds. + await SendNotification(message, embed: embed.Build()).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Helpers/NetUtil.cs b/SysBot.Pokemon.Discord/Helpers/NetUtil.cs index 23b188e43..2900a0bc4 100644 --- a/SysBot.Pokemon.Discord/Helpers/NetUtil.cs +++ b/SysBot.Pokemon.Discord/Helpers/NetUtil.cs @@ -1,19 +1,20 @@ -using Discord; -using PKHeX.Core; using System.Net.Http; using System.Threading.Tasks; +using Discord; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; public static class NetUtil { + private static readonly HttpClient Client = new(); + public static async Task DownloadFromUrlAsync(string url) { - using var client = new HttpClient(); - return await client.GetByteArrayAsync(url).ConfigureAwait(false); + return await Client.GetByteArrayAsync(url).ConfigureAwait(false); } - public static async Task> DownloadPKMAsync(IAttachment att) + public static async Task> DownloadEntityAsync(this IAttachment att) { var result = new Download { SanitizedFileName = Format.Sanitize(att.Filename) }; if (!EntityDetection.IsSizePlausible(att.Size)) @@ -26,6 +27,8 @@ public static async Task> DownloadPKMAsync(IAttachment att) // Download the resource and load the bytes into a buffer. var buffer = await DownloadFromUrlAsync(url).ConfigureAwait(false); + + // Ensure it actually converts into a file we can use. var prefer = EntityFileExtension.GetContextFromExtension(result.SanitizedFileName); var pkm = EntityFormat.GetFromBytes(buffer, prefer); if (pkm == null) diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index d7651784e..cebef4b61 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -1,9 +1,8 @@ +using System.Threading.Tasks; using Discord; -using Discord.Commands; +using Discord.Interactions; using Discord.Net; -using Discord.WebSocket; using PKHeX.Core; -using System.Threading.Tasks; namespace SysBot.Pokemon.Discord; @@ -11,132 +10,144 @@ namespace SysBot.Pokemon.Discord; { private const uint MaxTradeCode = 9999_9999; - public static async Task AddToQueueAsync(SocketCommandContext context, int code, string trainer, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type, SocketUser trader) + public static async Task AddToQueueAsync(SocketInteractionContext context, int code, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type, SocketInteractionContext trader) { if ((uint)code > MaxTradeCode) { - await context.Channel.SendMessageAsync("Trade code should be 00000000-99999999!").ConfigureAwait(false); + await context.Interaction.FollowupAsync("Trade code should be 00000000-99999999!").ConfigureAwait(false); return; } try { - const string helper = "I've added you to the queue! I'll message you here when your trade is starting."; - IUserMessage test = await trader.SendMessageAsync(helper).ConfigureAwait(false); - - // Try adding - var result = AddToTradeQueue(context, trade, code, trainer, sig, routine, type, trader, out var msg); - - // Notify in channel - await context.Channel.SendMessageAsync(msg).ConfigureAwait(false); - // Notify in PM to mirror what was said in the channel. - // Only tell them a trade code if it was successful. - if (result) - msg += $"\nYour trade code will be **{code:0000 0000}**."; - await trader.SendMessageAsync($"{msg}").ConfigureAwait(false); - - // Clean Up - if (result) + var result = AddToTradeQueue(trader, trade, code, sig, routine, type, out var message); + if (!result) { - // Delete the user's join message for privacy - if (!context.IsPrivate) - await context.Message.DeleteAsync(RequestOptions.Default).ConfigureAwait(false); + await context.Interaction.FollowupAsync(message).ConfigureAwait(false); + return; } - else + + // Message the user in their DMs. If this fails, the event handler will abort and let them know to enable DMs. + var secret = $""" + {message} + I'll message you here when your trade is starting. + """; + var builder = new EmbedBuilder { Color = Color.Blue }; + builder.AddField(x => { - // Delete our "I'm adding you!", and send the same message that we sent to the general channel. - await test.DeleteAsync().ConfigureAwait(false); - } + x.Name = "Trade Code:"; + x.Value = Format.Bold($"{code:0000 0000}"); + }); + var msg = await trader.Interaction.User.SendMessageAsync(secret, embed: builder.Build()).ConfigureAwait(false); + + // Keep a public log of them joining the queue. + await context.Interaction.Channel.SendMessageAsync($"{trader.User.Mention} - {message}").ConfigureAwait(false); + + // Update the ephermal command message to backlink to the DM we just sent the user. + await context.Interaction.FollowupAsync($"Please check your direct messages: {msg.GetJumpUrl()}").ConfigureAwait(false); + + // All further communication is in Direct Messages to the user (no further input needed). } catch (HttpException ex) { - await HandleDiscordExceptionAsync(context, trader, ex).ConfigureAwait(false); + await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); } } - public static Task AddToQueueAsync(SocketCommandContext context, int code, string trainer, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type) - { - return AddToQueueAsync(context, code, trainer, sig, trade, routine, type, context.User); - } - - private static bool AddToTradeQueue(SocketCommandContext context, T pk, int code, string trainerName, RequestSignificance sig, PokeRoutineType type, PokeTradeType t, SocketUser trader, out string msg) + private static bool AddToTradeQueue(SocketInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type, out string message) { - var user = trader; - var userID = user.Id; + var channel = trader.Channel; + var user = trader.User; + var userId = user.Id; var name = user.Username; - - var trainer = new PokeTradeTrainerInfo(trainerName, userID); - var notifier = new DiscordTradeNotifier(pk, trainer, code, user); - var detail = new PokeTradeDetail(pk, trainer, notifier, t, code, sig == RequestSignificance.Favored); - var trade = new TradeEntry(detail, userID, type, name); + var trainer = new PokeTradeTrainerInfo(name, userId); + var notifier = new DiscordTradeNotifier(pk, trainer, code, trader); + var detail = new PokeTradeDetail + { + Type = type, + Code = code, + TradeData = pk, + Trainer = trainer, + Notifier = notifier, + IsFavored = sig == RequestSignificance.Favored, + }; + var trade = new TradeEntry(detail, userId, routine, name); var hub = SysCord.Runner.Hub; - var Info = hub.Queues.Info; - var added = Info.AddToTradeQueue(trade, userID, sig == RequestSignificance.Owner); - + var info = hub.Queues.Info; + var added = info.AddToTradeQueue(trade, userId, sig == RequestSignificance.Owner); if (added == QueueResultAdd.AlreadyInQueue) { - msg = "Sorry, you are already in the queue."; + message = "Sorry, you are already in the queue."; return false; } - var position = Info.CheckPosition(userID, type); - - var ticketID = ""; - if (TradeStartModule.IsStartChannel(context.Channel.Id)) - ticketID = $", unique ID: {detail.ID}"; + var position = info.CheckPosition(userId, routine); + var ticketId = TradeStartModule.IsStartChannel(channel.Id) ? $", unique ID: {detail.Id}" : ""; + var pokeName = type == PokeTradeType.Specific && pk.Species != 0 + ? $" Receiving: {GameInfo.GetStrings("en").Species[pk.Species]}." + : ""; - var pokeName = ""; - if (t == PokeTradeType.Specific && pk.Species != 0) - pokeName = $" Receiving: {GameInfo.GetStrings("en").Species[pk.Species]}."; - msg = $"{user.Mention} - Added to the {type} queue{ticketID}. Current Position: {position.Position}.{pokeName}"; - - var botct = Info.Hub.Bots.Count; + message = $"Added to the {routine} queue{ticketId}. Current Position: {position.Position}.{pokeName}"; + var botct = info.Hub.Bots.Count; if (position.Position > botct) { - var eta = Info.Hub.Config.Queues.EstimateDelay(position.Position, botct); - msg += $" Estimated: {eta:F1} minutes."; + var eta = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); + message += $" Estimated: {eta:F1} minutes."; } return true; } - private static async Task HandleDiscordExceptionAsync(SocketCommandContext context, SocketUser trader, HttpException ex) + private static async Task HandleDiscordExceptionAsync(SocketInteractionContext context, HttpException ex) { string message = string.Empty; switch (ex.DiscordCode) { case DiscordErrorCode.InsufficientPermissions or DiscordErrorCode.MissingPermissions: - { - // Check if the exception was raised due to missing "Send Messages" or "Manage Messages" permissions. Nag the bot owner if so. - var permissions = context.Guild.CurrentUser.GetPermissions(context.Channel as IGuildChannel); - if (!permissions.SendMessages) - { - // Nag the owner in logs. - message = "You must grant me \"Send Messages\" permissions!"; - Base.LogUtil.LogError(message, "QueueHelper"); - return; - } - if (!permissions.ManageMessages) + var channel = context.Interaction.Channel; + IGuild? guild = context.Guild; + if (guild is not null && channel is IGuildChannel guildChannel) { - var app = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); - var owner = app.Owner.Id; - message = $"<@{owner}> You must grant me \"Manage Messages\" permissions!"; + var self = await guild.GetCurrentUserAsync().ConfigureAwait(false); + var permissions = self.GetPermissions(guildChannel); + if (!permissions.SendMessages) + { + message = "You must grant me \"Send Messages\" permissions!"; + Base.LogUtil.LogError(message); + return; + } + if (!permissions.ManageMessages) + { + var app = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + message = $"<@{app.Owner.Id}> You must grant me \"Manage Messages\" permissions!"; + await context.Interaction.RespondAsync(message, ephemeral: true).ConfigureAwait(false); + Base.LogUtil.LogError(message); + return; + } } - } break; case DiscordErrorCode.CannotSendMessageToUser: - { - // The user either has DMs turned off, or Discord thinks they do. - message = context.User == trader ? "You must enable private messages in order to be queued!" : "The mentioned user must enable private messages in order for them to be queued!"; - } + message = "You must enable private messages in order to be queued!"; break; default: - { - // Send a generic error message. - message = ex.DiscordCode != null ? $"Discord error {(int)ex.DiscordCode}: {ex.Reason}" : $"Http error {(int)ex.HttpCode}: {ex.Message}"; - } + message = ex.DiscordCode != null + ? $"Discord error {(int)ex.DiscordCode}: {ex.Reason}" + : $"Http error {(int)ex.HttpCode}: {ex.Message}"; break; } - await context.Channel.SendMessageAsync(message).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(message)) + return; + + var interaction = context.Interaction; + if (interaction.HasResponded) + { + var channel = await interaction.GetChannelAsync().ConfigureAwait(false); + await channel.SendMessageAsync(message).ConfigureAwait(false); + } + else + { + await interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); + } } } diff --git a/SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs b/SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs deleted file mode 100644 index bf5215d6f..000000000 --- a/SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Discord.Commands; -using Discord.WebSocket; -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -/// -/// Same as with extra consideration for bots accepting Queue requests. -/// -public sealed class RequireQueueRoleAttribute(string RoleName) : PreconditionAttribute -{ - // Create a field to store the specified name - - // Create a constructor so the name can be specified - - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var mgr = SysCordSettings.Manager; - if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) - return Task.FromResult(PreconditionResult.FromSuccess()); - - // Check if this user is a Guild User, which is the only context where roles exist - if (context.User is not SocketGuildUser gUser) - return Task.FromResult(PreconditionResult.FromError("You must be sending the message from a guild to run this command.")); - - var roles = gUser.Roles; - if (mgr.CanUseSudo(roles.Select(z => z.Name))) - return Task.FromResult(PreconditionResult.FromSuccess()); - - bool canQueue = SysCordSettings.HubConfig.Queues.CanQueue; - if (!canQueue) - return Task.FromResult(PreconditionResult.FromError("Sorry, I am not currently accepting queue requests!")); - - if (!mgr.GetHasRoleAccess(RoleName, roles.Select(z => z.Name))) - return Task.FromResult(PreconditionResult.FromError("You do not have the required role to run this command.")); - - return Task.FromResult(PreconditionResult.FromSuccess()); - } -} diff --git a/SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs b/SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs deleted file mode 100644 index 4fe9bdee5..000000000 --- a/SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Discord.Commands; -using Discord.WebSocket; -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -/// -/// Requires an assigned role in order to accept commands. Can be used by sudo users if satisfied. -/// -public sealed class RequireRoleAccessAttribute(string RoleName) : PreconditionAttribute -{ - // Create a field to store the specified name - - // Create a constructor so the name can be specified - - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var mgr = SysCordSettings.Manager; - if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) - return Task.FromResult(PreconditionResult.FromSuccess()); - - // Check if this user is a Guild User, which is the only context where roles exist - if (context.User is not SocketGuildUser gUser) - return Task.FromResult(PreconditionResult.FromError("You must be sending the message from a guild to run this command.")); - - var roles = gUser.Roles; - if (mgr.CanUseSudo(roles.Select(z => z.Name))) - return Task.FromResult(PreconditionResult.FromSuccess()); - - if (!mgr.GetHasRoleAccess(RoleName, roles.Select(z => z.Name))) - return Task.FromResult(PreconditionResult.FromError("You do not have the required role to run this command.")); - - return Task.FromResult(PreconditionResult.FromSuccess()); - } -} diff --git a/SysBot.Pokemon.Discord/Helpers/RequireRoleAttribute.cs b/SysBot.Pokemon.Discord/Helpers/RequireRoleAttribute.cs deleted file mode 100644 index cb1c094af..000000000 --- a/SysBot.Pokemon.Discord/Helpers/RequireRoleAttribute.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Discord.Commands; -using Discord.WebSocket; -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public sealed class RequireRoleAttribute(string RoleName) : PreconditionAttribute -{ - // Create a field to store the specified name - - // Create a constructor so the name can be specified - - // Override the CheckPermissions method - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - // Since no async work is done, the result has to be wrapped with `Task.FromResult` to avoid compiler errors - - // Check if this user is a Guild User, which is the only context where roles exist - if (context.User is not SocketGuildUser gUser) - return Task.FromResult(PreconditionResult.FromError("You must be in a guild to run this command.")); - - // If this command was executed by a user with the appropriate role, return a success - if (gUser.Roles.Any(r => r.Name == RoleName)) - return Task.FromResult(PreconditionResult.FromSuccess()); - - // Since it wasn't, fail - return Task.FromResult(PreconditionResult.FromError($"You must have a role named {RoleName} to run this command.")); - } -} diff --git a/SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs b/SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs deleted file mode 100644 index faeb62643..000000000 --- a/SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Discord.Commands; -using Discord.WebSocket; -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace SysBot.Pokemon.Discord; - -public sealed class RequireSudoAttribute : PreconditionAttribute -{ - // Override the CheckPermissions method - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) - { - var mgr = SysCordSettings.Manager; - if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) - return Task.FromResult(PreconditionResult.FromSuccess()); - - // Check if this user is a Guild User, which is the only context where roles exist - if (context.User is not SocketGuildUser gUser) - return Task.FromResult(PreconditionResult.FromError("You must be in a guild to run this command.")); - - if (mgr.CanUseSudo(gUser.Roles.Select(z => z.Name))) - return Task.FromResult(PreconditionResult.FromSuccess()); - - // Since it wasn't, fail - return Task.FromResult(PreconditionResult.FromError("You are not permitted to run this command.")); - } -} diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index e7e726128..0a59cb862 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -1,90 +1,80 @@ -using Discord; -using Discord.WebSocket; -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; public static class ReusableActions { - public static async Task SendPKMAsync(this IMessageChannel channel, PKM pkm, string msg = "") + extension(IMessageChannel channel) { - var tmp = Path.Combine(Path.GetTempPath(), PathUtil.CleanFileName(pkm.FileName)); - Span data = stackalloc byte[pkm.SIZE_PARTY]; - pkm.WriteDecryptedDataParty(data); - await File.WriteAllBytesAsync(tmp, data.ToArray()); - await channel.SendFileAsync(tmp, msg).ConfigureAwait(false); - File.Delete(tmp); + public async Task SendFileAsync(PKM pkm, string message = "", Embed? embed = null) + { + var attach = pkm.ToFileAttachment(); + await channel.SendFileAsync(attach, message, embed: embed).ConfigureAwait(false); + } } - public static async Task SendPKMAsync(this IUser user, PKM pkm, string msg = "") + extension(SocketInteractionContext context) { - var tmp = Path.Combine(Path.GetTempPath(), PathUtil.CleanFileName(pkm.FileName)); - Span data = stackalloc byte[pkm.SIZE_PARTY]; - pkm.WriteDecryptedDataParty(data); - await File.WriteAllBytesAsync(tmp, data.ToArray()); - await user.SendFileAsync(tmp, msg).ConfigureAwait(false); - File.Delete(tmp); - } + public async Task SendFileAsync(PKM pk, string message = "", Embed? embed = null) + => await context.SendFileAsync([pk], message, embed).ConfigureAwait(false); + public async Task SendFileAsync(IEnumerable list, string message = "", Embed? embed = null) + { + var attach = list.Select(ToFileAttachment); + var interaction = context.Interaction; + var task = interaction.HasResponded + ? interaction.FollowupWithFilesAsync(attach, message, embed: embed) + : interaction.RespondWithFilesAsync(attach, message, embed: embed); + await task.ConfigureAwait(false); + } - public static async Task RepostPKMAsShowdownAsync(this ISocketMessageChannel channel, IAttachment att) - { - if (!EntityDetection.IsSizePlausible(att.Size)) - return; - var result = await NetUtil.DownloadPKMAsync(att).ConfigureAwait(false); - if (!result.Success) - return; + public async Task SendFilePrivatelyAsync(PKM pk, string message = "", Embed? embed = null) + => await context.SendFilePrivatelyAsync([pk], message, embed).ConfigureAwait(false); - var pkm = result.Data!; - await channel.SendPKMAsShowdownSetAsync(pkm).ConfigureAwait(false); - } + public async Task SendFilePrivatelyAsync(IEnumerable list, string message = "", Embed? embed = null) + { + var user = context.Interaction.User; + var attach = list.Select(ToFileAttachment); + await user.SendFilesAsync(attach, message, embed: embed).ConfigureAwait(false); + } - public static RequestSignificance GetFavor(this IUser user) - { - var mgr = SysCordSettings.Manager; - if (user.Id == mgr.Owner) - return RequestSignificance.Owner; - if (mgr.CanUseSudo(user.Id)) - return RequestSignificance.Favored; - if (user is SocketGuildUser g) - return mgr.GetSignificance(g.Roles.Select(z => z.Name)); - return RequestSignificance.None; } - public static async Task EchoAndReply(this ISocketMessageChannel channel, string msg) + extension(PKM pk) { - // Announce it in the channel the command was entered only if it's not already an echo channel. - EchoUtil.Echo(msg); - if (!EchoModule.IsEchoChannel(channel)) - await channel.SendMessageAsync(msg).ConfigureAwait(false); - } + private FileAttachment ToFileAttachment() + { + Span data = stackalloc byte[pk.SIZE_PARTY]; + pk.WriteDecryptedDataParty(data); + var result = data.ToArray(); - public static async Task SendPKMAsShowdownSetAsync(this ISocketMessageChannel channel, PKM pkm) - { - var txt = GetFormattedShowdownText(pkm); - await channel.SendMessageAsync(txt).ConfigureAwait(false); + // No need to save it to the host disk, can just send it directly from memory. + var stream = new MemoryStream(result); + var fileName = PathUtil.CleanFileName(pk.FileName); + return new FileAttachment(stream, fileName); + } } - public static string GetFormattedShowdownText(PKM pkm) + public static string GetFormattedShowdownText(PKM pk, LanguageID language = LanguageID.English) { - var showdown = ShowdownParsing.GetShowdownText(pkm); - return Format.Code(showdown); - } + var config = BattleTemplateConfig.Showdown; - private static readonly string[] separator = [ ",", ", ", " " ]; + var settings = new BattleTemplateExportSettings(config, language); + var showdown = ShowdownParsing.GetShowdownText(pk, settings); - public static IReadOnlyList GetListFromString(string str) - { - // Extract comma separated list - return str.Split(separator, StringSplitOptions.RemoveEmptyEntries); + return Format.Code(showdown); } - public static string StripCodeBlock(string str) => str + /// + /// Removes the Discord code formatting. + /// + public static string StripCodeBlock(string message) => message .Replace("`\n", "") .Replace("\n`", "") .Replace("`", "") diff --git a/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj b/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj index 3951e00da..b6f6361be 100644 --- a/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj +++ b/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj @@ -1,9 +1,9 @@  - - - + + + diff --git a/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj.DotSettings b/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj.DotSettings new file mode 100644 index 000000000..89316e414 --- /dev/null +++ b/SysBot.Pokemon.Discord/SysBot.Pokemon.Discord.csproj.DotSettings @@ -0,0 +1,2 @@ + + Library \ No newline at end of file diff --git a/SysBot.Pokemon.Discord/SysCord.cs b/SysBot.Pokemon.Discord/SysCord.cs index 296af2bfd..f7c4ec728 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -1,108 +1,78 @@ -using Discord; -using Discord.Commands; -using Discord.WebSocket; -using Microsoft.Extensions.DependencyInjection; -using PKHeX.Core; -using SysBot.Base; using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using PKHeX.Core; +using SysBot.Base; using static Discord.GatewayIntents; namespace SysBot.Pokemon.Discord; -public static class SysCordSettings -{ - public static DiscordManager Manager { get; internal set; } = null!; - public static DiscordSettings Settings => Manager.Config; - public static PokeTradeHubConfig HubConfig { get; internal set; } = null!; -} - public sealed class SysCord where T : PKM, new() { public static PokeBotRunner Runner { get; private set; } = null!; private readonly DiscordSocketClient _client; - private readonly DiscordManager Manager; + private readonly DiscordManager _manager; public readonly PokeTradeHub Hub; - // Keep the CommandService and DI container around for use with commands. - // These two types require you install the Discord.Net.Commands package. - private readonly CommandService _commands; + private readonly InteractionService _interactions; private readonly IServiceProvider _services; - // Track loading of Echo/Logging channels, so they aren't loaded multiple times. private bool MessageChannelsLoaded { get; set; } + private bool CommandsRegistered { get; set; } public SysCord(PokeBotRunner runner) { Runner = runner; Hub = runner.Hub; - Manager = new DiscordManager(Hub.Config.Discord); + _manager = new DiscordManager(Hub.Config.Discord); - SysCordSettings.Manager = Manager; + SysCordSettings.Manager = _manager; SysCordSettings.HubConfig = Hub.Config; _client = new DiscordSocketClient(new DiscordSocketConfig { - // How much logging do you want to see? LogLevel = LogSeverity.Info, - GatewayIntents = Guilds | GuildMessages | DirectMessages | GuildMembers | GuildPresences | MessageContent, - // If you or another service needs to do anything with messages - // (ex. checking Reactions, checking the content of edited/deleted messages), - // you must set the MessageCacheSize. You may adjust the number as needed. - //MessageCacheSize = 50, + GatewayIntents = Guilds | GuildMessages | DirectMessages, }); - _commands = new CommandService(new CommandServiceConfig + _interactions = new InteractionService(_client.Rest, new InteractionServiceConfig { - // Again, log level: LogLevel = LogSeverity.Info, - - // This makes commands get run on the task thread pool instead on the websocket read thread. - // This ensures long-running logic can't block the websocket connection. DefaultRunMode = Hub.Config.Discord.AsyncCommands ? RunMode.Async : RunMode.Sync, - - // There's a few more properties you can set, - // for example, case-insensitive commands. - CaseSensitiveCommands = false, }); - // Subscribe the logging handler to both the client and the CommandService. _client.Log += Log; - _commands.Log += Log; - - // Setup your DI container. + _interactions.Log += Log; _services = ConfigureServices(); } - // If any services require the client, or the CommandService, or something else you keep on hand, - // pass them as parameters into this method as needed. - // If this method is getting pretty long, you can separate it out into another file using partials. - private static ServiceProvider ConfigureServices() + private ServiceProvider ConfigureServices() { - var map = new ServiceCollection();//.AddSingleton(new SomeServiceClass()); - - // When all your required services are in the collection, build the container. - // Tip: There's an overload taking in a 'validateScopes' bool to make sure - // you haven't made any mistakes in your dependency graph. - return map.BuildServiceProvider(); + return new ServiceCollection() + .AddSingleton(_client) + .AddSingleton(_interactions) + .BuildServiceProvider(); } - // Example of a logging handler. This can be reused by add-ons - // that ask for a Func. - private static Task Log(LogMessage msg) { var text = $"[{msg.Severity,8}] {msg.Source}: {msg.Message} {msg.Exception}"; Console.ForegroundColor = GetTextColor(msg.Severity); Console.WriteLine($"{DateTime.Now,-19} {text}"); Console.ResetColor(); - LogUtil.LogText($"SysCord: {text}"); - return Task.CompletedTask; } @@ -110,10 +80,8 @@ private static Task Log(LogMessage msg) { LogSeverity.Critical => ConsoleColor.Red, LogSeverity.Error => ConsoleColor.Red, - LogSeverity.Warning => ConsoleColor.Yellow, LogSeverity.Info => ConsoleColor.White, - LogSeverity.Verbose => ConsoleColor.DarkGray, LogSeverity.Debug => ConsoleColor.DarkGray, _ => Console.ForegroundColor, @@ -121,132 +89,119 @@ private static Task Log(LogMessage msg) public async Task MainAsync(string apiToken, CancellationToken token) { - // Centralize the logic for commands into a separate method. await InitCommands().ConfigureAwait(false); - - // Login and connect. await _client.LoginAsync(TokenType.Bot, apiToken).ConfigureAwait(false); - await _client.StartAsync().ConfigureAwait(false); var app = await _client.GetApplicationInfoAsync().ConfigureAwait(false); - Manager.Owner = app.Owner.Id; + _manager.Owner = app.Owner.Id; - // Wait infinitely so your bot actually stays connected. + await _client.StartAsync().ConfigureAwait(false); await MonitorStatusAsync(token).ConfigureAwait(false); } public async Task InitCommands() { - var assembly = Assembly.GetExecutingAssembly(); - - await _commands.AddModulesAsync(assembly, _services).ConfigureAwait(false); - var genericTypes = assembly.DefinedTypes.Where(z => z.IsSubclassOf(typeof(ModuleBase)) && z.IsGenericType); - foreach (var t in genericTypes) - { - var genModule = t.MakeGenericType(typeof(T)); - await _commands.AddModuleAsync(genModule, _services).ConfigureAwait(false); - } - var modules = _commands.Modules.ToList(); - + // All modules are suffixed with "Module" in their class name. + // The blacklist is a comma-separated list of module names (without the "Module" suffix) that should not be added to the bot. var blacklist = Hub.Config.Discord.ModuleBlacklist - .Replace("Module", "").Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(z => z.Trim()).ToList(); + .Replace("Module", "") + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(z => z.Trim()) + .ToList(); - foreach (var module in modules) - { - var name = module.Name; - name = name.Replace("Module", ""); - var gen = name.IndexOf('`'); - if (gen != -1) - name = name[..gen]; - if (blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase))) - await _commands.RemoveModuleAsync(module).ConfigureAwait(false); - } + var assembly = Assembly.GetExecutingAssembly(); + await LoadSlashCommandsFromAssembly(assembly, blacklist).ConfigureAwait(false); - // Subscribe a handler to see if a message invokes a command. - _client.Ready += LoadLoggingAndEcho; - _client.MessageReceived += HandleMessageAsync; + _client.Ready += LoadCommandsAndChannels; + _client.InteractionCreated += HandleInteractionAsync; } - private async Task HandleMessageAsync(SocketMessage arg) + private async Task LoadSlashCommandsFromAssembly(Assembly assembly, List blacklist) { - // Bail out if it's a System Message. - if (arg is not SocketUserMessage msg) - return; + var moduleTypes = assembly.DefinedTypes + .Where(z => z is { IsAbstract: false, IsGenericTypeDefinition: false } && typeof(InteractionModuleBase).IsAssignableFrom(z.AsType())) + .Select(z => z.AsType()); + var genericTypes = assembly.DefinedTypes + .Where(z => z is { IsAbstract: false, IsGenericTypeDefinition: true } && typeof(InteractionModuleBase).IsAssignableFrom(z.AsType())) + .Select(z => z.MakeGenericType(typeof(T))); - // We don't want the bot to respond to itself or other bots. - if (msg.Author.Id == _client.CurrentUser.Id || msg.Author.IsBot) - return; + var types = moduleTypes.Concat(genericTypes); - // Create a number to track where the prefix ends and the command begins - int pos = 0; - if (msg.HasStringPrefix(Hub.Config.Discord.CommandPrefix, ref pos)) + foreach (var module in types) { - bool handled = await TryHandleCommandAsync(msg, pos).ConfigureAwait(false); - if (handled) - return; + if (!IsBlacklisted(module, blacklist)) + await _interactions.AddModuleAsync(module, _services).ConfigureAwait(false); } + } - await TryHandleMessageAsync(msg).ConfigureAwait(false); + private static bool IsBlacklisted(Type module, List blacklist) + { + var name = GetModuleName(module.Name); + return blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase)); } - private async Task TryHandleMessageAsync(SocketMessage msg) + private static string GetModuleName(string name) { - // should this be a service? - if (msg.Attachments.Count > 0) - { - var mgr = Manager; - var cfg = mgr.Config; - if (cfg.ConvertPKMToShowdownSet && (cfg.ConvertPKMReplyAnyChannel || mgr.CanUseCommandChannel(msg.Channel.Id))) - { - foreach (var att in msg.Attachments) - await msg.Channel.RepostPKMAsShowdownAsync(att).ConfigureAwait(false); - } - } + name = name.Replace("Module", ""); + // Trim off any generic type parameters (e.g., `1, `2) from the name for comparison purposes. + var gen = name.IndexOf('`'); + if (gen != -1) + name = name[..gen]; + return name; } - private async Task TryHandleCommandAsync(SocketUserMessage msg, int pos) + private async Task HandleInteractionAsync(SocketInteraction interaction) { - // Create a Command Context. - var context = new SocketCommandContext(_client, msg); + // Only application commands are relevant here. Keeping this check means + // future component/modal interactions can be handled independently. + if (interaction is not SocketSlashCommand command) + return; - // Check Permission - var mgr = Manager; - if (!mgr.CanUseCommandUser(msg.Author.Id)) + var context = new SocketInteractionContext(_client, command); + + if (!_manager.CanUseCommandUser(context.User.Id)) { - await msg.Channel.SendMessageAsync("You are not permitted to use this command.").ConfigureAwait(false); - return true; + await RespondErrorAsync(command, "You are not permitted to use this command.").ConfigureAwait(false); + return; } - if (!mgr.CanUseCommandChannel(msg.Channel.Id) && msg.Author.Id != mgr.Owner) + + if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) { if (Hub.Config.Discord.ReplyCannotUseCommandInChannel) - await msg.Channel.SendMessageAsync("You can't use that command here.").ConfigureAwait(false); - return true; + { + // Visibly reply (so that others can see). + await RespondErrorAsync(command, "You can't use that command here.", ephemeral: false).ConfigureAwait(false); + return; + } + + // Clean up the modal. + await command.DeferAsync(ephemeral: true).ConfigureAwait(false); + await command.DeleteOriginalResponseAsync().ConfigureAwait(false); + return; } - // Execute the command. (result does not indicate a return value, - // rather an object stating if the command executed successfully). - var guild = msg.Channel is SocketGuildChannel g ? g.Guild.Name : "Unknown Guild"; - await Log(new LogMessage(LogSeverity.Info, "Command", $"Executing command from {guild}#{msg.Channel.Name}:@{msg.Author.Username}. Content: {msg}")).ConfigureAwait(false); - var result = await _commands.ExecuteAsync(context, pos, _services).ConfigureAwait(false); - - if (result.Error == CommandError.UnknownCommand) - return false; - - // Uncomment the following lines if you want the bot - // to send a message if it failed. - // This does not catch errors from commands with 'RunMode.Async', - // subscribe a handler for '_commands.CommandExecuted' to see those. - if (!result.IsSuccess) - await msg.Channel.SendMessageAsync(result.ErrorReason).ConfigureAwait(false); - return true; + var commandName = command.Data.Name; + var location = context.Interaction.IsDMInteraction ? "Direct Messages" : context.Guild?.Name ?? "Unknown Guild"; + var status = $"Executing command from {location}#{context.Interaction.Channel?.Name ?? $"Unknown Channel: {channel}"}:@{context.User.Username}. Command: {commandName}"; + await Log(GetLog(LogSeverity.Info, status)).ConfigureAwait(false); + + var result = await _interactions.ExecuteCommandAsync(context, _services).ConfigureAwait(false); + if (!result.IsSuccess && !command.HasResponded) + await RespondErrorAsync(command, result.ErrorReason).ConfigureAwait(false); } + private static Task RespondErrorAsync(SocketInteraction interaction, string message, bool ephemeral = true) => interaction.HasResponded + ? interaction.FollowupAsync(message, ephemeral: ephemeral) + : interaction.RespondAsync(message, ephemeral: ephemeral); + + private static LogMessage GetLog(LogSeverity severity, string message, [CallerMemberName] string identity = "") + => new (severity, identity, message); + private async Task MonitorStatusAsync(CancellationToken token) { - const int Interval = 20; // seconds - // Check datetime for update - UserStatus state = UserStatus.Idle; + const int interval = 20; + var state = UserStatus.Idle; + while (!token.IsCancellationRequested) { var time = DateTime.Now; @@ -259,7 +214,7 @@ private async Task MonitorStatusAsync(CancellationToken token) lastLogged = recent?.LastTime ?? time; } var delta = time - lastLogged; - var gap = TimeSpan.FromSeconds(Interval) - delta; + var gap = TimeSpan.FromSeconds(interval) - delta; bool noQueue = !Hub.Queues.Info.GetCanQueue(); if (gap <= TimeSpan.Zero) @@ -284,8 +239,25 @@ private async Task MonitorStatusAsync(CancellationToken token) } } - private async Task LoadLoggingAndEcho() + // There is a global rate limit of 200 application command creates per day, per guild + // We'll still be good citizens and only trigger an update if the modules were revised. + private string ComputeSlashCommandHash() { + var commands = _interactions.SlashCommands; + var json = JsonSerializer.Serialize(commands.Select(c => new { + c.Name, + c.Description, + Params = c.Parameters.Select(p => new { p.Name, p.Description, p.DiscordOptionType }) + })); + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json))); + } + + private async Task LoadCommandsAndChannels() + { + if (!CommandsRegistered) + await RegisterSlashCommands().ConfigureAwait(false); + if (MessageChannelsLoaded) return; @@ -297,11 +269,44 @@ private async Task LoadLoggingAndEcho() TradeStartModule.RestoreTradeStarting(_client); // Don't let it load more than once in case of Discord hiccups. - await Log(new LogMessage(LogSeverity.Info, "LoadLoggingAndEcho()", "Logging and Echo channels loaded!")).ConfigureAwait(false); + const string status = "Logging and Echo channels loaded!"; + await Log(GetLog(LogSeverity.Info, status)).ConfigureAwait(false); MessageChannelsLoaded = true; var game = Hub.Config.Discord.BotGameStatus; if (!string.IsNullOrWhiteSpace(game)) await _client.SetGameAsync(game).ConfigureAwait(false); } + + private async Task RegisterSlashCommands() + { + var cfg = SysCordSettings.HubConfig.Discord; + var hash = ComputeSlashCommandHash(); + if (hash == cfg.SlashCommandHash) + { + LogUtil.LogInfo("Skipped registering commands -- no signature changes."); + var count = _interactions.SlashCommands.Count; + SysCordSettings.SetCommandsRegistered(count); + return; + } + + try + { + // deleteMissing=true removes obsolete application commands left behind by previous versions of the bot. + // Global commands have a TTL of 1 hour + await _interactions.RegisterCommandsGloballyAsync(deleteMissing: true).ConfigureAwait(false); + CommandsRegistered = true; + + var count = _interactions.SlashCommands.Count; + SysCordSettings.SetCommandsRegistered(count); + cfg.SlashCommandHash = hash; + + var message = $"Registered {count} slash commands. Hash: {hash}"; + LogUtil.LogInfo(message); + } + catch (Exception ex) + { + LogUtil.LogSafe(ex); + } + } } diff --git a/SysBot.Pokemon.Discord/SysCordSettings.cs b/SysBot.Pokemon.Discord/SysCordSettings.cs new file mode 100644 index 000000000..09505e051 --- /dev/null +++ b/SysBot.Pokemon.Discord/SysCordSettings.cs @@ -0,0 +1,19 @@ +using System; + +namespace SysBot.Pokemon.Discord; + +public static class SysCordSettings +{ + public static DiscordManager Manager { get; internal set; } = null!; + public static DiscordSettings Settings => Manager.Config; + public static PokeTradeHubConfig HubConfig { get; internal set; } = null!; + + public static int CommandsRegistered { get; private set; } + public static DateTime CommandsRegisteredTime { get; private set; } + + public static void SetCommandsRegistered(int count) + { + CommandsRegistered = count; + CommandsRegisteredTime = DateTime.UtcNow; + } +} diff --git a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs index ed1903681..0e486cf10 100644 --- a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs +++ b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs @@ -1,49 +1,58 @@ +using System; using PKHeX.Core; using SysBot.Base; -using System; namespace SysBot.Pokemon.Twitch; public static class TwitchCommandsHelper where T : PKM, new() { - // Helper functions for commands - public static bool AddToWaitingList(string setstring, string display, string username, ulong mUserId, bool sub, out string msg) + /// + /// Adds a user to the waiting list for a trade request. + /// + /// The Showdown set string representing the Pokémon. + /// The display name of the user. + /// The username of the user. + /// The user ID of the user. + /// Indicates if the user is a subscriber. + /// The message to be returned to the user. + /// True if the request was added to the queue. + public static bool AddToWaitingList(string showdownSet, string display, string username, ulong mUserId, bool sub, out string message) { if (!TwitchBot.Info.GetCanQueue()) { - msg = "Sorry, I am not currently accepting queue requests!"; + message = "Sorry, I am not currently accepting queue requests!"; return false; } - var set = ShowdownUtil.ConvertToShowdown(setstring); + var set = ShowdownUtil.ConvertToShowdown(showdownSet); if (set == null) { - msg = $"Skipping trade, @{username}: Empty nickname provided for the species."; + message = $"Skipping trade, @{username}: Empty nickname provided for the species."; return false; } var template = AutoLegalityWrapper.GetTemplate(set); if (template.Species == 0) { - msg = $"Skipping trade, @{username}: Please read what you are supposed to type as the command argument."; + message = $"Skipping trade, @{username}: Please read what you are supposed to type as the command argument."; return false; } if (set.InvalidLines.Count != 0) { - msg = $"Skipping trade, @{username}: Unable to parse Showdown Set:\n{string.Join("\n", set.InvalidLines)}"; + message = $"Skipping trade, @{username}: Unable to parse Showdown Set:\n{string.Join('\n', set.InvalidLines)}"; return false; } try { var sav = AutoLegalityWrapper.GetTrainerInfo(); - PKM pkm = sav.GetLegal(template, out var result); + var pkm = sav.GetLegal(template, out var result); var la = new LegalityAnalysis(pkm); var enc = la.EncounterOriginal; if (!pkm.CanBeTraded(enc)) { - msg = $"Skipping trade, @{username}: Provided Pokémon content is blocked from trading!"; + message = $"Skipping trade, @{username}: Provided Pokémon content is blocked from trading!"; return false; } @@ -52,20 +61,23 @@ public static bool AddToWaitingList(string setstring, string display, string use if (la.Valid) { var tq = new TwitchQueue(pk, new PokeTradeTrainerInfo(display, mUserId), username, sub); - TwitchBot.QueuePool.RemoveAll(z => z.UserName == username); // remove old requests if any - TwitchBot.QueuePool.Add(tq); - msg = $"@{username} - added to the waiting list. Please whisper your trade code to me! Your request from the waiting list will be removed if you are too slow!"; + + var pool = TwitchBot.QueuePool; + pool.RemoveAll(z => z.Username == username); // remove old requests if any + pool.Add(tq); + + message = $"@{username} - added to the waiting list. Please whisper your trade code to me! Your request from the waiting list will be removed if you are too slow!"; return true; } } var reason = result == "Timeout" ? "Set took too long to generate." : "Unable to legalize the Pokémon."; - msg = $"Skipping trade, @{username}: {reason}"; + message = $"Skipping trade, @{username}: {reason}"; } catch (Exception ex) { - LogUtil.LogSafe(ex, nameof(TwitchCommandsHelper)); - msg = $"Skipping trade, @{username}: An unexpected problem occurred."; + LogUtil.LogSafe(ex); + message = $"Skipping trade, @{username}: An unexpected problem occurred."; } return false; } @@ -76,9 +88,9 @@ public static string ClearTrade(string user) return GetClearTradeMessage(result); } - public static string ClearTrade(ulong userID) + public static string ClearTrade(ulong userId) { - var result = TwitchBot.Info.ClearTrade(userID); + var result = TwitchBot.Info.ClearTrade(userId); return GetClearTradeMessage(result); } diff --git a/SysBot.Pokemon.Twitch/Helpers/TwitchQueue.cs b/SysBot.Pokemon.Twitch/Helpers/TwitchQueue.cs index 7a4772861..be8b33c51 100644 --- a/SysBot.Pokemon.Twitch/Helpers/TwitchQueue.cs +++ b/SysBot.Pokemon.Twitch/Helpers/TwitchQueue.cs @@ -1,13 +1,9 @@ -using PKHeX.Core; +using PKHeX.Core; namespace SysBot.Pokemon.Twitch; -public class TwitchQueue(T Entity, PokeTradeTrainerInfo Trainer, string Username, bool Subscriber) +public sealed record TwitchQueue(T Entity, PokeTradeTrainerInfo Trainer, string Username, bool IsSubscriber) where T : PKM, new() { - public T Entity { get; } = Entity; - public PokeTradeTrainerInfo Trainer { get; } = Trainer; - public string UserName { get; } = Username; public string DisplayName => Trainer.TrainerName; - public bool IsSubscriber { get; } = Subscriber; } diff --git a/SysBot.Pokemon.Twitch/Helpers/TwitchTradeNotifier.cs b/SysBot.Pokemon.Twitch/Helpers/TwitchTradeNotifier.cs index a7e0ecf61..27fbb47c4 100644 --- a/SysBot.Pokemon.Twitch/Helpers/TwitchTradeNotifier.cs +++ b/SysBot.Pokemon.Twitch/Helpers/TwitchTradeNotifier.cs @@ -1,7 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Linq; +using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; using TwitchLib.Client; namespace SysBot.Pokemon.Twitch; @@ -31,41 +32,41 @@ public TwitchTradeNotifier(T data, PokeTradeTrainerInfo info, int code, string u public Action>? OnFinish { private get; set; } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) { LogUtil.LogText(message); - SendMessage($"@{info.Trainer.TrainerName}: {message}", Settings.NotifyDestination); + await SendMessage($"@{info.Trainer.TrainerName}: {message}", Settings.NotifyDestination).ConfigureAwait(false); } - public void TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) + public async Task TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) { OnFinish?.Invoke(routine); var line = $"@{info.Trainer.TrainerName}: Trade canceled, {msg}"; LogUtil.LogText(line); - SendMessage(line, Settings.TradeCanceledDestination); + await SendMessage(line, Settings.TradeCanceledDestination).ConfigureAwait(false); } - public void TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) + public async Task TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) { OnFinish?.Invoke(routine); var tradedToUser = Data.Species; var message = $"@{info.Trainer.TrainerName}: " + (tradedToUser != 0 ? $"Trade finished. Enjoy your {(Species)tradedToUser}!" : "Trade finished!"); LogUtil.LogText(message); - SendMessage(message, Settings.TradeFinishDestination); + await SendMessage(message, Settings.TradeFinishDestination).ConfigureAwait(false); } - public void TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) + public async Task TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) { var receive = Data.Species == 0 ? string.Empty : $" ({Data.Nickname})"; - var msg = $"@{info.Trainer.TrainerName} (ID: {info.ID}): Initializing trade{receive} with you. Please be ready. Use the code you whispered me to search!"; + var msg = $"@{info.Trainer.TrainerName} (ID: {info.Id}): Initializing trade{receive} with you. Please be ready. Use the code you whispered me to search!"; var dest = Settings.TradeStartDestination; if (dest == TwitchMessageDestination.Whisper) msg += $" Your trade code is: {info.Code:0000 0000}"; LogUtil.LogText(msg); - SendMessage(msg, dest); + await SendMessage(msg, dest).ConfigureAwait(false); } - public void TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) + public async Task TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) { var name = Info.TrainerName; var trainer = string.IsNullOrEmpty(name) ? string.Empty : $", @{name}"; @@ -76,34 +77,34 @@ public void TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail in else if (dest == TwitchMessageDestination.Whisper) message += $" Your trade code is: {info.Code:0000 0000}"; LogUtil.LogText(message); - SendMessage($"@{info.Trainer.TrainerName} {message}", dest); + await SendMessage($"@{info.Trainer.TrainerName} {message}", dest).ConfigureAwait(false); } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary trade) { - var msg = message.Summary; - if (message.Details.Count > 0) - msg += ", " + string.Join(", ", message.Details.Select(z => $"{z.Heading}: {z.Detail}")); + var msg = trade.Summary; + if (trade.Details.Count > 0) + msg += ", " + string.Join(", ", trade.Details.Select(z => $"{z.Heading}: {z.Detail}")); LogUtil.LogText(msg); - SendMessage(msg, Settings.NotifyDestination); + await SendMessage(msg, Settings.NotifyDestination); } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) { var msg = $"Details for {result.FileName}: " + message; LogUtil.LogText(msg); - SendMessage(msg, Settings.NotifyDestination); + await SendMessage(msg, Settings.NotifyDestination).ConfigureAwait(false); } - private void SendMessage(string message, TwitchMessageDestination dest) + private async Task SendMessage(string message, TwitchMessageDestination dest) { switch (dest) { case TwitchMessageDestination.Channel: - _ = Client.SendMessageAsync(Channel, message, false); + await Client.SendMessageAsync(Channel, message).ConfigureAwait(false); break; case TwitchMessageDestination.Whisper: - _ = Client.SendMessageAsync(Channel, $"/w {Username} {message}", false); + await Client.SendMessageAsync(Channel, $"/w {Username} {message}").ConfigureAwait(false); break; } } diff --git a/SysBot.Pokemon.Twitch/TwitchBot.cs b/SysBot.Pokemon.Twitch/TwitchBot.cs index a2369803c..3f3cff4e2 100644 --- a/SysBot.Pokemon.Twitch/TwitchBot.cs +++ b/SysBot.Pokemon.Twitch/TwitchBot.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; using TwitchLib.Client; using TwitchLib.Client.Events; using TwitchLib.Client.Models; @@ -13,18 +13,18 @@ namespace SysBot.Pokemon.Twitch; public class TwitchBot where T : PKM, new() { - private static PokeTradeHub Hub = null!; - internal static TradeQueueInfo Info => Hub.Queues.Info; + private static PokeTradeHub _hub = null!; + internal static TradeQueueInfo Info => _hub.Queues.Info; internal static readonly List> QueuePool = []; - private readonly TwitchClient client; - private readonly string Channel; - private readonly TwitchSettings Settings; + private readonly TwitchClient _client; + private readonly string _channel; + private readonly TwitchSettings _settings; public TwitchBot(TwitchSettings settings, PokeTradeHub hub) { - Hub = hub; - Settings = settings; + _hub = hub; + _settings = settings; var credentials = new ConnectionCredentials(settings.Username.ToLower(), settings.Token); @@ -34,47 +34,47 @@ public TwitchBot(TwitchSettings settings, PokeTradeHub hub) // message send interval is managed (50ms for each message sent) }; - Channel = settings.Channel; + _channel = settings.Channel; WebSocketClient customClient = new(clientOptions); - client = new TwitchClient(customClient); + _client = new TwitchClient(customClient); - client.Initialize(credentials, Channel); + _client.Initialize(credentials, _channel); - client.OnJoinedChannel += Client_OnJoinedChannel; - client.OnMessageReceived += Client_OnMessageReceived; - client.OnWhisperReceived += Client_OnWhisperReceived; - client.OnChatCommandReceived += Client_OnChatCommandReceived; - client.OnWhisperCommandReceived += Client_OnWhisperCommandReceived; - client.OnConnected += Client_OnConnected; - client.OnDisconnected += Client_OnDisconnected; - client.OnLeftChannel += Client_OnLeftChannel; + _client.OnJoinedChannel += Client_OnJoinedChannel; + _client.OnMessageReceived += Client_OnMessageReceived; + _client.OnWhisperReceived += Client_OnWhisperReceived; + _client.OnChatCommandReceived += Client_OnChatCommandReceived; + _client.OnWhisperCommandReceived += Client_OnWhisperCommandReceived; + _client.OnConnected += Client_OnConnected; + _client.OnDisconnected += Client_OnDisconnected; + _client.OnLeftChannel += Client_OnLeftChannel; - client.OnMessageSent += async (_, e) => + _client.OnMessageSent += async (_, e) => { - LogUtil.LogText($"[{client.TwitchUsername}] - Message Sent in {e.SentMessage.Channel}: {e.SentMessage.Message}"); + LogUtil.LogText($"[{_client.TwitchUsername}] - Message Sent in {e.SentMessage.Channel}: {e.SentMessage.Message}"); await Task.CompletedTask; }; - client.OnMessageThrottled += async (_, e) => + _client.OnMessageThrottled += async (_, e) => { - LogUtil.LogError($"Message Throttled: {e}", "TwitchBot"); + LogUtil.LogError($"Message Throttled: {e}"); await Task.CompletedTask; }; - client.OnError += (_, e) => + _client.OnError += (_, e) => { - LogUtil.LogError(e.Exception.Message + Environment.NewLine + e.Exception.StackTrace, "TwitchBot"); + LogUtil.LogError(e.Exception.Message + Environment.NewLine + e.Exception.StackTrace); return Task.CompletedTask; }; - client.OnConnectionError += (_, e) => + _client.OnConnectionError += (_, e) => { - LogUtil.LogError(e.BotUsername + Environment.NewLine + e.Error.Message, "TwitchBot"); + LogUtil.LogError(e.BotUsername + Environment.NewLine + e.Error.Message); return Task.CompletedTask; }; - _ = client.ConnectAsync(); + _ = _client.ConnectAsync(); - EchoUtil.Forwarders.Add(msg => _ = client.SendMessageAsync(Channel, msg, false)); + EchoUtil.Forwarders.Add(msg => _ = _client.SendMessageAsync(_channel, msg)); // Turn on if verified // Hub.Queues.Forwarders.Add((bot, detail) => client.SendMessage(Channel, $"{bot.Connection.Name} is now trading (ID {detail.ID}) {detail.Trainer.TrainerName}")); @@ -84,65 +84,72 @@ public void StartingDistribution(string message) { Task.Run(async () => { - await client.SendMessageAsync(Channel, "5...", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, "5...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessageAsync(Channel, "4...", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, "4...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessageAsync(Channel, "3...", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, "3...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessageAsync(Channel, "2...", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, "2...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessageAsync(Channel, "1...", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, "1...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(message)) - await client.SendMessageAsync(Channel, message, false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, message).ConfigureAwait(false); }); } - private bool AddToTradeQueue(T pk, int code, OnWhisperReceivedArgs e, RequestSignificance sig, PokeRoutineType type, out string msg) + private bool AddToTradeQueue(T pk, int code, OnWhisperReceivedArgs e, RequestSignificance sig, PokeRoutineType type, out string message) { // var user = e.WhisperMessage.UserId; - var userID = ulong.Parse(e.WhisperMessage.UserId); + var userId = ulong.Parse(e.WhisperMessage.UserId); var name = e.WhisperMessage.DisplayName; var trainer = new PokeTradeTrainerInfo(name, ulong.Parse(e.WhisperMessage.UserId)); - var notifier = new TwitchTradeNotifier(pk, trainer, code, e.WhisperMessage.Username, client, Channel, Hub.Config.Twitch); - var tt = type == PokeRoutineType.SeedCheck ? PokeTradeType.Seed : PokeTradeType.Specific; - var detail = new PokeTradeDetail(pk, trainer, notifier, tt, code, sig == RequestSignificance.Favored); - var trade = new TradeEntry(detail, userID, type, name); - - var added = Info.AddToTradeQueue(trade, userID, sig == RequestSignificance.Owner); + var notifier = new TwitchTradeNotifier(pk, trainer, code, e.WhisperMessage.Username, _client, _channel, _hub.Config.Twitch); + var tradeType = type == PokeRoutineType.SeedCheck ? PokeTradeType.Seed : PokeTradeType.Specific; + var detail = new PokeTradeDetail + { + IsFavored = sig.IsFavored, + Code = code, + TradeData = pk, + Trainer = trainer, + Notifier = notifier, + Type = tradeType, + }; + var trade = new TradeEntry(detail, userId, type, name); + var added = Info.AddToTradeQueue(trade, userId, sig.IsOwner); if (added == QueueResultAdd.AlreadyInQueue) { - msg = $"@{name}: Sorry, you are already in the queue."; + message = $"@{name}: Sorry, you are already in the queue."; return false; } - var position = Info.CheckPosition(userID, type); - msg = $"@{name}: Added to the {type} queue, unique ID: {detail.ID}. Current Position: {position.Position}"; + var position = Info.CheckPosition(userId, type); + message = $"@{name}: Added to the {type} queue, unique ID: {detail.Id}. Current Position: {position.Position}"; var botct = Info.Hub.Bots.Count; if (position.Position > botct) { var eta = Info.Hub.Config.Queues.EstimateDelay(position.Position, botct); - msg += $". Estimated: {eta:F1} minutes."; + message += $". Estimated: {eta:F1} minutes."; } return true; } private Task Client_OnConnected(object? sender, OnConnectedEventArgs e) { - LogUtil.LogText($"[{client.TwitchUsername}] - Connected as {e.BotUsername}"); + LogUtil.LogText($"[{_client.TwitchUsername}] - Connected as {e.BotUsername}"); return Task.CompletedTask; } private async Task Client_OnDisconnected(object? sender, OnDisconnectedArgs e) { - LogUtil.LogText($"[{client.TwitchUsername}] - Disconnected."); - while (!client.IsConnected) + LogUtil.LogText($"[{_client.TwitchUsername}] - Disconnected."); + while (!_client.IsConnected) { - await client.ReconnectAsync().ConfigureAwait(false); + await _client.ReconnectAsync().ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); } } @@ -150,25 +157,25 @@ private async Task Client_OnDisconnected(object? sender, OnDisconnectedArgs e) private async Task Client_OnJoinedChannel(object? sender, OnJoinedChannelArgs e) { LogUtil.LogInfo($"Joined {e.Channel}", e.BotUsername); - await client.SendMessageAsync(e.Channel, "Connected!", false).ConfigureAwait(false); + await _client.SendMessageAsync(e.Channel, "Connected!").ConfigureAwait(false); } private async Task Client_OnMessageReceived(object? sender, OnMessageReceivedArgs e) { - LogUtil.LogText($"[{client.TwitchUsername}] - Received message: @{e.ChatMessage.Username}: {e.ChatMessage.Message}"); - if (client.JoinedChannels.Count == 0) - await client.JoinChannelAsync(e.ChatMessage.Channel, false).ConfigureAwait(false); + LogUtil.LogText($"[{_client.TwitchUsername}] - Received message: @{e.ChatMessage.Username}: {e.ChatMessage.Message}"); + if (_client.JoinedChannels.Count == 0) + await _client.JoinChannelAsync(e.ChatMessage.Channel).ConfigureAwait(false); } private async Task Client_OnLeftChannel(object? sender, OnLeftChannelArgs e) { - LogUtil.LogText($"[{client.TwitchUsername}] - Left channel {e.Channel}"); - await client.JoinChannelAsync(e.Channel, false).ConfigureAwait(false); + LogUtil.LogText($"[{_client.TwitchUsername}] - Left channel {e.Channel}"); + await _client.JoinChannelAsync(e.Channel).ConfigureAwait(false); } private async Task Client_OnChatCommandReceived(object? sender, OnChatCommandReceivedArgs e) { - if (!Hub.Config.Twitch.AllowCommandsViaChannel || Hub.Config.Twitch.UserBlacklist.Contains(e.ChatMessage.Username)) + if (!_hub.Config.Twitch.AllowCommandsViaChannel || _hub.Config.Twitch.UserBlacklist.Contains(e.ChatMessage.Username)) return; var msg = e.ChatMessage; @@ -179,12 +186,12 @@ private async Task Client_OnChatCommandReceived(object? sender, OnChatCommandRec return; var channel = e.ChatMessage.Channel; - await client.SendMessageAsync(channel, response, false).ConfigureAwait(false); + await _client.SendMessageAsync(channel, response).ConfigureAwait(false); } private async Task Client_OnWhisperCommandReceived(object? sender, OnWhisperCommandReceivedArgs e) { - if (!Hub.Config.Twitch.AllowCommandsViaWhisper || Hub.Config.Twitch.UserBlacklist.Contains(e.WhisperMessage.Username)) + if (!_hub.Config.Twitch.AllowCommandsViaWhisper || _hub.Config.Twitch.UserBlacklist.Contains(e.WhisperMessage.Username)) return; var msg = e.WhisperMessage; @@ -194,20 +201,20 @@ private async Task Client_OnWhisperCommandReceived(object? sender, OnWhisperComm if (response.Length == 0) return; - await client.SendMessageAsync(Channel, $"/w {msg.Username} {response}", false).ConfigureAwait(false); + await _client.SendMessageAsync(_channel, $"/w {msg.Username} {response}").ConfigureAwait(false); } private string HandleCommand(TwitchLibMessage m, string c, string args, bool whisper) { - bool sudo() => m is ChatMessage ch && (ch.IsBroadcaster || Settings.IsSudo(m.Username)); - bool subscriber() => m is ChatMessage { SubscribedMonthCount: > 0 }; + bool IsSudo() => m is ChatMessage ch && (ch.IsBroadcaster || _settings.IsSudo(m.Username)); + bool IsSubscriber() => m is ChatMessage { SubscribedMonthCount: > 0 }; switch (c) { // User Usable Commands case "trade": - var _ = TwitchCommandsHelper.AddToWaitingList(args, m.DisplayName, m.Username, ulong.Parse(m.UserId), subscriber(), out string msg); - return msg; + var _ = TwitchCommandsHelper.AddToWaitingList(args, m.DisplayName, m.Username, ulong.Parse(m.UserId), IsSubscriber(), out var message); + return message; case "ts": return $"@{m.Username}: {Info.GetPositionString(ulong.Parse(m.UserId))}"; case "tc": @@ -217,11 +224,11 @@ private string HandleCommand(TwitchLibMessage m, string c, string args, bool whi return TwitchCommandsHelper.GetCode(ulong.Parse(m.UserId)); // Sudo Only Commands - case "tca" when !sudo(): - case "pr" when !sudo(): - case "pc" when !sudo(): - case "tt" when !sudo(): - case "tcu" when !sudo(): + case "tca" when !IsSudo(): + case "pr" when !IsSudo(): + case "pc" when !IsSudo(): + case "tt" when !IsSudo(): + case "tcu" when !IsSudo(): return "This command is locked for sudo users only!"; case "tca": @@ -229,7 +236,7 @@ private string HandleCommand(TwitchLibMessage m, string c, string args, bool whi return "Cleared all queues!"; case "pr": - return Info.Hub.Ledy.Pool.Reload(Hub.Config.Folder.DistributeFolder) ? $"Reloaded from folder. Pool count: {Info.Hub.Ledy.Pool.Count}" : "Failed to reload from folder."; + return Info.Hub.Ledy.Pool.Reload(_hub.Config.Folder.DistributeFolder) ? $"Reloaded from folder. Pool count: {Info.Hub.Ledy.Pool.Count}" : "Failed to reload from folder."; case "pc": return $"The pool count is: {Info.Hub.Ledy.Pool.Count}"; @@ -248,39 +255,42 @@ private string HandleCommand(TwitchLibMessage m, string c, string args, bool whi private async Task Client_OnWhisperReceived(object? sender, OnWhisperReceivedArgs e) { - LogUtil.LogText($"[{client.TwitchUsername}] - @{e.WhisperMessage.Username}: {e.WhisperMessage.Message}"); + LogUtil.LogText($"[{_client.TwitchUsername}] - @{e.WhisperMessage.Username}: {e.WhisperMessage.Message}"); if (QueuePool.Count > 100) { var removed = QueuePool[0]; QueuePool.RemoveAt(0); // First in, first out - await client.SendMessageAsync(Channel, $"Removed @{removed.DisplayName} ({(Species)removed.Entity.Species}) from the waiting list: stale request.", false).ConfigureAwait(false); + + var message = $"Removed @{removed.DisplayName} ({(Species)removed.Entity.Species}) from the waiting list: stale request."; + await _client.SendMessageAsync(_channel, message).ConfigureAwait(false); } - var user = QueuePool.FindLast(q => q.UserName == e.WhisperMessage.Username); + var user = QueuePool.FindLast(q => q.Username == e.WhisperMessage.Username); if (user == null) return; + QueuePool.Remove(user); var msg = e.WhisperMessage.Message; try { int code = Util.ToInt32(msg); var sig = GetUserSignificance(user); - _ = AddToTradeQueue(user.Entity, code, e, sig, PokeRoutineType.LinkTrade, out string message); - await client.SendMessageAsync(Channel, message, false).ConfigureAwait(false); + _ = AddToTradeQueue(user.Entity, code, e, sig, PokeRoutineType.LinkTrade, out var message); + await _client.SendMessageAsync(_channel, message).ConfigureAwait(false); } catch (Exception ex) { - LogUtil.LogSafe(ex, nameof(TwitchBot)); - LogUtil.LogError($"{ex.Message}", nameof(TwitchBot)); + LogUtil.LogSafe(ex); + LogUtil.LogError($"{ex.Message}"); } } private RequestSignificance GetUserSignificance(TwitchQueue user) { - var name = user.UserName; - if (name == Channel) + var name = user.Username; + if (name == _channel) return RequestSignificance.Owner; - if (Settings.IsSudo(user.UserName)) + if (_settings.IsSudo(user.Username)) return RequestSignificance.Favored; return user.IsSubscriber ? RequestSignificance.Favored : RequestSignificance.None; } diff --git a/SysBot.Pokemon.WinForms/ConfigLoader.cs b/SysBot.Pokemon.WinForms/ConfigLoader.cs index 7385fe42a..8f51b82f7 100644 --- a/SysBot.Pokemon.WinForms/ConfigLoader.cs +++ b/SysBot.Pokemon.WinForms/ConfigLoader.cs @@ -1,8 +1,8 @@ -using SysBot.Base; using System; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text.Json; +using SysBot.Base; namespace SysBot.Pokemon.WinForms; diff --git a/SysBot.Pokemon.WinForms/Controls/BotController.cs b/SysBot.Pokemon.WinForms/Controls/BotController.cs index c616a77d0..01bfacb3a 100644 --- a/SysBot.Pokemon.WinForms/Controls/BotController.cs +++ b/SysBot.Pokemon.WinForms/Controls/BotController.cs @@ -1,9 +1,9 @@ -using SysBot.Base; using System; using System.ComponentModel; using System.Drawing; using System.Linq; using System.Windows.Forms; +using SysBot.Base; namespace SysBot.Pokemon.WinForms; @@ -146,7 +146,7 @@ public void SendCommand(BotControlCommand cmd, bool echo = true) { if (Runner?.Config.SkipConsoleBotCreation != false) { - LogUtil.LogError("No bots were created because SkipConsoleBotCreation is on!", "Hub"); + LogUtil.LogError("No bots were created because SkipConsoleBotCreation is on!"); return; } var bot = GetBot(); @@ -187,9 +187,7 @@ private BotSource GetBot() return bot; } -#pragma warning disable WFO5001 private void BotController_MouseEnter(object? sender, EventArgs e) => BackColor = Application.IsDarkModeEnabled ? Color.MidnightBlue : Color.LightSkyBlue; -#pragma warning restore WFO5001 private void BotController_MouseLeave(object? sender, EventArgs e) => BackColor = Color.Transparent; public void ReadState() @@ -226,14 +224,14 @@ public enum BotControlCommand public static class BotControlCommandExtensions { - public static bool IsUsable(this BotControlCommand cmd, bool running, bool paused) + extension(BotControlCommand command) { - return cmd switch + public bool IsUsable(bool isRunning, bool isPaused) => command switch { - BotControlCommand.Start => !running, - BotControlCommand.Stop => running, - BotControlCommand.Idle => running && !paused, - BotControlCommand.Resume => paused, + BotControlCommand.Start => !isRunning, + BotControlCommand.Stop => isRunning, + BotControlCommand.Idle => isRunning && !isPaused, + BotControlCommand.Resume => isPaused, BotControlCommand.Restart => true, _ => false, }; diff --git a/SysBot.Pokemon.WinForms/InitUtil.cs b/SysBot.Pokemon.WinForms/InitUtil.cs index f3aa1032a..6de7dffe0 100644 --- a/SysBot.Pokemon.WinForms/InitUtil.cs +++ b/SysBot.Pokemon.WinForms/InitUtil.cs @@ -5,19 +5,25 @@ namespace SysBot.Pokemon.WinForms; public static class InitUtil { - public static void InitializeStubs(ProgramMode mode) + public static void InitializeStubs(ProgramMode mode, string trainer, LanguageID language) { + Trainer = trainer; + Language = language; var sav = GetFakeSaveFile(mode); SetUpSpriteCreator(sav); } + private static string Trainer { get; set; } = "SysBot"; + private static LanguageID Language { get; set; } = LanguageID.English; + private static SaveFile Get(GameVersion version) => BlankSaveFile.Get(version, Trainer, Language); + private static SaveFile GetFakeSaveFile(ProgramMode mode) => mode switch { - ProgramMode.SWSH => new SAV8SWSH(), - ProgramMode.BDSP => new SAV8BS(), - ProgramMode.LA => new SAV8LA(), - ProgramMode.SV => new SAV9SV(), - ProgramMode.LZA => new SAV9ZA(), + ProgramMode.SWSH => Get(GameVersion.SW), + ProgramMode.BDSP => Get(GameVersion.BD), + ProgramMode.LA => Get(GameVersion.PLA), + ProgramMode.SV => Get(GameVersion.SV), + ProgramMode.LZA => Get(GameVersion.ZA), _ => throw new System.ArgumentOutOfRangeException(nameof(mode)), }; diff --git a/SysBot.Pokemon.WinForms/Main.cs b/SysBot.Pokemon.WinForms/Main.cs index d242a3016..949c53b14 100644 --- a/SysBot.Pokemon.WinForms/Main.cs +++ b/SysBot.Pokemon.WinForms/Main.cs @@ -1,11 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Drawing; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon.WinForms; @@ -33,7 +33,8 @@ public Main() Text = $"{Text} ({Config.Mode})"; Task.Run(BotMonitor); - InitUtil.InitializeStubs(Config.Mode); + var trainer = Config.Hub.Legality; + InitUtil.InitializeStubs(Config.Mode, trainer.GenerateOT, trainer.GenerateLanguage); if (Config.DarkMode) { @@ -145,7 +146,7 @@ private void B_Start_Click(object sender, EventArgs e) { SaveCurrentConfig(); - LogUtil.LogInfo("Starting all bots...", "Form"); + LogUtil.LogInfo("Starting all bots..."); RunningEnvironment.InitializeStart(); SendAll(BotControlCommand.Start); Tab_Logs.Select(); diff --git a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs index f5c7aabea..165d0aee6 100644 --- a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs +++ b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs @@ -1,10 +1,10 @@ -using PKHeX.Core; +using System.Threading; +using System.Threading.Tasks; +using PKHeX.Core; using SysBot.Pokemon.Discord; using SysBot.Pokemon.Twitch; using SysBot.Pokemon.WinForms; using SysBot.Pokemon.YouTube; -using System.Threading; -using System.Threading.Tasks; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon.YouTube/SysBot.Pokemon.YouTube.csproj b/SysBot.Pokemon.YouTube/SysBot.Pokemon.YouTube.csproj index 4f4fa9acb..f877ef0cc 100644 --- a/SysBot.Pokemon.YouTube/SysBot.Pokemon.YouTube.csproj +++ b/SysBot.Pokemon.YouTube/SysBot.Pokemon.YouTube.csproj @@ -1,10 +1,10 @@  - - - - + + + + diff --git a/SysBot.Pokemon.YouTube/YouTubeBot.cs b/SysBot.Pokemon.YouTube/YouTubeBot.cs index 725da7433..c20dcee98 100644 --- a/SysBot.Pokemon.YouTube/YouTubeBot.cs +++ b/SysBot.Pokemon.YouTube/YouTubeBot.cs @@ -1,10 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Google.Apis.YouTube.v3.Data; using PKHeX.Core; using StreamingClient.Base.Util; using SysBot.Base; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; using YouTube.Base; using YouTube.Base.Clients; @@ -12,41 +12,41 @@ namespace SysBot.Pokemon.YouTube; public class YouTubeBot where T : PKM, new() { - private ChatClient client; - private readonly YouTubeSettings Settings; + private ChatClient _client; + private readonly YouTubeSettings _settings; - private readonly PokeTradeHub Hub; - private TradeQueueInfo Info => Hub.Queues.Info; + private readonly PokeTradeHub _hub; + private TradeQueueInfo Info => _hub.Queues.Info; public YouTubeBot(YouTubeSettings settings, PokeTradeHub hub) { - Hub = hub; - Settings = settings; + _hub = hub; + _settings = settings; Logger.LogOccurred += Logger_LogOccurred; - client = null!; + _client = null!; Task.Run(async () => { try { - var connection = await YouTubeConnection.ConnectViaLocalhostOAuthBrowser(Settings.ClientID, Settings.ClientSecret, Scopes.scopes, true).ConfigureAwait(false); + var connection = await YouTubeConnection.ConnectViaLocalhostOAuthBrowser(_settings.ClientID, _settings.ClientSecret, Scopes.scopes, true).ConfigureAwait(false); if (connection == null) return; - var channel = await connection.Channels.GetChannelByID(Settings.ChannelID).ConfigureAwait(false); + var channel = await connection.Channels.GetChannelByID(_settings.ChannelID).ConfigureAwait(false); if (channel == null) return; - client = new ChatClient(connection); - client.OnMessagesReceived += Client_OnMessagesReceived; - EchoUtil.Forwarders.Add(msg => client.SendMessage(msg)); + _client = new ChatClient(connection); + _client.OnMessagesReceived += Client_OnMessagesReceived; + EchoUtil.Forwarders.Add(msg => _client.SendMessage(msg)); - if (await client.Connect().ConfigureAwait(false)) + if (await _client.Connect().ConfigureAwait(false)) await Task.Delay(-1).ConfigureAwait(false); } catch (Exception ex) { - LogUtil.LogError(ex.Message, nameof(YouTubeBot)); + LogUtil.LogError(ex.Message, nameof(YouTubeBot<>)); } }); } @@ -55,24 +55,24 @@ public void StartingDistribution(string message) { Task.Run(async () => { - await client.SendMessage("5...").ConfigureAwait(false); + await _client.SendMessage("5...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessage("4...").ConfigureAwait(false); + await _client.SendMessage("4...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessage("3...").ConfigureAwait(false); + await _client.SendMessage("3...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessage("2...").ConfigureAwait(false); + await _client.SendMessage("2...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); - await client.SendMessage("1...").ConfigureAwait(false); + await _client.SendMessage("1...").ConfigureAwait(false); await Task.Delay(1_000).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(message)) - await client.SendMessage(message).ConfigureAwait(false); + await _client.SendMessage(message).ConfigureAwait(false); }); } private string HandleCommand(LiveChatMessage m, string cmd, string args) { - if (!m.AuthorDetails.IsChatOwner.Equals(true) && Settings.IsSudo(m.AuthorDetails.DisplayName)) + if (!m.AuthorDetails.IsChatOwner.Equals(true) && _settings.IsSudo(m.AuthorDetails.DisplayName)) return string.Empty; // sudo only commands if (args.Length > 0) @@ -80,7 +80,7 @@ private string HandleCommand(LiveChatMessage m, string cmd, string args) return cmd switch { - "pr" => (Info.Hub.Ledy.Pool.Reload(Hub.Config.Folder.DistributeFolder) + "pr" => (Info.Hub.Ledy.Pool.Reload(_hub.Config.Folder.DistributeFolder) ? $"Reloaded from folder. Pool count: {Info.Hub.Ledy.Pool.Count}" : "Failed to reload from folder."), @@ -92,7 +92,7 @@ private string HandleCommand(LiveChatMessage m, string cmd, string args) private static void Logger_LogOccurred(object? sender, Log e) { - LogUtil.LogError(e.Message, nameof(YouTubeBot)); + LogUtil.LogError(e.Message, nameof(YouTubeBot<>)); } private void Client_OnMessagesReceived(object? sender, IEnumerable messages) @@ -112,7 +112,7 @@ private void Client_OnMessagesReceived(object? sender, IEnumerable : ISeedSearchHandler where T : PKM, new() { - public void CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot) + public async Task CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, + PokeRoutineExecutor bot) { // Let PKHeX try and deduce it first. Usually will be the best match. - if (TryPKHeX(pkm, detail, settings, bot) && !settings.ShowAllZ3Results) + if (await TryPKHeX(pkm, detail, settings, bot).ConfigureAwait(false) && !settings.ShowAllZ3Results) return; var ec = pkm.EncryptionConstant; @@ -25,30 +27,30 @@ public void CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettin foreach (var match in matches) { var lump = new PokeTradeSummary("Calculated Seed:", match); - detail.SendNotification(bot, lump); + await detail.SendNotification(bot, lump).ConfigureAwait(false); } } else { var match = Z3Search.GetFirstSeed(ec, pid, IVs, settings.ResultDisplayMode); var lump = new PokeTradeSummary("Calculated Seed:", match); - detail.SendNotification(bot, lump); + await detail.SendNotification(bot, lump).ConfigureAwait(false); } } - private static bool TryPKHeX(T pk, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot) + private static async Task TryPKHeX(T pk, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot) { var la = new LegalityAnalysis(pk); var enc = la.Info.EncounterMatch; - if (enc is not ISeedCorrelation64 x) + if (enc is not ISeedCorrelation64 correlated) return false; - if (x.TryGetSeed(pk, out var seed) != SeedCorrelationResult.Success) + if (correlated.TryGetSeed(pk, out var seed) != SeedCorrelationResult.Success) return false; var flawless = enc is IFlawlessIVCount f ? f.FlawlessIVCount : 0; var result = new SeedSearchResult(Z3SearchResult.Success, seed, flawless, settings.ResultDisplayMode); var lump = new PokeTradeSummary("Calculated Seed:", result); - detail.SendNotification(bot, lump); + await detail.SendNotification(bot, lump).ConfigureAwait(false); return true; } } diff --git a/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs b/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs index 4204003c0..0cfb42196 100644 --- a/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs +++ b/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs @@ -1,10 +1,10 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; using static SysBot.Base.SwitchButton; namespace SysBot.Pokemon; @@ -62,7 +62,7 @@ public static void DumpPokemon(string folder, string subfolder, T pk) Span data = stackalloc byte[pk.SIZE_PARTY]; pk.WriteDecryptedDataParty(data); File.WriteAllBytes(fn, data); - LogUtil.LogInfo($"Saved file: {fn}", "Dump"); + LogUtil.LogInfo($"Saved file: {fn}"); } public async Task TryReconnect(int attempts, int extraDelay, SwitchProtocol protocol, CancellationToken token) @@ -171,7 +171,7 @@ protected async Task CheckPartnerReputation(PokeRoutineExecutor if (cd != 0 && TimeSpan.FromMinutes(cd) > delta) { var wait = TimeSpan.FromMinutes(cd) - delta; - poke.Notifier.SendNotification(bot, poke, $"You are still on trade cooldown and cannot trade for another {wait.TotalMinutes:F1} minute(s)."); + await poke.Notifier.SendNotification(bot, poke, $"You are still on trade cooldown and cannot trade for another {wait.TotalMinutes:F1} minute(s).").ConfigureAwait(false); var msg = $"Found {user.TrainerName}{useridmsg} ignoring the {cd} minute trade cooldown. Last encountered {delta.TotalMinutes:F1} minutes ago."; if (AbuseSettings.EchoNintendoOnlineIDCooldown) msg += $"\nID: {TrainerNID}"; @@ -192,7 +192,7 @@ protected async Task CheckPartnerReputation(PokeRoutineExecutor await BlockUser(token).ConfigureAwait(false); if (AbuseSettings.BanIDWhenBlockingUser || bot is not PokeRoutineExecutor8SWSH) // Only ban ID if blocking in SWSH, always in other games. { - AbuseSettings.BannedIDs.AddIfNew([GetReference(TrainerName, TrainerNID, "in-game block for multiple accounts")]); + AbuseSettings.BannedIDs.AddIfNew(GetReference(TrainerName, TrainerNID, "in-game block for multiple accounts")); Log($"Added {TrainerNID} to the BannedIDs list."); } } @@ -222,7 +222,7 @@ protected async Task CheckPartnerReputation(PokeRoutineExecutor await BlockUser(token).ConfigureAwait(false); if (AbuseSettings.BanIDWhenBlockingUser || bot is not PokeRoutineExecutor8SWSH) // Only ban ID if blocking in SWSH, always in other games. { - AbuseSettings.BannedIDs.AddIfNew([GetReference(TrainerName, TrainerNID, "in-game block for sending to multiple in-game players")]); + AbuseSettings.BannedIDs.AddIfNew(GetReference(TrainerName, TrainerNID, "in-game block for sending to multiple in-game players")); Log($"Added {TrainerNID} to the BannedIDs list."); } } diff --git a/SysBot.Pokemon/Actions/PokeRoutineExecutorBase.cs b/SysBot.Pokemon/Actions/PokeRoutineExecutorBase.cs index 417a7eec2..cb90f64a0 100644 --- a/SysBot.Pokemon/Actions/PokeRoutineExecutorBase.cs +++ b/SysBot.Pokemon/Actions/PokeRoutineExecutorBase.cs @@ -1,7 +1,7 @@ -using PKHeX.Core; -using SysBot.Base; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Actions/PokeRoutineType.cs b/SysBot.Pokemon/Actions/PokeRoutineType.cs index e34ed22b4..e5d51b9a2 100644 --- a/SysBot.Pokemon/Actions/PokeRoutineType.cs +++ b/SysBot.Pokemon/Actions/PokeRoutineType.cs @@ -48,5 +48,8 @@ public enum PokeRoutineType public static class PokeRoutineTypeExtensions { - public static bool IsTradeBot(this PokeRoutineType type) => type is >= PokeRoutineType.FlexTrade and <= PokeRoutineType.Dump; + extension(PokeRoutineType type) + { + public bool IsTradeBot() => type is (>= PokeRoutineType.FlexTrade and <= PokeRoutineType.Dump); + } } diff --git a/SysBot.Pokemon/BDSP/BotFactory8BS.cs b/SysBot.Pokemon/BDSP/BotFactory8BS.cs index d1cb51ea9..b7da7ceee 100644 --- a/SysBot.Pokemon/BDSP/BotFactory8BS.cs +++ b/SysBot.Pokemon/BDSP/BotFactory8BS.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/BDSP/BotRemoteControl/RemoteControlBotBS.cs b/SysBot.Pokemon/BDSP/BotRemoteControl/RemoteControlBotBS.cs index e9b92ca33..3c75c2b9c 100644 --- a/SysBot.Pokemon/BDSP/BotRemoteControl/RemoteControlBotBS.cs +++ b/SysBot.Pokemon/BDSP/BotRemoteControl/RemoteControlBotBS.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs b/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs index fa3bcfee5..45ff5bb10 100644 --- a/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs +++ b/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs @@ -1,11 +1,10 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; -using SysBot.Base; using System; -using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; +using SysBot.Base; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.BasePokeDataOffsetsBS; @@ -182,7 +181,7 @@ private async Task PerformTrade(SAV8BS sav, PokeTradeDetail detail, PokeRou { Log(socket.Message); result = PokeTradeResult.ExceptionConnection; - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); throw; // let this interrupt the trade loop. re-entering the trade loop will recheck the connection. } catch (Exception e) @@ -191,22 +190,22 @@ private async Task PerformTrade(SAV8BS sav, PokeTradeDetail detail, PokeRou result = PokeTradeResult.ExceptionInternal; } - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); } - private void HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) + private async Task HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) { detail.IsProcessing = false; if (result.ShouldAttemptRetry() && detail.Type != PokeTradeType.Random && !detail.IsRetry) { detail.IsRetry = true; Hub.Queues.Enqueue(type, detail, Math.Min(priority, PokeTradePriorities.Tier2)); - detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt."); + await detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt.").ConfigureAwait(false); } else { - detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}."); - detail.TradeCanceled(this, result); + await detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}.").ConfigureAwait(false); + await detail.TradeCanceled(this, result).ConfigureAwait(false); } } @@ -214,7 +213,7 @@ private async Task PerformLinkCodeTrade(SAV8BS sav, PokeTradeDe { // Update Barrier Settings UpdateBarrier(poke.IsSynchronized); - poke.TradeInitialize(this); + await poke.TradeInitialize(this).ConfigureAwait(false); Hub.Config.Stream.EndEnterCode(this); var distroRemainInRoom = poke.Type == PokeTradeType.Random && Hub.Config.Distribution.RemainInUnionRoomBDSP; @@ -242,7 +241,7 @@ private async Task PerformLinkCodeTrade(SAV8BS sav, PokeTradeDe } await RequestUnionRoomTrade(token).ConfigureAwait(false); - poke.TradeSearching(this); + await poke.TradeSearching(this).ConfigureAwait(false); var waitPartner = Hub.Config.Trade.TradeWaitTime; // Keep pressing A until we detect someone talking to us. @@ -279,10 +278,10 @@ private async Task PerformLinkCodeTrade(SAV8BS sav, PokeTradeDe var tradePartner = await GetTradePartnerInfo(token).ConfigureAwait(false); var trainerNID = GetFakeNID(tradePartner.TrainerName, tradePartner.TrainerID); - RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.ID}\t{toSend.EncryptionConstant:X8}"); + RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.Id}\t{toSend.EncryptionConstant:X8}"); Log($"Found Link Trade partner: {tradePartner.TrainerName}-{tradePartner.TID7} (ID: {trainerNID}"); - var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token); + var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token).ConfigureAwait(false); if (partnerCheck != PokeTradeResult.Success) { // Try to get out of the box. @@ -308,7 +307,7 @@ private async Task PerformLinkCodeTrade(SAV8BS sav, PokeTradeDe await Click(A, 0_500, token).ConfigureAwait(false); } - poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon..."); + await poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon...").ConfigureAwait(false); // Requires at least one trade for this pointer to make sense, so cache it here. LinkTradePokemonOffset = await SwitchConnection.PointerAll(Offsets.LinkTradePartnerPokemonPointer, token).ConfigureAwait(false); @@ -356,7 +355,7 @@ private async Task PerformLinkCodeTrade(SAV8BS sav, PokeTradeDe // As long as we got rid of our inject in b1s1, assume the trade went through. Log("User completed the trade."); - poke.TradeFinished(this, received); + await poke.TradeFinished(this, received).ConfigureAwait(false); // Only log if we completed the trade. UpdateCountsAndExport(poke, received, toSend); @@ -675,7 +674,7 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d // Extra information for shiny eggs, because of people dumping to skip hatching. var eggstring = pk.IsEgg ? "Egg " : string.Empty; msg += pk.IsShiny ? $"\n**This Pokémon {eggstring}is shiny!**" : string.Empty; - detail.SendNotification(this, pk, msg); + await detail.SendNotification(this, pk, msg).ConfigureAwait(false); } Log($"Ended Dump loop after processing {ctr} Pokémon."); @@ -683,8 +682,8 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d return PokeTradeResult.TrainerTooSlow; TradeSettings.AddCompletedDumps(); - detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon."); - detail.Notifier.TradeFinished(this, detail, detail.TradeData); // blank pk8 + await detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon.").ConfigureAwait(false); + await detail.Notifier.TradeFinished(this, detail, detail.TradeData).ConfigureAwait(false); // blank pk8 return PokeTradeResult.Success; } @@ -722,7 +721,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) toSend = trade.Receive; poke.TradeData = toSend; - poke.SendNotification(this, "Injecting the requested Pokémon."); + await poke.SendNotification(this, "Injecting the requested Pokémon.").ConfigureAwait(false); await Click(A, 0_800, token).ConfigureAwait(false); await SetBoxPokemonAbsolute(BoxStartOffset, toSend, token, sav).ConfigureAwait(false); await Task.Delay(2_500, token).ConfigureAwait(false); @@ -730,7 +729,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) else if (config.LedyQuitIfNoMatch) { var nickname = offered.IsNicknamed ? $" (Nickname: \"{offered.Nickname}\")" : string.Empty; - poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}."); + await poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}.").ConfigureAwait(false); return (toSend, PokeTradeResult.TrainerRequestBad); } diff --git a/SysBot.Pokemon/BDSP/BotTrade/TradePartnerBS.cs b/SysBot.Pokemon/BDSP/BotTrade/TradePartnerBS.cs index 44bb57d12..671d104a7 100644 --- a/SysBot.Pokemon/BDSP/BotTrade/TradePartnerBS.cs +++ b/SysBot.Pokemon/BDSP/BotTrade/TradePartnerBS.cs @@ -1,6 +1,7 @@ -using PKHeX.Core; using System; using System.Diagnostics; +using PKHeX.Core; +using static System.Buffers.Binary.BinaryPrimitives; namespace SysBot.Pokemon; @@ -14,7 +15,7 @@ public sealed class TradePartnerBS public TradePartnerBS(byte[] TIDSID, byte[] trainerNameObject) { Debug.Assert(TIDSID.Length == 4); - var tidsid = BitConverter.ToUInt32(TIDSID, 0); + var tidsid = ReadUInt32LittleEndian(TIDSID); TID7 = $"{tidsid % 1_000_000:000000}"; SID7 = $"{tidsid / 1_000_000:0000}"; TrainerID = tidsid; @@ -24,7 +25,7 @@ public TradePartnerBS(byte[] TIDSID, byte[] trainerNameObject) public const int MaxByteLengthStringObject = 0x14 + 0x1A; - public static string ReadStringFromRAMObject(byte[] obj) + public static string ReadStringFromRAMObject(ReadOnlySpan obj) { // 0x10 typeinfo/monitor, 0x4 len, char[len] const int ofs_len = 0x10; @@ -33,10 +34,10 @@ public static string ReadStringFromRAMObject(byte[] obj) // Detect string length, but be cautious about its correctness (protect against bad data) int maxCharCount = (obj.Length - ofs_chars) / 2; - int length = BitConverter.ToInt32(obj, ofs_len); + int length = ReadInt32LittleEndian(obj[ofs_len..]); if (length < 0 || length > maxCharCount) length = maxCharCount; - return StringConverter8.GetString(obj.AsSpan(ofs_chars, length * 2)); + return StringConverter8.GetString(obj.Slice(ofs_chars, length * 2)); } } diff --git a/SysBot.Pokemon/BDSP/PokeRoutineExecutor8BS.cs b/SysBot.Pokemon/BDSP/PokeRoutineExecutor8BS.cs index 698e11596..23ffc95e1 100644 --- a/SysBot.Pokemon/BDSP/PokeRoutineExecutor8BS.cs +++ b/SysBot.Pokemon/BDSP/PokeRoutineExecutor8BS.cs @@ -1,10 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.BasePokeDataOffsetsBS; @@ -30,10 +31,10 @@ public override async Task ReadPokemonPointer(IEnumerable jumps, int return await ReadPokemon(offset, token).ConfigureAwait(false); } - public async Task ReadIsChanged(uint offset, byte[] original, CancellationToken token) + public async Task ReadIsChanged(uint offset, ReadOnlyMemory original, CancellationToken token) { var result = await Connection.ReadBytesAsync(offset, original.Length, token).ConfigureAwait(false); - return !result.SequenceEqual(original); + return !result.AsSpan().SequenceEqual(original.Span); } public override Task ReadBoxPokemon(int box, int slot, CancellationToken token) @@ -55,7 +56,7 @@ public Task SetBoxPokemonAbsolute(ulong offset, PB8 pkm, CancellationToken token pkm.RefreshChecksum(); Span data = stackalloc byte[pkm.SIZE_PARTY]; pkm.WriteEncryptedDataParty(data); - return SwitchConnection.WriteBytesAbsoluteAsync(data, offset, token); + return SwitchConnection.WriteBytesAbsoluteAsync(data.ToArray(), offset, token); } public async Task IdentifyTrainer(CancellationToken token) @@ -159,14 +160,15 @@ public Task UnSoftBan(CancellationToken token) { Log("Soft ban detected, unbanning."); // Write the float value to 0. - var data = BitConverter.GetBytes(0); + var data = new byte[4]; + WriteSingleLittleEndian(data, 0); return SwitchConnection.PointerPoke(data, Offsets.UnionWorkPenaltyPointer, token); } public async Task CheckIfSoftBanned(ulong offset, CancellationToken token) { var data = await SwitchConnection.ReadBytesAbsoluteAsync(offset, 4, token).ConfigureAwait(false); - return BitConverter.ToUInt32(data, 0) != 0; + return ReadSingleLittleEndian(data) != 0; } public async Task CloseGame(PokeTradeHubConfig config, CancellationToken token) @@ -241,10 +243,10 @@ public async Task IsUnionWork(ulong offset, CancellationToken token) public async Task IsPartnerParamLoaded(CancellationToken token) { var byt = await SwitchConnection.PointerPeek(8, Offsets.LinkTradePartnerParamPointer, token).ConfigureAwait(false); - return BitConverter.ToUInt64(byt, 0) != 0; + return ReadUInt64LittleEndian(byt) != 0; } - public async Task GetTradePartnerNID(CancellationToken token) => BitConverter.ToUInt64(await SwitchConnection.PointerPeek(sizeof(ulong), Offsets.LinkTradePartnerNIDPointer, token).ConfigureAwait(false), 0); + public async Task GetTradePartnerNID(CancellationToken token) => ReadUInt64LittleEndian(await SwitchConnection.PointerPeek(sizeof(ulong), Offsets.LinkTradePartnerNIDPointer, token).ConfigureAwait(false)); public async Task GetTextSpeed(CancellationToken token) { diff --git a/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs b/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs index 6f1caec09..3938ac704 100644 --- a/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs +++ b/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs @@ -1,9 +1,9 @@ -using PKHeX.Core; -using PKHeX.Core.AutoMod; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using PKHeX.Core; +using PKHeX.Core.AutoMod; namespace SysBot.Pokemon; @@ -55,7 +55,7 @@ private static void InitializeSettings(LegalitySettings cfg) // We need all the encounter types present, so add the missing ones at the end. var missing = EncounterPriority.Except(cfg.PrioritizeEncounters); cfg.PrioritizeEncounters.AddRange(missing); - cfg.PrioritizeEncounters = cfg.PrioritizeEncounters.Distinct().ToList(); // Don't allow duplicates. + cfg.PrioritizeEncounters = [.. cfg.PrioritizeEncounters.Distinct()]; // Don't allow duplicates. EncounterMovesetGenerator.PriorityList = cfg.PrioritizeEncounters; } diff --git a/SysBot.Pokemon/Helpers/RemoteControlAccess.cs b/SysBot.Pokemon/Helpers/RemoteControlAccess.cs index 780300133..75b0383df 100644 --- a/SysBot.Pokemon/Helpers/RemoteControlAccess.cs +++ b/SysBot.Pokemon/Helpers/RemoteControlAccess.cs @@ -19,7 +19,7 @@ public class RemoteControlAccess public class RemoteControlAccessList { /// - /// Don't mutate this list; use and . + /// Don't mutate this list; use and . /// This is public for serialization purposes. /// public List List { get; set; } = []; @@ -45,6 +45,19 @@ public class RemoteControlAccessList public int RemoveAll(Predicate item) => List.RemoveAll(item); public void Clear() => List.Clear(); + /// + /// Adds new items if not already present by . + /// + /// List of items to add + public void AddIfNew(params ReadOnlySpan list) + { + foreach (var item in list) + { + if (!Contains(item.ID)) + List.Add(item); + } + } + /// /// Adds new items if not already present by . /// diff --git a/SysBot.Pokemon/LA/BotFactory8LA.cs b/SysBot.Pokemon/LA/BotFactory8LA.cs index f816cf743..3b8c16eb8 100644 --- a/SysBot.Pokemon/LA/BotFactory8LA.cs +++ b/SysBot.Pokemon/LA/BotFactory8LA.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/LA/BotRemoteControl/RemoteControlBotLA.cs b/SysBot.Pokemon/LA/BotRemoteControl/RemoteControlBotLA.cs index bf220827d..1b42087d2 100644 --- a/SysBot.Pokemon/LA/BotRemoteControl/RemoteControlBotLA.cs +++ b/SysBot.Pokemon/LA/BotRemoteControl/RemoteControlBotLA.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs b/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs index 4d5462267..00809cff9 100644 --- a/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs +++ b/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs @@ -1,11 +1,11 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; -using SysBot.Base; using System; -using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsLA; @@ -23,7 +23,7 @@ public class PokeTradeBotLA(PokeTradeHub Hub, PokeBotState Config) : PokeRo /// Folder to dump received trade data to. /// /// If null, will skip dumping. - private readonly IDumper DumpSetting = Hub.Config.Folder; + private readonly FolderSettings DumpSetting = Hub.Config.Folder; /// /// Synchronized start for multiple bots. @@ -177,7 +177,7 @@ private async Task PerformTrade(SAV8LA sav, PokeTradeDetail detail, PokeRou { Log(socket.Message); result = PokeTradeResult.ExceptionConnection; - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); throw; // let this interrupt the trade loop. re-entering the trade loop will recheck the connection. } catch (Exception e) @@ -186,22 +186,22 @@ private async Task PerformTrade(SAV8LA sav, PokeTradeDetail detail, PokeRou result = PokeTradeResult.ExceptionInternal; } - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); } - private void HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) + private async Task HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) { detail.IsProcessing = false; if (result.ShouldAttemptRetry() && detail.Type != PokeTradeType.Random && !detail.IsRetry) { detail.IsRetry = true; Hub.Queues.Enqueue(type, detail, Math.Min(priority, PokeTradePriorities.Tier2)); - detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt."); + await detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt.").ConfigureAwait(false); } else { - detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}."); - detail.TradeCanceled(this, result); + await detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}.").ConfigureAwait(false); + await detail.TradeCanceled(this, result).ConfigureAwait(false); } } @@ -209,7 +209,7 @@ private async Task PerformLinkCodeTrade(SAV8LA sav, PokeTradeDe { // Update Barrier Settings UpdateBarrier(poke.IsSynchronized); - poke.TradeInitialize(this); + await poke.TradeInitialize(this).ConfigureAwait(false); Hub.Config.Stream.EndEnterCode(this); if (await CheckIfSoftBanned(SoftBanOffset, token).ConfigureAwait(false)) @@ -249,7 +249,7 @@ private async Task PerformLinkCodeTrade(SAV8LA sav, PokeTradeDe WaitAtBarrierIfApplicable(token); await Click(PLUS, 1_000, token).ConfigureAwait(false); - poke.TradeSearching(this); + await poke.TradeSearching(this).ConfigureAwait(false); // Wait for a Trainer... var partnerFound = await WaitForTradePartner(token).ConfigureAwait(false); @@ -272,17 +272,17 @@ private async Task PerformLinkCodeTrade(SAV8LA sav, PokeTradeDe var tradePartner = await GetTradePartnerInfo(token).ConfigureAwait(false); var trainerNID = await GetTradePartnerNID(TradePartnerNIDOffset, token).ConfigureAwait(false); - RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.ID}\t{toSend.EncryptionConstant:X8}"); + RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.Id}\t{toSend.EncryptionConstant:X8}"); Log($"Found Link Trade partner: {tradePartner.TrainerName}-{tradePartner.TID7} (ID: {trainerNID})"); - var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token); + var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token).ConfigureAwait(false); if (partnerCheck != PokeTradeResult.Success) { await ExitTrade(false, token).ConfigureAwait(false); return partnerCheck; } - poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon..."); + await poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon...").ConfigureAwait(false); if (poke.Type == PokeTradeType.Dump) { @@ -292,7 +292,7 @@ private async Task PerformLinkCodeTrade(SAV8LA sav, PokeTradeDe } // Watch their status to indicate they have offered a Pokémon as well. - var offering = await ReadUntilChanged(TradePartnerStatusOffset, [0x3], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var offering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {3}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!offering) { await ExitTrade(false, token).ConfigureAwait(false); @@ -354,7 +354,7 @@ private async Task PerformLinkCodeTrade(SAV8LA sav, PokeTradeDe // As long as we got rid of our inject in b1s1, assume the trade went through. Log("User completed the trade."); - poke.TradeFinished(this, received); + await poke.TradeFinished(this, received).ConfigureAwait(false); // Only log if we completed the trade. UpdateCountsAndExport(poke, received, toSend); @@ -427,7 +427,7 @@ protected virtual async Task WaitForTradePartner(CancellationToken token) if (!valid) continue; var data = await SwitchConnection.ReadBytesAbsoluteAsync(offset, 4, token).ConfigureAwait(false); - if (BitConverter.ToInt32(data, 0) != 2) + if (ReadInt32LittleEndian(data) != 2) continue; TradePartnerStatusOffset = offset; return true; @@ -534,7 +534,7 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d msg += $"\n**Trainer Data**\n```OT: {ot}\nOTGender: {ot_gender}\nTID: {tid}\nSID: {sid}```"; msg += pk.IsShiny ? "\n**This Pokémon is shiny!**" : string.Empty; - detail.SendNotification(this, pk, msg); + await detail.SendNotification(this, pk, msg).ConfigureAwait(false); } Log($"Ended Dump loop after processing {ctr} Pokémon."); @@ -542,8 +542,8 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d return PokeTradeResult.TrainerTooSlow; TradeSettings.AddCompletedDumps(); - detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon."); - detail.Notifier.TradeFinished(this, detail, detail.TradeData); // blank PA8 + await detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon.").ConfigureAwait(false); + await detail.Notifier.TradeFinished(this, detail, detail.TradeData).ConfigureAwait(false); // blank PA8 return PokeTradeResult.Success; } @@ -567,7 +567,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) private async Task<(PA8 toSend, PokeTradeResult check)> HandleClone(SAV8LA sav, PokeTradeDetail poke, PA8 offered, CancellationToken token) { if (Hub.Config.Discord.ReturnPKMs) - poke.SendNotification(this, offered, "Here's what you showed me!"); + await poke.SendNotification(this, offered, "Here's what you showed me!").ConfigureAwait(false); var la = new LegalityAnalysis(offered); if (!la.Valid) @@ -578,8 +578,8 @@ private async Task GetTradePartnerInfo(CancellationToken token) var report = la.Report(); Log(report); - poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade."); - poke.SendNotification(this, report); + await poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade.").ConfigureAwait(false); + await poke.SendNotification(this, report).ConfigureAwait(false); return (offered, PokeTradeResult.IllegalTrade); } @@ -589,13 +589,13 @@ private async Task GetTradePartnerInfo(CancellationToken token) clone.Tracker = 0; var cloneSpecies = GetSpeciesName(clone.Species); - poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want."); + await poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want.").ConfigureAwait(false); Log($"Cloned a {cloneSpecies}. Waiting for user to change their Pokémon..."); if (!await CheckCloneChangedOffer(token).ConfigureAwait(false)) { // They get one more chance. - poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**"); + await poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**").ConfigureAwait(false); if (!await CheckCloneChangedOffer(token).ConfigureAwait(false)) { Log("Trade partner did not change their Pokémon."); @@ -619,14 +619,14 @@ private async Task GetTradePartnerInfo(CancellationToken token) private async Task CheckCloneChangedOffer(CancellationToken token) { // Watch their status to indicate they canceled, then offered a new Pokémon. - var hovering = await ReadUntilChanged(TradePartnerStatusOffset, [0x2], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var hovering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {2}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!hovering) { Log("Trade partner did not change their initial offer."); await ExitTrade(false, token).ConfigureAwait(false); return false; } - var offering = await ReadUntilChanged(TradePartnerStatusOffset, [0x3], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var offering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {3}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!offering) { await ExitTrade(false, token).ConfigureAwait(false); @@ -657,13 +657,13 @@ private async Task CheckCloneChangedOffer(CancellationToken token) toSend = trade.Receive; poke.TradeData = toSend; - poke.SendNotification(this, "Injecting the requested Pokémon."); + await poke.SendNotification(this, "Injecting the requested Pokémon.").ConfigureAwait(false); await SetBoxPokemonAbsolute(BoxStartOffset, toSend, token, sav).ConfigureAwait(false); } else if (config.LedyQuitIfNoMatch) { var nickname = offered.IsNicknamed ? $" (Nickname: \"{offered.Nickname}\")" : string.Empty; - poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}."); + await poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}.").ConfigureAwait(false); return (toSend, PokeTradeResult.TrainerRequestBad); } diff --git a/SysBot.Pokemon/LA/BotTrade/TradePartnerLA.cs b/SysBot.Pokemon/LA/BotTrade/TradePartnerLA.cs index 948664d87..fec8e99d6 100644 --- a/SysBot.Pokemon/LA/BotTrade/TradePartnerLA.cs +++ b/SysBot.Pokemon/LA/BotTrade/TradePartnerLA.cs @@ -1,6 +1,7 @@ -using PKHeX.Core; using System; using System.Diagnostics; +using PKHeX.Core; +using static System.Buffers.Binary.BinaryPrimitives; namespace SysBot.Pokemon; @@ -10,10 +11,10 @@ public sealed class TradePartnerLA public string SID7 { get; } public string TrainerName { get; } - public TradePartnerLA(byte[] TIDSID, byte[] trainerNameObject) + public TradePartnerLA(ReadOnlySpan TIDSID, byte[] trainerNameObject) { Debug.Assert(TIDSID.Length == 4); - var tidsid = BitConverter.ToUInt32(TIDSID, 0); + var tidsid = ReadUInt32LittleEndian(TIDSID); TID7 = $"{tidsid % 1_000_000:000000}"; SID7 = $"{tidsid / 1_000_000:0000}"; diff --git a/SysBot.Pokemon/LA/PokeRoutineExecutor8LA.cs b/SysBot.Pokemon/LA/PokeRoutineExecutor8LA.cs index 1a3a6a9e1..45e3546cc 100644 --- a/SysBot.Pokemon/LA/PokeRoutineExecutor8LA.cs +++ b/SysBot.Pokemon/LA/PokeRoutineExecutor8LA.cs @@ -1,10 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsLA; @@ -30,10 +31,10 @@ public override async Task ReadPokemonPointer(IEnumerable jumps, int return await ReadPokemon(offset, token).ConfigureAwait(false); } - public async Task ReadIsChanged(uint offset, byte[] original, CancellationToken token) + public async Task ReadIsChanged(uint offset, ReadOnlyMemory original, CancellationToken token) { var result = await Connection.ReadBytesAsync(offset, original.Length, token).ConfigureAwait(false); - return !result.SequenceEqual(original); + return !result.AsSpan().SequenceEqual(original.Span); } public override Task ReadBoxPokemon(int box, int slot, CancellationToken token) @@ -55,12 +56,12 @@ public Task SetBoxPokemonAbsolute(ulong offset, PA8 pkm, CancellationToken token pkm.RefreshChecksum(); Span data = stackalloc byte[pkm.SIZE_STORED]; pkm.WriteEncryptedDataStored(data); - return SwitchConnection.WriteBytesAbsoluteAsync(data, offset, token); + return SwitchConnection.WriteBytesAbsoluteAsync(data.ToArray(), offset, token); } public Task SetCurrentBox(byte box, CancellationToken token) { - return SwitchConnection.PointerPoke([box], Offsets.CurrentBoxPointer, token); + return SwitchConnection.PointerPoke(new[] {box}, Offsets.CurrentBoxPointer, token); } public async Task GetCurrentBox(CancellationToken token) @@ -149,14 +150,15 @@ public Task UnSoftBan(CancellationToken token) { Log("Soft ban detected, unbanning."); // Write the value to 0. - var data = BitConverter.GetBytes(0); + var data = new byte[4]; + WriteUInt32LittleEndian(data, 0); return SwitchConnection.PointerPoke(data, Offsets.SoftbanPointer, token); } public async Task CheckIfSoftBanned(ulong offset, CancellationToken token) { var data = await SwitchConnection.ReadBytesAbsoluteAsync(offset, 4, token).ConfigureAwait(false); - return BitConverter.ToUInt32(data, 0) != 0; + return ReadUInt32LittleEndian(data) != 0; } public async Task CloseGame(PokeTradeHubConfig config, CancellationToken token) @@ -218,7 +220,7 @@ public async Task StartGame(PokeTradeHubConfig config, CancellationToken token) public async Task GetTradePartnerNID(ulong offset, CancellationToken token) { var data = await SwitchConnection.ReadBytesAbsoluteAsync(offset, 8, token).ConfigureAwait(false); - return BitConverter.ToUInt64(data, 0); + return ReadUInt64LittleEndian(data); } public async Task IsOnOverworld(ulong offset, CancellationToken token) diff --git a/SysBot.Pokemon/LZA/BotFactory9LZA.cs b/SysBot.Pokemon/LZA/BotFactory9LZA.cs index 47b546900..e6efda154 100644 --- a/SysBot.Pokemon/LZA/BotFactory9LZA.cs +++ b/SysBot.Pokemon/LZA/BotFactory9LZA.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/LZA/BotRemoteControl/RemoteControlBotLZA.cs b/SysBot.Pokemon/LZA/BotRemoteControl/RemoteControlBotLZA.cs index 3fc64328a..008d5d407 100644 --- a/SysBot.Pokemon/LZA/BotRemoteControl/RemoteControlBotLZA.cs +++ b/SysBot.Pokemon/LZA/BotRemoteControl/RemoteControlBotLZA.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs b/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs index efa396a8b..011f45106 100644 --- a/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs +++ b/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs @@ -1,11 +1,11 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; -using SysBot.Base; using System; -using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsLZA; @@ -23,7 +23,7 @@ public class PokeTradeBotLZA(PokeTradeHub Hub, PokeBotState Config) : PokeR /// Folder to dump received trade data to. /// /// If null, will skip dumping. - private readonly IDumper DumpSetting = Hub.Config.Folder; + private readonly FolderSettings DumpSetting = Hub.Config.Folder; /// /// Synchronized start for multiple bots. @@ -194,7 +194,7 @@ private async Task PerformTrade(SAV9ZA sav, PokeTradeDetail detail, PokeRou { Log(socket.Message); result = PokeTradeResult.ExceptionConnection; - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); throw; // let this interrupt the trade loop. re-entering the trade loop will recheck the connection. } catch (Exception e) @@ -203,22 +203,22 @@ private async Task PerformTrade(SAV9ZA sav, PokeTradeDetail detail, PokeRou result = PokeTradeResult.ExceptionInternal; } - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); } - private void HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) + private async Task HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) { detail.IsProcessing = false; if (result.ShouldAttemptRetry() && detail.Type != PokeTradeType.Random && !detail.IsRetry) { detail.IsRetry = true; Hub.Queues.Enqueue(type, detail, Math.Min(priority, PokeTradePriorities.Tier2)); - detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt."); + await detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt.").ConfigureAwait(false); } else { - detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}."); - detail.TradeCanceled(this, result); + await detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}.").ConfigureAwait(false); + await detail.TradeCanceled(this, result).ConfigureAwait(false); } } @@ -226,7 +226,7 @@ private async Task PerformLinkCodeTrade(SAV9ZA sav, PokeTradeDe { // Update Barrier Settings UpdateBarrier(poke.IsSynchronized); - poke.TradeInitialize(this); + await poke.TradeInitialize(this).ConfigureAwait(false); Hub.Config.Stream.EndEnterCode(this); // If we're expected to be on the overworld and we aren't, recover there. @@ -292,7 +292,7 @@ private async Task PerformLinkCodeTrade(SAV9ZA sav, PokeTradeDe WaitAtBarrierIfApplicable(token); await Click(PLUS, 1_000, token).ConfigureAwait(false); - poke.TradeSearching(this); + await poke.TradeSearching(this).ConfigureAwait(false); // Wait for a Trainer... var partnerFound = await WaitForTradePartner(token).ConfigureAwait(false); @@ -321,17 +321,17 @@ private async Task PerformLinkCodeTrade(SAV9ZA sav, PokeTradeDe await Task.Delay(1_000 + Hub.Config.Timings.ExtraTimeOpenBox, token).ConfigureAwait(false); var tradePartner = await GetTradePartnerInfo(token).ConfigureAwait(false); - RecordUtil.Record($"Initiating\t{tradePartner.NID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.ID}\t{toSend.EncryptionConstant:X8}"); + RecordUtil.Record($"Initiating\t{tradePartner.NID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.Id}\t{toSend.EncryptionConstant:X8}"); Log($"Found Link Trade partner: {tradePartner.TrainerName}-{tradePartner.TID7} (ID: {tradePartner.NID})"); - var partnerCheck = await CheckPartnerReputation(this, poke, tradePartner.NID, tradePartner.TrainerName, AbuseSettings, token); + var partnerCheck = await CheckPartnerReputation(this, poke, tradePartner.NID, tradePartner.TrainerName, AbuseSettings, token).ConfigureAwait(false); if (partnerCheck != PokeTradeResult.Success) { await ResetToLinkPlay(token).ConfigureAwait(false); return partnerCheck; } - poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon..."); + await poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon...").ConfigureAwait(false); if (poke.Type == PokeTradeType.Dump) { @@ -341,7 +341,7 @@ private async Task PerformLinkCodeTrade(SAV9ZA sav, PokeTradeDe } // Watch their status to indicate they have offered a Pokémon as well. - var offering = await ReadUntilChanged(TradePartnerStatusOffset, [0x3], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var offering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {3}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!offering) { await ResetToLinkPlay(token).ConfigureAwait(false); @@ -405,7 +405,7 @@ private async Task PerformLinkCodeTrade(SAV9ZA sav, PokeTradeDe // As long as we got rid of our inject in b1s1, assume the trade went through. Log("User completed the trade."); - poke.TradeFinished(this, received); + await poke.TradeFinished(this, received).ConfigureAwait(false); // Only log if we completed the trade. UpdateCountsAndExport(poke, received, toSend); @@ -689,7 +689,7 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d msg += $"\n**Trainer Data**\n```OT: {ot}\nOTGender: {ot_gender}\nTID: {tid}\nSID: {sid}```"; msg += pk.IsShiny ? "\n**This Pokémon is shiny!**" : string.Empty; - detail.SendNotification(this, pk, msg); + await detail.SendNotification(this, pk, msg).ConfigureAwait(false); } Log($"Ended Dump loop after processing {dumped} Pokémon."); @@ -697,8 +697,8 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d return PokeTradeResult.TrainerTooSlow; TradeSettings.AddCompletedDumps(); - detail.Notifier.SendNotification(this, detail, $"Dumped {dumped} Pokémon."); - detail.Notifier.TradeFinished(this, detail, detail.TradeData); // blank PA9 + await detail.Notifier.SendNotification(this, detail, $"Dumped {dumped} Pokémon.").ConfigureAwait(false); + await detail.Notifier.TradeFinished(this, detail, detail.TradeData).ConfigureAwait(false); // blank PA9 return PokeTradeResult.Success; } @@ -710,7 +710,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) // NID should be the first 8 bytes, converted to a ulong. var id = chunk.AsSpan(0, 8).ToArray(); - var nid = BitConverter.ToUInt64(id); + var nid = ReadUInt64LittleEndian(id); if (nid == 0) // They probably left too quickly, so try the backup pointer. nid = await GetTradePartnerNID(token).ConfigureAwait(false); @@ -744,7 +744,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) private async Task<(PA9 toSend, PokeTradeResult check)> HandleClone(SAV9ZA sav, PokeTradeDetail poke, PA9 offered, CancellationToken token) { if (Hub.Config.Discord.ReturnPKMs) - poke.SendNotification(this, offered, "Here's what you showed me!"); + await poke.SendNotification(this, offered, "Here's what you showed me!").ConfigureAwait(false); var la = new LegalityAnalysis(offered); if (!la.Valid) @@ -755,8 +755,8 @@ private async Task GetTradePartnerInfo(CancellationToken token) var report = la.Report(); Log(report); - poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade."); - poke.SendNotification(this, report); + await poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade.").ConfigureAwait(false); + await poke.SendNotification(this, report).ConfigureAwait(false); return (offered, PokeTradeResult.IllegalTrade); } @@ -766,13 +766,13 @@ private async Task GetTradePartnerInfo(CancellationToken token) clone.Tracker = 0; var cloneSpecies = GetSpeciesName(clone.Species); - poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want."); + await poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want.").ConfigureAwait(false); Log($"Cloned a {cloneSpecies}. Waiting for user to change their Pokémon..."); if (!await CheckCloneChangedOffer(token).ConfigureAwait(false)) { // They get one more chance. - poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**"); + await poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**").ConfigureAwait(false); if (!await CheckCloneChangedOffer(token).ConfigureAwait(false)) { Log("Trade partner did not change their Pokémon."); @@ -796,14 +796,14 @@ private async Task GetTradePartnerInfo(CancellationToken token) private async Task CheckCloneChangedOffer(CancellationToken token) { // Watch their status to indicate they canceled, then offered a new Pokémon. - var hovering = await ReadUntilChanged(TradePartnerStatusOffset, [0x2], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var hovering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {2}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!hovering) { Log("Trade partner did not change their initial offer."); await ResetToLinkPlay(token).ConfigureAwait(false); return false; } - var offering = await ReadUntilChanged(TradePartnerStatusOffset, [0x3], 25_000, 1_000, true, true, token).ConfigureAwait(false); + var offering = await ReadUntilChanged(TradePartnerStatusOffset, new byte[] {3}, 25_000, 1_000, true, true, token).ConfigureAwait(false); if (!offering) { await ResetToLinkPlay(token).ConfigureAwait(false); @@ -834,13 +834,13 @@ private async Task CheckCloneChangedOffer(CancellationToken token) toSend = trade.Receive; poke.TradeData = toSend; - poke.SendNotification(this, "Injecting the requested Pokémon."); + await poke.SendNotification(this, "Injecting the requested Pokémon.").ConfigureAwait(false); await SetBoxPokemonAbsolute(BoxStartOffset, toSend, token, sav).ConfigureAwait(false); } else if (config.LedyQuitIfNoMatch) { var nickname = offered.IsNicknamed ? $" (Nickname: \"{offered.Nickname}\")" : string.Empty; - poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}."); + await poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}.").ConfigureAwait(false); return (toSend, PokeTradeResult.TrainerRequestBad); } diff --git a/SysBot.Pokemon/LZA/BotTrade/TradePartnerLZA.cs b/SysBot.Pokemon/LZA/BotTrade/TradePartnerLZA.cs index 4555f2ce5..bf52a1185 100644 --- a/SysBot.Pokemon/LZA/BotTrade/TradePartnerLZA.cs +++ b/SysBot.Pokemon/LZA/BotTrade/TradePartnerLZA.cs @@ -1,6 +1,7 @@ -using PKHeX.Core; using System; using System.Diagnostics; +using PKHeX.Core; +using static System.Buffers.Binary.BinaryPrimitives; namespace SysBot.Pokemon; @@ -11,12 +12,12 @@ public sealed class TradePartnerLZA public string SID7 { get; } public string TrainerName { get; } - public TradePartnerLZA(ulong ID, byte[] TIDSID, byte[] trainerNameObject) + public TradePartnerLZA(ulong ID, ReadOnlySpan TIDSID, ReadOnlySpan trainerNameObject) { NID = ID; Debug.Assert(TIDSID.Length == 4); - var tidsid = BitConverter.ToUInt32(TIDSID, 0); + var tidsid = ReadUInt32LittleEndian(TIDSID); TID7 = $"{tidsid % 1_000_000:000000}"; SID7 = $"{tidsid / 1_000_000:0000}"; diff --git a/SysBot.Pokemon/LZA/PokeRoutineExecutor9LZA.cs b/SysBot.Pokemon/LZA/PokeRoutineExecutor9LZA.cs index 60a0d6cfd..3e6ec9601 100644 --- a/SysBot.Pokemon/LZA/PokeRoutineExecutor9LZA.cs +++ b/SysBot.Pokemon/LZA/PokeRoutineExecutor9LZA.cs @@ -1,10 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsLZA; @@ -30,10 +31,10 @@ public override async Task ReadPokemonPointer(IEnumerable jumps, int return await ReadPokemon(offset, token).ConfigureAwait(false); } - public async Task ReadIsChanged(uint offset, byte[] original, CancellationToken token) + public async Task ReadIsChanged(uint offset, ReadOnlyMemory original, CancellationToken token) { var result = await Connection.ReadBytesAsync(offset, original.Length, token).ConfigureAwait(false); - return !result.SequenceEqual(original); + return !result.AsSpan().SequenceEqual(original.Span); } public override Task ReadBoxPokemon(int box, int slot, CancellationToken token) @@ -55,12 +56,12 @@ public Task SetBoxPokemonAbsolute(ulong offset, PA9 pkm, CancellationToken token pkm.RefreshChecksum(); Span data = stackalloc byte[pkm.SIZE_PARTY]; pkm.WriteEncryptedDataParty(data); - return SwitchConnection.WriteBytesAbsoluteAsync(data, offset, token); + return SwitchConnection.WriteBytesAbsoluteAsync(data.ToArray(), offset, token); } public Task SetCurrentBox(byte box, CancellationToken token) { - return SwitchConnection.PointerPoke([box], Offsets.CurrentBoxPointer, token); + return SwitchConnection.PointerPoke(new[] {box}, Offsets.CurrentBoxPointer, token); } public async Task GetCurrentBox(CancellationToken token) @@ -204,7 +205,7 @@ public async Task StartGame(PokeTradeHubConfig config, CancellationToken token) public async Task GetTradePartnerNID(CancellationToken token) { var data = await SwitchConnection.PointerPeek(8, Offsets.TradePartnerBackupNIDPointer, token).ConfigureAwait(false); - return BitConverter.ToUInt64(data, 0); + return ReadUInt64LittleEndian(data); } public async Task IsOnOverworld(CancellationToken token) diff --git a/SysBot.Pokemon/Queues/QueueCheckResult.cs b/SysBot.Pokemon/Queues/QueueCheckResult.cs index 46e3545cd..229423d19 100644 --- a/SysBot.Pokemon/Queues/QueueCheckResult.cs +++ b/SysBot.Pokemon/Queues/QueueCheckResult.cs @@ -20,7 +20,7 @@ public string GetMessage() if (!InQueue || Detail is null) return "You are not in the queue."; var position = $"{Position}/{QueueCount}"; - var msg = $"You are in the {Detail.Type} queue! Position: {position} (ID {Detail.Trade.ID})"; + var msg = $"You are in the {Detail.Type} queue! Position: {position} (ID {Detail.Trade.Id})"; var pk = Detail.Trade.TradeData; if (pk.Species != 0) msg += $", Receiving: {GameInfo.GetStrings("en").Species[pk.Species]}"; diff --git a/SysBot.Pokemon/Queues/TradeQueueInfo.cs b/SysBot.Pokemon/Queues/TradeQueueInfo.cs index dc9286b72..279af15b5 100644 --- a/SysBot.Pokemon/Queues/TradeQueueInfo.cs +++ b/SysBot.Pokemon/Queues/TradeQueueInfo.cs @@ -1,9 +1,9 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; @@ -15,7 +15,7 @@ public sealed record TradeQueueInfo(PokeTradeHub Hub) where T : PKM, new() { private readonly Lock _sync = new(); - private readonly List> UsersInQueue = []; + private readonly List> _queue = []; public readonly PokeTradeHub Hub = Hub; public int Count @@ -23,7 +23,7 @@ public int Count get { lock (_sync) - return UsersInQueue.Count; + return _queue.Count; } } @@ -34,32 +34,32 @@ public bool GetCanQueue() if (!Hub.Config.Queues.CanQueue) return false; lock (_sync) - return UsersInQueue.Count < Hub.Config.Queues.MaxQueueCount && Hub.TradeBotsReady; + return _queue.Count < Hub.Config.Queues.MaxQueueCount && Hub.TradeBotsReady; } public TradeEntry? GetDetail(ulong uid) { lock (_sync) - return UsersInQueue.Find(z => z.UserID == uid); + return _queue.Find(z => z.UserID == uid); } public QueueCheckResult CheckPosition(ulong uid, PokeRoutineType type = 0) { lock (_sync) { - var index = UsersInQueue.FindIndex(z => z.Equals(uid, type)); + var index = _queue.FindIndex(z => z.Equals(uid, type)); if (index < 0) return QueueCheckResult.None; - var entry = UsersInQueue[index]; + var entry = _queue[index]; var actualIndex = 1; for (int i = 0; i < index; i++) { - if (UsersInQueue[i].Type == entry.Type) + if (_queue[i].Type == entry.Type) actualIndex++; } - var inQueue = UsersInQueue.Count(z => z.Type == entry.Type); + var inQueue = _queue.Count(z => z.Type == entry.Type); return new QueueCheckResult(true, entry, actualIndex, inQueue); } @@ -87,7 +87,7 @@ public void ClearAllQueues() lock (_sync) { Hub.Queues.ClearAll(); - UsersInQueue.Clear(); + _queue.Clear(); } } @@ -97,9 +97,9 @@ public QueueResultRemove ClearTrade(string userName) return ClearTrade(details); } - public QueueResultRemove ClearTrade(ulong userID) + public QueueResultRemove ClearTrade(ulong userId) { - var details = GetIsUserQueued(z => z.UserID == userID); + var details = GetIsUserQueued(z => z.UserID == userId); return ClearTrade(details); } @@ -140,7 +140,7 @@ public int ClearTrade(IEnumerable> details, PokeTradeHub hub) { int removed = queue.Remove(detail.Trade); if (removed != 0) - UsersInQueue.Remove(detail); + _queue.Remove(detail); removedCount += removed; } } @@ -149,19 +149,20 @@ public int ClearTrade(IEnumerable> details, PokeTradeHub hub) return removedCount; } - public IEnumerable GetUserList(string fmt) + public string[] GetUserList(string format) { lock (_sync) - { - return UsersInQueue.Select(z => string.Format(fmt, z.Trade.ID, z.Trade.Code, z.Trade.Type, z.Username, (Species)z.Trade.TradeData.Species)); - } + return [.. _queue.Select(z => FormatUser(format, z))]; } + private static string FormatUser(string format, TradeEntry z) + => string.Format(format, z.Trade.Id, z.Trade.Code, z.Trade.Type, z.Username, (Species)z.Trade.TradeData.Species); + public IList> GetIsUserQueued(Func, bool> match) { lock (_sync) { - return UsersInQueue.Where(match).ToArray(); + return [.. _queue.Where(match)]; } } @@ -169,16 +170,16 @@ public bool Remove(TradeEntry detail) { lock (_sync) { - LogUtil.LogInfo($"Removing {detail.Trade.Trainer.TrainerName}", nameof(TradeQueueInfo)); - return UsersInQueue.Remove(detail); + LogUtil.LogInfo($"Removing {detail.Trade.Trainer.TrainerName}", nameof(TradeQueueInfo<>)); + return _queue.Remove(detail); } } - public QueueResultAdd AddToTradeQueue(TradeEntry trade, ulong userID, bool sudo = false) + public QueueResultAdd AddToTradeQueue(TradeEntry trade, ulong userId, bool sudo = false) { lock (_sync) { - if (UsersInQueue.Any(z => z.UserID == userID) && !sudo) + if (_queue.Any(z => z.UserID == userId) && !sudo) return QueueResultAdd.AlreadyInQueue; if (Hub.Config.Legality.ResetHOMETracker && trade.Trade.TradeData is IHomeTrack t) @@ -188,7 +189,7 @@ public QueueResultAdd AddToTradeQueue(TradeEntry trade, ulong userID, bool su var queue = Hub.Queues.GetQueue(trade.Type); queue.Enqueue(trade.Trade, priority); - UsersInQueue.Add(trade); + _queue.Add(trade); trade.Trade.Notifier.OnFinish = _ => Remove(trade); return QueueResultAdd.Added; @@ -200,6 +201,6 @@ public QueueResultAdd AddToTradeQueue(TradeEntry trade, ulong userID, bool su public int UserCount(Func, bool> func) { lock (_sync) - return UsersInQueue.Count(func); + return _queue.Count(func); } } diff --git a/SysBot.Pokemon/Queues/TradeQueueManager.cs b/SysBot.Pokemon/Queues/TradeQueueManager.cs index ae9736268..8f6842e2f 100644 --- a/SysBot.Pokemon/Queues/TradeQueueManager.cs +++ b/SysBot.Pokemon/Queues/TradeQueueManager.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System; using System.Collections.Generic; +using PKHeX.Core; namespace SysBot.Pokemon; @@ -52,7 +52,14 @@ public bool TryDequeueLedy(out PokeTradeDetail detail, bool force = false) var random = Hub.Ledy.Pool.GetRandomPoke(); var code = cfg.RandomCode ? Hub.Config.Trade.GetRandomTradeCode() : cfg.TradeCode; var trainer = new PokeTradeTrainerInfo("Random Distribution"); - detail = new PokeTradeDetail(random, trainer, PokeTradeHub.LogNotifier, PokeTradeType.Random, code); + detail = new PokeTradeDetail + { + Type = PokeTradeType.Random, + Code = code, + TradeData = random, + Trainer = trainer, + Notifier = PokeTradeHub.LogNotifier, + }; return true; } diff --git a/SysBot.Pokemon/SV/BotFactory9SV.cs b/SysBot.Pokemon/SV/BotFactory9SV.cs index 2a606337b..a20d3bf2d 100644 --- a/SysBot.Pokemon/SV/BotFactory9SV.cs +++ b/SysBot.Pokemon/SV/BotFactory9SV.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SV/BotRemoteControl/RemoteControlBotSV.cs b/SysBot.Pokemon/SV/BotRemoteControl/RemoteControlBotSV.cs index 40780c61c..6f2f7f61a 100644 --- a/SysBot.Pokemon/SV/BotRemoteControl/RemoteControlBotSV.cs +++ b/SysBot.Pokemon/SV/BotRemoteControl/RemoteControlBotSV.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs b/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs index 913d65d52..5af919dcf 100644 --- a/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs +++ b/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs @@ -1,11 +1,10 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; -using SysBot.Base; using System; -using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; +using SysBot.Base; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSV; @@ -23,7 +22,7 @@ public class PokeTradeBotSV(PokeTradeHub Hub, PokeBotState Config) : PokeRo /// Folder to dump received trade data to. /// /// If null, will skip dumping. - private readonly IDumper DumpSetting = Hub.Config.Folder; + private readonly FolderSettings DumpSetting = Hub.Config.Folder; /// /// Synchronized start for multiple bots. @@ -185,7 +184,7 @@ private async Task PerformTrade(SAV9SV sav, PokeTradeDetail detail, PokeRou { Log(socket.Message); result = PokeTradeResult.ExceptionConnection; - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); throw; // let this interrupt the trade loop. re-entering the trade loop will recheck the connection. } catch (Exception e) @@ -194,22 +193,22 @@ private async Task PerformTrade(SAV9SV sav, PokeTradeDetail detail, PokeRou result = PokeTradeResult.ExceptionInternal; } - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); } - private void HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) + private async Task HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) { detail.IsProcessing = false; if (result.ShouldAttemptRetry() && detail.Type != PokeTradeType.Random && !detail.IsRetry) { detail.IsRetry = true; Hub.Queues.Enqueue(type, detail, Math.Min(priority, PokeTradePriorities.Tier2)); - detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt."); + await detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt.").ConfigureAwait(false); } else { - detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}."); - detail.TradeCanceled(this, result); + await detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}.").ConfigureAwait(false); + await detail.TradeCanceled(this, result).ConfigureAwait(false); } } @@ -217,7 +216,7 @@ private async Task PerformLinkCodeTrade(SAV9SV sav, PokeTradeDe { // Update Barrier Settings UpdateBarrier(poke.IsSynchronized); - poke.TradeInitialize(this); + await poke.TradeInitialize(this).ConfigureAwait(false); Hub.Config.Stream.EndEnterCode(this); // StartFromOverworld can be true on first pass or if something went wrong last trade. @@ -282,7 +281,7 @@ private async Task PerformLinkCodeTrade(SAV9SV sav, PokeTradeDe WaitAtBarrierIfApplicable(token); await Click(A, 1_000, token).ConfigureAwait(false); - poke.TradeSearching(this); + await poke.TradeSearching(this).ConfigureAwait(false); // Wait for a Trainer... var partnerFound = await WaitForTradePartner(token).ConfigureAwait(false); @@ -318,10 +317,10 @@ private async Task PerformLinkCodeTrade(SAV9SV sav, PokeTradeDe var tradePartner = await GetTradePartnerInfo(token).ConfigureAwait(false); var trainerNID = await GetTradePartnerNID(TradePartnerNIDOffset, token).ConfigureAwait(false); - RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.ID}\t{toSend.EncryptionConstant:X8}"); + RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{tradePartner.TrainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.Id}\t{toSend.EncryptionConstant:X8}"); Log($"Found Link Trade partner: {tradePartner.TrainerName}-{tradePartner.TID7} (ID: {trainerNID})"); - var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token); + var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, tradePartner.TrainerName, AbuseSettings, token).ConfigureAwait(false); if (partnerCheck != PokeTradeResult.Success) { await Click(A, 1_000, token).ConfigureAwait(false); // Ensures we dismiss a popup. @@ -338,7 +337,7 @@ private async Task PerformLinkCodeTrade(SAV9SV sav, PokeTradeDe return PokeTradeResult.TrainerTooSlow; } - poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon..."); + await poke.SendNotification(this, $"Found Link Trade partner: {tradePartner.TrainerName}. Waiting for a Pokémon...").ConfigureAwait(false); if (poke.Type == PokeTradeType.Dump) { @@ -400,7 +399,7 @@ private async Task PerformLinkCodeTrade(SAV9SV sav, PokeTradeDe // As long as we got rid of our inject in b1s1, assume the trade went through. Log("User completed the trade."); - poke.TradeFinished(this, received); + await poke.TradeFinished(this, received).ConfigureAwait(false); // Only log if we completed the trade. UpdateCountsAndExport(poke, received, toSend); @@ -792,7 +791,7 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d // Extra information for shiny eggs, because of people dumping to skip hatching. var eggstring = pk.IsEgg ? "Egg " : string.Empty; msg += pk.IsShiny ? $"\n**This Pokémon {eggstring}is shiny!**" : string.Empty; - detail.SendNotification(this, pk, msg); + await detail.SendNotification(this, pk, msg).ConfigureAwait(false); } Log($"Ended Dump loop after processing {ctr} Pokémon."); @@ -800,8 +799,8 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d return PokeTradeResult.TrainerTooSlow; TradeSettings.AddCompletedDumps(); - detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon."); - detail.Notifier.TradeFinished(this, detail, detail.TradeData); // blank PK9 + await detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon.").ConfigureAwait(false); + await detail.Notifier.TradeFinished(this, detail, detail.TradeData).ConfigureAwait(false); // blank PK9 return PokeTradeResult.Success; } @@ -827,7 +826,7 @@ private async Task GetTradePartnerInfo(CancellationToken token) private async Task<(PK9 toSend, PokeTradeResult check)> HandleClone(SAV9SV sav, PokeTradeDetail poke, PK9 offered, byte[] oldEC, CancellationToken token) { if (Hub.Config.Discord.ReturnPKMs) - poke.SendNotification(this, offered, "Here's what you showed me!"); + await poke.SendNotification(this, offered, "Here's what you showed me!").ConfigureAwait(false); var la = new LegalityAnalysis(offered); if (!la.Valid) @@ -838,8 +837,8 @@ private async Task GetTradePartnerInfo(CancellationToken token) var report = la.Report(); Log(report); - poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade."); - poke.SendNotification(this, report); + await poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade.").ConfigureAwait(false); + await poke.SendNotification(this, report).ConfigureAwait(false); return (offered, PokeTradeResult.IllegalTrade); } @@ -849,14 +848,14 @@ private async Task GetTradePartnerInfo(CancellationToken token) clone.Tracker = 0; var cloneSpecies = GetSpeciesName(clone.Species); - poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want."); + await poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want.").ConfigureAwait(false); Log($"Cloned a {cloneSpecies}. Waiting for user to change their Pokémon..."); // Separate this out from WaitForPokemonChanged since we compare to old EC from original read. var partnerFound = await ReadUntilChanged(TradePartnerOfferedOffset, oldEC, 15_000, 0_200, false, true, token).ConfigureAwait(false); if (!partnerFound) { - poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**"); + await poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**").ConfigureAwait(false); // They get one more chance. partnerFound = await ReadUntilChanged(TradePartnerOfferedOffset, oldEC, 15_000, 0_200, false, true, token).ConfigureAwait(false); } @@ -896,13 +895,13 @@ private async Task GetTradePartnerInfo(CancellationToken token) toSend = trade.Receive; poke.TradeData = toSend; - poke.SendNotification(this, "Injecting the requested Pokémon."); + await poke.SendNotification(this, "Injecting the requested Pokémon.").ConfigureAwait(false); await SetBoxPokemonAbsolute(BoxStartOffset, toSend, token, sav).ConfigureAwait(false); } else if (config.LedyQuitIfNoMatch) { var nickname = offered.IsNicknamed ? $" (Nickname: \"{offered.Nickname}\")" : string.Empty; - poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}."); + await poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}.").ConfigureAwait(false); return (toSend, PokeTradeResult.TrainerRequestBad); } diff --git a/SysBot.Pokemon/SV/BotTrade/TradePartnerSV.cs b/SysBot.Pokemon/SV/BotTrade/TradePartnerSV.cs index e308b50aa..c7b7687da 100644 --- a/SysBot.Pokemon/SV/BotTrade/TradePartnerSV.cs +++ b/SysBot.Pokemon/SV/BotTrade/TradePartnerSV.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System; using System.Buffers.Binary; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SV/PokeRoutineExecutor9SV.cs b/SysBot.Pokemon/SV/PokeRoutineExecutor9SV.cs index 8a6484559..4d286e3ea 100644 --- a/SysBot.Pokemon/SV/PokeRoutineExecutor9SV.cs +++ b/SysBot.Pokemon/SV/PokeRoutineExecutor9SV.cs @@ -1,10 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSV; @@ -30,10 +31,10 @@ public override async Task ReadPokemonPointer(IEnumerable jumps, int return await ReadPokemon(offset, token).ConfigureAwait(false); } - public async Task ReadIsChanged(uint offset, byte[] original, CancellationToken token) + public async Task ReadIsChanged(uint offset, ReadOnlyMemory original, CancellationToken token) { var result = await Connection.ReadBytesAsync(offset, original.Length, token).ConfigureAwait(false); - return !result.SequenceEqual(original); + return !result.AsSpan().SequenceEqual(original.Span); } public override Task ReadBoxPokemon(int box, int slot, CancellationToken token) @@ -55,12 +56,12 @@ public Task SetBoxPokemonAbsolute(ulong offset, PK9 pkm, CancellationToken token pkm.RefreshChecksum(); Span data = stackalloc byte[pkm.SIZE_STORED]; pkm.WriteEncryptedDataStored(data); - return SwitchConnection.WriteBytesAbsoluteAsync(data, offset, token); + return SwitchConnection.WriteBytesAbsoluteAsync(data.ToArray(), offset, token); } public Task SetCurrentBox(byte box, CancellationToken token) { - return SwitchConnection.PointerPoke([box], Offsets.CurrentBoxPointer, token); + return SwitchConnection.PointerPoke(new[] {box}, Offsets.CurrentBoxPointer, token); } public async Task GetCurrentBox(CancellationToken token) @@ -221,7 +222,7 @@ public async Task IsConnectedOnline(ulong offset, CancellationToken token) public async Task GetTradePartnerNID(ulong offset, CancellationToken token) { var data = await SwitchConnection.ReadBytesAbsoluteAsync(offset, 8, token).ConfigureAwait(false); - return BitConverter.ToUInt64(data, 0); + return ReadUInt64LittleEndian(data); } public Task ClearTradePartnerNID(ulong offset, CancellationToken token) diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotDogSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotDogSWSH.cs index 89a15c9a7..6d896dd7b 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotDogSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotDogSWSH.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; using static SysBot.Base.SwitchButton; using static SysBot.Base.SwitchStick; using static SysBot.Pokemon.PokeDataOffsetsSWSH; diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotEggSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotEggSWSH.cs index 171f4af70..ec295c2ca 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotEggSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotEggSWSH.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; using static SysBot.Base.SwitchButton; using static SysBot.Base.SwitchStick; using static SysBot.Pokemon.PokeDataOffsetsSWSH; diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/EncounterBotFossilSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/EncounterBotFossilSWSH.cs index 734535eb3..5fcb2981d 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/EncounterBotFossilSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/EncounterBotFossilSWSH.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSWSH; @@ -36,7 +36,7 @@ protected override async Task EncounterLoop(SAV8SWSH sav, CancellationToken toke while (!token.IsCancellationRequested) { - if (encounterCount != 0 && encounterCount % reviveCount == 0) + if (EncounterCount != 0 && EncounterCount % reviveCount == 0) { Log($"Ran out of fossils to revive {Settings.Species}."); if (Settings.InjectWhenEmpty) diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/FossilCount.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/FossilCount.cs index b35062879..b695f0b48 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/FossilCount.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotFossil/FossilCount.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; using static SysBot.Pokemon.FossilSpecies; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotLineSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotLineSWSH.cs index 60de59558..cd7e3fc1c 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotLineSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotLineSWSH.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; using static SysBot.Base.SwitchStick; using static SysBot.Pokemon.PokeDataOffsetsSWSH; diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotResetSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotResetSWSH.cs index 1103d1fd5..73e6ec753 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotResetSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotResetSWSH.cs @@ -1,7 +1,7 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; using static SysBot.Base.SwitchButton; using static SysBot.Base.SwitchStick; using static SysBot.Pokemon.PokeDataOffsetsSWSH; diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotSWSH.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotSWSH.cs index 34107cd4b..5cdd1462d 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterBotSWSH.cs @@ -1,9 +1,9 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; using static SysBot.Base.SwitchButton; using static SysBot.Base.SwitchStick; @@ -12,14 +12,19 @@ namespace SysBot.Pokemon; public abstract class EncounterBotSWSH : PokeRoutineExecutor8SWSH, IEncounterBot { protected readonly PokeTradeHub Hub; - private readonly IDumper DumpSetting; + private readonly FolderSettings DumpSetting; private readonly EncounterSettings Settings; private readonly int[] DesiredMinIVs; private readonly int[] DesiredMaxIVs; public ICountSettings Counts => Settings; public readonly IReadOnlyList UnwantedMarks; - protected EncounterBotSWSH(PokeBotState Config, PokeTradeHub hub) : base(Config) + // Cached offsets that stay the same per session. + protected ulong OverworldOffset; + + protected int EncounterCount; + + protected EncounterBotSWSH(PokeBotState config, PokeTradeHub hub) : base(config) { Hub = hub; Settings = Hub.Config.EncounterSWSH; @@ -28,11 +33,6 @@ protected EncounterBotSWSH(PokeBotState Config, PokeTradeHub hub) : base(Co StopConditionSettings.ReadUnwantedMarks(Hub.Config.StopConditions, out UnwantedMarks); } - // Cached offsets that stay the same per session. - protected ulong OverworldOffset; - - protected int encounterCount; - public override async Task MainLoop(CancellationToken token) { var settings = Hub.Config.EncounterSWSH; @@ -71,9 +71,9 @@ public override async Task HardStop() // return true if breaking loop protected async Task HandleEncounter(PK8 pk, CancellationToken token) { - encounterCount++; + EncounterCount++; var print = StopConditionSettings.GetPrintName(pk); - Log($"Encounter: {encounterCount}{Environment.NewLine}{print}{Environment.NewLine}"); + Log($"Encounter: {EncounterCount}{Environment.NewLine}{print}{Environment.NewLine}"); var folder = IncrementAndGetDumpFolder(pk); if (DumpSetting.Dump && !string.IsNullOrEmpty(DumpSetting.DumpFolder)) @@ -139,7 +139,7 @@ private string IncrementAndGetDumpFolder(PK8 pk) return "encounters"; } - private bool IsWaiting; + private bool IsWaiting { get; set; } public void Acknowledge() => IsWaiting = false; protected Task ResetStick(CancellationToken token) diff --git a/SysBot.Pokemon/SWSH/BotEncounter/EncounterSettings.cs b/SysBot.Pokemon/SWSH/BotEncounter/EncounterSettings.cs index a9fa6e586..75220b4ff 100644 --- a/SysBot.Pokemon/SWSH/BotEncounter/EncounterSettings.cs +++ b/SysBot.Pokemon/SWSH/BotEncounter/EncounterSettings.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System.Collections.Generic; using System.ComponentModel; using System.Threading; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SWSH/BotFactory8SWSH.cs b/SysBot.Pokemon/SWSH/BotFactory8SWSH.cs index 50f1f27f7..f3fcc3f50 100644 --- a/SysBot.Pokemon/SWSH/BotFactory8SWSH.cs +++ b/SysBot.Pokemon/SWSH/BotFactory8SWSH.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SWSH/BotRaid/RaidBotSWSH.cs b/SysBot.Pokemon/SWSH/BotRaid/RaidBotSWSH.cs index d07f42eb5..6d2fa84e6 100644 --- a/SysBot.Pokemon/SWSH/BotRaid/RaidBotSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotRaid/RaidBotSWSH.cs @@ -1,8 +1,9 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSWSH; @@ -114,7 +115,7 @@ private async Task HostRaidAsync(int code, CancellationToken token) if (raidBossSpecies == -1) { var data = await Connection.ReadBytesAsync(RaidBossOffset, 2, token).ConfigureAwait(false); - raidBossSpecies = BitConverter.ToUInt16(data, 0); + raidBossSpecies = ReadUInt16LittleEndian(data); } Log($"Initializing raid for {(Species)raidBossSpecies}."); @@ -201,7 +202,7 @@ private async Task ConfirmPlayerReady(uint player, CancellationToken token if (Settings.EchoPartyReady) { data = await Connection.ReadBytesAsync(ofs, 2, token).ConfigureAwait(false); - var dexno = BitConverter.ToUInt16(data, 0); + var dexno = ReadUInt16LittleEndian(data); data = await Connection.ReadBytesAsync(ofs + RaidAltFormInc, 1, token).ConfigureAwait(false); var altformstr = data[0] == 0 ? "" : "-" + data[0]; diff --git a/SysBot.Pokemon/SWSH/BotRaid/RaidSettings.cs b/SysBot.Pokemon/SWSH/BotRaid/RaidSettings.cs index 1d10e7830..15e09bf02 100644 --- a/SysBot.Pokemon/SWSH/BotRaid/RaidSettings.cs +++ b/SysBot.Pokemon/SWSH/BotRaid/RaidSettings.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System.Collections.Generic; using System.ComponentModel; using System.Threading; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SWSH/BotRemoteControl/RemoteControlBotSWSH.cs b/SysBot.Pokemon/SWSH/BotRemoteControl/RemoteControlBotSWSH.cs index 1ff0e984b..6f0db5521 100644 --- a/SysBot.Pokemon/SWSH/BotRemoteControl/RemoteControlBotSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotRemoteControl/RemoteControlBotSWSH.cs @@ -1,7 +1,7 @@ -using SysBot.Base; using System; using System.Threading; using System.Threading.Tasks; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs b/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs index ea22f41f6..e5e73f3ae 100644 --- a/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs @@ -1,11 +1,11 @@ -using PKHeX.Core; -using PKHeX.Core.Searching; -using SysBot.Base; using System; -using System.Linq; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using PKHeX.Core.Searching; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSWSH; @@ -23,7 +23,7 @@ public class PokeTradeBotSWSH(PokeTradeHub hub, PokeBotState Config) : Poke /// Folder to dump received trade data to. /// /// If null, will skip dumping. - private readonly IDumper DumpSetting = hub.Config.Folder; + private readonly FolderSettings DumpSetting = hub.Config.Folder; /// /// Synchronized start for multiple bots. @@ -172,7 +172,7 @@ private async Task PerformTrade(SAV8SWSH sav, PokeTradeDetail detail, PokeR { Log(socket.Message); result = PokeTradeResult.ExceptionConnection; - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); throw; // let this interrupt the trade loop. re-entering the trade loop will recheck the connection. } catch (Exception e) @@ -181,22 +181,22 @@ private async Task PerformTrade(SAV8SWSH sav, PokeTradeDetail detail, PokeR result = PokeTradeResult.ExceptionInternal; } - HandleAbortedTrade(detail, type, priority, result); + await HandleAbortedTrade(detail, type, priority, result).ConfigureAwait(false); } - private void HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) + private async Task HandleAbortedTrade(PokeTradeDetail detail, PokeRoutineType type, uint priority, PokeTradeResult result) { detail.IsProcessing = false; if (result.ShouldAttemptRetry() && detail.Type != PokeTradeType.Random && !detail.IsRetry) { detail.IsRetry = true; hub.Queues.Enqueue(type, detail, Math.Min(priority, PokeTradePriorities.Tier2)); - detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt."); + await detail.SendNotification(this, "Oops! Something happened. I'll requeue you for another attempt.").ConfigureAwait(false); } else { - detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}."); - detail.TradeCanceled(this, result); + await detail.SendNotification(this, $"Oops! Something happened. Canceling the trade: {result}.").ConfigureAwait(false); + await detail.TradeCanceled(this, result).ConfigureAwait(false); } } @@ -215,7 +215,7 @@ private async Task PerformLinkCodeTrade(SAV8SWSH sav, PokeTrade { // Update Barrier Settings UpdateBarrier(poke.IsSynchronized); - poke.TradeInitialize(this); + await poke.TradeInitialize(this).ConfigureAwait(false); await EnsureConnectedToYComm(OverworldOffset, hub.Config, token).ConfigureAwait(false); hub.Config.Stream.EndEnterCode(this); @@ -284,7 +284,7 @@ private async Task PerformLinkCodeTrade(SAV8SWSH sav, PokeTrade await Click(A, 0_800, token).ConfigureAwait(false); } - poke.TradeSearching(this); + await poke.TradeSearching(this).ConfigureAwait(false); await Task.Delay(0_500, token).ConfigureAwait(false); // Wait for a Trainer... @@ -305,10 +305,10 @@ private async Task PerformLinkCodeTrade(SAV8SWSH sav, PokeTrade var trainerName = await GetTradePartnerName(TradeMethod.LinkTrade, token).ConfigureAwait(false); var trainerTID = await GetTradePartnerTID7(TradeMethod.LinkTrade, token).ConfigureAwait(false); var trainerNID = await GetTradePartnerNID(token).ConfigureAwait(false); - RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{trainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.ID}\t{toSend.EncryptionConstant:X8}"); + RecordUtil.Record($"Initiating\t{trainerNID:X16}\t{trainerName}\t{poke.Trainer.TrainerName}\t{poke.Trainer.ID}\t{poke.Id}\t{toSend.EncryptionConstant:X8}"); Log($"Found Link Trade partner: {trainerName}-{trainerTID} (ID: {trainerNID})"); - var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, trainerName, AbuseSettings, token); + var partnerCheck = await CheckPartnerReputation(this, poke, trainerNID, trainerName, AbuseSettings, token).ConfigureAwait(false); if (partnerCheck != PokeTradeResult.Success) { await ExitSeedCheckTrade(token).ConfigureAwait(false); @@ -328,7 +328,7 @@ private async Task PerformLinkCodeTrade(SAV8SWSH sav, PokeTrade await Click(A, 0_500, token).ConfigureAwait(false); } - poke.SendNotification(this, $"Found Link Trade partner: {trainerName}. Waiting for a Pokémon..."); + await poke.SendNotification(this, $"Found Link Trade partner: {trainerName}. Waiting for a Pokémon...").ConfigureAwait(false); if (poke.Type == PokeTradeType.Dump) return await ProcessDumpTradeAsync(poke, token).ConfigureAwait(false); @@ -381,14 +381,14 @@ private async Task PerformLinkCodeTrade(SAV8SWSH sav, PokeTrade if (SearchUtil.HashByDetails(received) == SearchUtil.HashByDetails(toSend) && received.Checksum == toSend.Checksum) { Log("User did not complete the trade."); - RecordUtil.Record($"Cancelled\t{trainerNID:X16}\t{trainerName}\t{poke.Trainer.TrainerName}\\t{poke.ID}\t{toSend.EncryptionConstant:X8}\t{offered.EncryptionConstant:X8}"); + RecordUtil.Record($"Cancelled\t{trainerNID:X16}\t{trainerName}\t{poke.Trainer.TrainerName}\\t{poke.Id}\t{toSend.EncryptionConstant:X8}\t{offered.EncryptionConstant:X8}"); await ExitTrade(false, token).ConfigureAwait(false); return PokeTradeResult.TrainerTooSlow; } // As long as we got rid of our inject in b1s1, assume the trade went through. Log("User completed the trade."); - poke.TradeFinished(this, received); + await poke.TradeFinished(this, received).ConfigureAwait(false); RecordUtil.Record($"Finished\t{trainerNID:X16}\t{toSend.EncryptionConstant:X8}\t{received.EncryptionConstant:X8}"); @@ -470,7 +470,7 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail private async Task<(PK8 toSend, PokeTradeResult check)> HandleClone(SAV8SWSH sav, PokeTradeDetail poke, PK8 offered, byte[] oldEC, CancellationToken token) { if (hub.Config.Discord.ReturnPKMs) - poke.SendNotification(this, offered, "Here's what you showed me!"); + await poke.SendNotification(this, offered, "Here's what you showed me!").ConfigureAwait(false); var la = new LegalityAnalysis(offered); if (!la.Valid) @@ -481,8 +481,8 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail var report = la.Report(); Log(report); - poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade."); - poke.SendNotification(this, report); + await poke.SendNotification(this, "This Pokémon is not legal per PKHeX's legality checks. I am forbidden from cloning this. Exiting trade.").ConfigureAwait(false); + await poke.SendNotification(this, report).ConfigureAwait(false); return (offered, PokeTradeResult.IllegalTrade); } @@ -492,7 +492,7 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail clone.Tracker = 0; var cloneSpecies = GetSpeciesName(clone.Species); - poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want."); + await poke.SendNotification(this, $"**Cloned your {cloneSpecies}!**\nNow press B to cancel your offer and trade me a Pokémon you don't want.").ConfigureAwait(false); Log($"Cloned a {cloneSpecies}. Waiting for user to change their Pokémon..."); // Separate this out from WaitForPokemonChanged since we compare to old EC from original read. @@ -500,7 +500,7 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail if (!partnerFound) { - poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**"); + await poke.SendNotification(this, "**HEY CHANGE IT NOW OR I AM LEAVING!!!**").ConfigureAwait(false); // They get one more chance. partnerFound = await ReadUntilChanged(LinkTradePartnerPokemonOffset, oldEC, 15_000, 0_200, false, token).ConfigureAwait(false); } @@ -543,7 +543,7 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail toSend = trade.Receive; poke.TradeData = toSend; - poke.SendNotification(this, "Injecting the requested Pokémon."); + await poke.SendNotification(this, "Injecting the requested Pokémon.").ConfigureAwait(false); await Click(A, 0_800, token).ConfigureAwait(false); await SetBoxPokemon(toSend, 0, 0, token, sav).ConfigureAwait(false); await Task.Delay(2_500, token).ConfigureAwait(false); @@ -551,7 +551,7 @@ private async Task ConfirmAndStartTrading(PokeTradeDetail else if (config.LedyQuitIfNoMatch) { var nickname = offered.IsNicknamed ? $" (Nickname: \"{offered.Nickname}\")" : string.Empty; - poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}."); + await poke.SendNotification(this, $"No match found for the offered {GetSpeciesName(offered.Species)}{nickname}.").ConfigureAwait(false); return (toSend, PokeTradeResult.TrainerRequestBad); } @@ -629,7 +629,7 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d // Extra information for shiny eggs, because of people dumping to skip hatching. var eggstring = pk.IsEgg ? "Egg " : string.Empty; msg += pk.IsShiny ? $"\n**This Pokémon {eggstring}is shiny!**" : string.Empty; - detail.SendNotification(this, pk, msg); + await detail.SendNotification(this, pk, msg).ConfigureAwait(false); } Log($"Ended Dump loop after processing {ctr} Pokémon."); @@ -638,8 +638,8 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d return PokeTradeResult.TrainerTooSlow; TradeSettings.AddCompletedDumps(); - detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon."); - detail.Notifier.TradeFinished(this, detail, detail.TradeData); // blank pk8 + await detail.Notifier.SendNotification(this, detail, $"Dumped {ctr} Pokémon.").ConfigureAwait(false); + await detail.Notifier.TradeFinished(this, detail, detail.TradeData).ConfigureAwait(false); // blank pk8 return PokeTradeResult.Success; } @@ -742,7 +742,7 @@ private async Task PerformSurpriseTrade(SAV8SWSH sav, PK8 pkm, // Clear out the received trade data; we want to skip the trade animation. // The box slot locks have been removed prior to searching. - await Connection.WriteBytesAsync(BitConverter.GetBytes(SurpriseTradeSearch_Empty), SurpriseTradeSearchOffset, token).ConfigureAwait(false); + await Connection.WriteBytesAsync(PokeTradeBotUtil.EMPTY_U32, SurpriseTradeSearchOffset, token).ConfigureAwait(false); await Connection.WriteBytesAsync(PokeTradeBotUtil.EMPTY_SLOT, SurpriseTradePartnerPokemonOffset, token).ConfigureAwait(false); // Let the game recognize our modifications before finishing this loop. @@ -750,8 +750,9 @@ private async Task PerformSurpriseTrade(SAV8SWSH sav, PK8 pkm, // Clear the Surprise Trade slot locks! We'll skip the trade animation and reuse the slot on later loops. // Write 8 bytes of FF to set both Int32's to -1. Regular locks are [Box32][Slot32] + var tmp = BitConverter.GetBytes(ulong.MaxValue); - await Connection.WriteBytesAsync(BitConverter.GetBytes(ulong.MaxValue), SurpriseTradeLockBox, token).ConfigureAwait(false); + await Connection.WriteBytesAsync(tmp, SurpriseTradeLockBox, token).ConfigureAwait(false); if (token.IsCancellationRequested) return PokeTradeResult.RoutineCancel; @@ -772,7 +773,7 @@ private async Task EndSeedCheckTradeAsync(PokeTradeDetail { await ExitSeedCheckTrade(token).ConfigureAwait(false); - detail.TradeFinished(this, pk); + await detail.TradeFinished(this, pk).ConfigureAwait(false); if (DumpSetting.Dump && !string.IsNullOrEmpty(DumpSetting.DumpFolder)) DumpPokemon(DumpSetting.DumpFolder, "seed", pk); @@ -797,23 +798,23 @@ private async Task EndSeedCheckTradeAsync(PokeTradeDetail return PokeTradeResult.Success; } - private void ReplyWithSeedCheckResults(PokeTradeDetail detail, PK8 result) + private async Task ReplyWithSeedCheckResults(PokeTradeDetail detail, PK8 result) { - detail.SendNotification(this, "Calculating your seed(s)..."); + await detail.SendNotification(this, "Calculating your seed(s)...").ConfigureAwait(false); if (result.IsShiny) { Log("The Pokémon is already shiny!"); // Do not bother checking for next shiny frame - detail.SendNotification(this, "This Pokémon is already shiny! Raid seed calculation was not done."); + await detail.SendNotification(this, "This Pokémon is already shiny! Raid seed calculation was not done.").ConfigureAwait(false); if (DumpSetting.Dump && !string.IsNullOrEmpty(DumpSetting.DumpFolder)) DumpPokemon(DumpSetting.DumpFolder, "seed", result); - detail.TradeFinished(this, result); + await detail.TradeFinished(this, result).ConfigureAwait(false); return; } - SeedChecker.CalculateAndNotify(result, detail, hub.Config.SeedCheckSWSH, this); + await SeedChecker.CalculateAndNotify(result, detail, hub.Config.SeedCheckSWSH, this).ConfigureAwait(false); Log("Seed calculation completed."); } @@ -946,7 +947,7 @@ private async Task CheckIfSearchingForLinkTradePartner(CancellationToken t private async Task CheckIfSearchingForSurprisePartner(CancellationToken token) { var data = await Connection.ReadBytesAsync(SurpriseTradeSearchOffset, 8, token).ConfigureAwait(false); - return BitConverter.ToUInt32(data, 0) == SurpriseTradeSearch_Searching; + return ReadUInt32LittleEndian(data) == SurpriseTradeSearch_Searching; } private async Task GetTradePartnerName(TradeMethod tradeMethod, CancellationToken token) @@ -961,7 +962,7 @@ private async Task GetTradePartnerTID7(TradeMethod tradeMethod, Cancella var ofs = GetTrainerTIDSIDOffset(tradeMethod); var data = await Connection.ReadBytesAsync(ofs, 8, token).ConfigureAwait(false); - var tidsid = BitConverter.ToUInt32(data, 0); + var tidsid = ReadUInt32LittleEndian(data); var tid7 = $"{tidsid % 1_000_000:000000}"; return tid7; } @@ -969,6 +970,6 @@ private async Task GetTradePartnerTID7(TradeMethod tradeMethod, Cancella public async Task GetTradePartnerNID(CancellationToken token) { var data = await Connection.ReadBytesAsync(LinkTradePartnerNIDOffset, 8, token).ConfigureAwait(false); - return BitConverter.ToUInt64(data, 0); + return ReadUInt64LittleEndian(data); } } diff --git a/SysBot.Pokemon/SWSH/PokeRoutineExecutor8SWSH.cs b/SysBot.Pokemon/SWSH/PokeRoutineExecutor8SWSH.cs index 8445e211b..f1ae793d1 100644 --- a/SysBot.Pokemon/SWSH/PokeRoutineExecutor8SWSH.cs +++ b/SysBot.Pokemon/SWSH/PokeRoutineExecutor8SWSH.cs @@ -1,10 +1,11 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; +using static System.Buffers.Binary.BinaryPrimitives; using static SysBot.Base.SwitchButton; using static SysBot.Pokemon.PokeDataOffsetsSWSH; @@ -64,7 +65,7 @@ public override Task ReadBoxPokemon(int box, int slot, CancellationToken to public Task SetCurrentBox(byte box, CancellationToken token) { - return Connection.WriteBytesAsync([box], CurrentBoxOffset, token); + return Connection.WriteBytesAsync(new[] {box}, CurrentBoxOffset, token); } public async Task GetCurrentBox(CancellationToken token) @@ -73,10 +74,10 @@ public async Task GetCurrentBox(CancellationToken token) return data[0]; } - public async Task ReadIsChanged(uint offset, byte[] original, CancellationToken token) + public async Task ReadIsChanged(uint offset, ReadOnlyMemory original, CancellationToken token) { var result = await Connection.ReadBytesAsync(offset, original.Length, token).ConfigureAwait(false); - return !result.SequenceEqual(original); + return !result.AsSpan().SequenceEqual(original.Span); } public async Task IdentifyTrainer(CancellationToken token) @@ -211,7 +212,7 @@ public Task UnSoftBan(CancellationToken token) // how long we are soft banned and once the soft ban is lifted // the game sets the value back to 0 (1970/01/01 12:00 AM (UTC)) Log("Soft ban detected, unbanning."); - var data = BitConverter.GetBytes(0); + var data = new byte[4]; return Connection.WriteBytesAsync(data, SoftBanUnixTimespanOffset, token); } @@ -279,13 +280,13 @@ public async Task StartGame(PokeTradeHubConfig config, CancellationToken token) public async Task IsCorrectScreen(uint expectedScreen, CancellationToken token) { var data = await Connection.ReadBytesAsync(CurrentScreenOffset, 4, token).ConfigureAwait(false); - return BitConverter.ToUInt32(data, 0) == expectedScreen; + return ReadUInt32LittleEndian(data) == expectedScreen; } public async Task GetCurrentScreen(CancellationToken token) { var data = await Connection.ReadBytesAsync(CurrentScreenOffset, 4, token).ConfigureAwait(false); - return BitConverter.ToUInt32(data, 0); + return ReadUInt32LittleEndian(data); } public async Task IsInBattle(CancellationToken token) @@ -297,7 +298,7 @@ public async Task IsInBattle(CancellationToken token) public async Task IsInBox(CancellationToken token) { var data = await Connection.ReadBytesAsync(CurrentScreenOffset, 4, token).ConfigureAwait(false); - var dataint = BitConverter.ToUInt32(data, 0); + var dataint = ReadUInt32LittleEndian(data); return dataint is CurrentScreen_Box1 or CurrentScreen_Box2; } diff --git a/SysBot.Pokemon/Settings/DistributionSettings.cs b/SysBot.Pokemon/Settings/DistributionSettings.cs index 3a34c39d4..2c3328fa1 100644 --- a/SysBot.Pokemon/Settings/DistributionSettings.cs +++ b/SysBot.Pokemon/Settings/DistributionSettings.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; -using SysBot.Base; using System.ComponentModel; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Settings/FavoredPrioritySettings.cs b/SysBot.Pokemon/Settings/FavoredPrioritySettings.cs index 0909ce5a0..d980bae7f 100644 --- a/SysBot.Pokemon/Settings/FavoredPrioritySettings.cs +++ b/SysBot.Pokemon/Settings/FavoredPrioritySettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; namespace SysBot.Pokemon; @@ -17,34 +17,29 @@ public class FavoredPrioritySettings : IFavoredCPQSetting private const float _mexp = 0.5f; private const float _mmul = 0.1f; - private int _minimumFreeAhead = _mfi; - private float _bypassFactor = 1.5f; - private float _exponent = 0.777f; - private float _multiply = 0.5f; - [Category(Operation), Description("Determines how the insertion position of favored users is calculated. \"None\" will prevent any favoritism from being applied.")] public FavoredMode Mode { get; set; } [Category(Configure), Description("Inserted after (unfavored users)^(exponent) unfavored users.")] public float Exponent { - get => _exponent; - set => _exponent = Math.Max(_mexp, value); - } + get; + set => field = Math.Max(_mexp, value); + } = 0.777f; [Category(Configure), Description("Multiply: Inserted after (unfavored users)*(multiply) unfavored users. Setting this to 0.2 adds in after 20% of users.")] public float Multiply { - get => _multiply; - set => _multiply = Math.Max(_mmul, value); - } + get; + set => field = Math.Max(_mmul, value); + } = 0.5f; [Category(Configure), Description("Number of unfavored users to not skip over. This only is enforced if a significant number of unfavored users are in the queue.")] public int MinimumFreeAhead { - get => _minimumFreeAhead; - set => _minimumFreeAhead = Math.Max(_mfi, value); - } + get; + set => field = Math.Max(_mfi, value); + } = _mfi; [Category(Configure), Description("Minimum number of unfavored users in queue to cause {MinimumFreeAhead} to be enforced. When the aforementioned number is higher than this value, a favored user is not placed ahead of {MinimumFreeAhead} unfavored users.")] public int MinimumFreeBypass => (int)Math.Ceiling(MinimumFreeAhead * MinimumFreeBypassFactor); @@ -52,7 +47,7 @@ public int MinimumFreeAhead [Category(Configure), Description("Scalar that is multiplied with {MinimumFreeAhead} to determine the {MinimumFreeBypass} value.")] public float MinimumFreeBypassFactor { - get => _bypassFactor; - set => _bypassFactor = Math.Min(_bmax, Math.Max(_bmin, value)); - } + get; + set => field = Math.Min(_bmax, Math.Max(_bmin, value)); + } = 1.5f; } diff --git a/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs b/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs index c30eab9e7..eeb1d76d6 100644 --- a/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; namespace SysBot.Pokemon; @@ -16,9 +16,6 @@ public class DiscordSettings [Category(Startup), Description("Bot login token.")] public string Token { get; set; } = string.Empty; - [Category(Startup), Description("Bot command prefix.")] - public string CommandPrefix { get; set; } = "$"; - [Category(Startup), Description("List of modules that will not be loaded when the bot is started (comma separated).")] public string ModuleBlacklist { get; set; } = string.Empty; @@ -31,6 +28,12 @@ public class DiscordSettings [Category(Startup), Description("Indicates the Discord presence status color only considering bots that are Trade-type.")] public bool BotColorStatusTradeOnly { get; set; } = true; + [Category(Startup), Description("Unique hash identity of the slash command set most recently registered to Discord. Clear this string to force a refresh on next startup.")] + public string SlashCommandHash { get; set; } = ""; + + [Category(Startup), Description("Main Discord server the bot lives in, where commands will be reloaded more quickly to if you need to update them.")] + public ulong SlashMainGuild { get; set; } + [Category(Operation), Description("Custom message the bot will reply with when a user says hello to it. Use string formatting to mention the user in the reply.")] public string HelloResponse { get; set; } = "Hi {0}!"; diff --git a/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs b/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs index b9d4d6705..b4c08879d 100644 --- a/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs @@ -1,9 +1,10 @@ -using PKHeX.Core; -using SysBot.Base; using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; using System.Linq; +using PKHeX.Core; +using SysBot.Base; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global namespace SysBot.Pokemon; @@ -272,7 +273,7 @@ public void EndEnterCode(PokeRoutineExecutorBase b) private void GenerateBotConnection(PokeRoutineExecutorBase b, PokeTradeDetail detail) where T : PKM, new() { var file = b.Connection.Name; - var name = string.Format(TrainerTradeStart, detail.ID, detail.Trainer.TrainerName, (Species)detail.TradeData.Species); + var name = string.Format(TrainerTradeStart, detail.Id, detail.Trainer.TrainerName, (Species)detail.TradeData.Species); File.WriteAllText($"{file}.txt", name); } @@ -288,21 +289,21 @@ public void EndEnterCode(PokeRoutineExecutorBase b) private void GenerateOnDeck(PokeTradeHub hub) where T : PKM, new() { - var ondeck = hub.Queues.Info.GetUserList(OnDeckFormat); + IEnumerable ondeck = hub.Queues.Info.GetUserList(OnDeckFormat); ondeck = ondeck.Skip(OnDeckSkip).Take(OnDeckTake); // filter down File.WriteAllText("ondeck.txt", string.Join(OnDeckSeparator, ondeck)); } private void GenerateOnDeck2(PokeTradeHub hub) where T : PKM, new() { - var ondeck = hub.Queues.Info.GetUserList(OnDeckFormat2); + IEnumerable ondeck = hub.Queues.Info.GetUserList(OnDeckFormat2); ondeck = ondeck.Skip(OnDeckSkip2).Take(OnDeckTake2); // filter down File.WriteAllText("ondeck2.txt", string.Join(OnDeckSeparator2, ondeck)); } private void GenerateUserList(PokeTradeHub hub) where T : PKM, new() { - var users = hub.Queues.Info.GetUserList(UserListFormat); + IEnumerable users = hub.Queues.Info.GetUserList(UserListFormat); users = users.Skip(UserListSkip); if (UserListTake > 0) users = users.Take(UserListTake); // filter down diff --git a/SysBot.Pokemon/Settings/Integrations/TwitchSettings.cs b/SysBot.Pokemon/Settings/Integrations/TwitchSettings.cs index 64d718807..2c5d34e85 100644 --- a/SysBot.Pokemon/Settings/Integrations/TwitchSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/TwitchSettings.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.ComponentModel; -using System.Linq; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Settings/Integrations/YouTubeSettings.cs b/SysBot.Pokemon/Settings/Integrations/YouTubeSettings.cs index 2866ef866..40bddbbe5 100644 --- a/SysBot.Pokemon/Settings/Integrations/YouTubeSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/YouTubeSettings.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.ComponentModel; -using System.Linq; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Settings/LegalitySettings.cs b/SysBot.Pokemon/Settings/LegalitySettings.cs index da0f5c9c4..4b0ae873b 100644 --- a/SysBot.Pokemon/Settings/LegalitySettings.cs +++ b/SysBot.Pokemon/Settings/LegalitySettings.cs @@ -1,9 +1,9 @@ -using PKHeX.Core; -using PKHeX.Core.AutoMod; using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using PKHeX.Core; +using PKHeX.Core.AutoMod; namespace SysBot.Pokemon; @@ -45,7 +45,7 @@ public string GenerateOT public GameVersionPriorityType GameVersionPriority { get; set; } = GameVersionPriorityType.NativeOnly; [Category(Generate), Description("Specifies the order of games to use to generate encounters. Set PrioritizeGame to \"true\" to enable.")] - public List PriorityOrder { get; set; } = Enum.GetValues().Where(GameUtil.IsValidSavedVersion).Reverse().ToList(); + public List PriorityOrder { get; set; } = [.. Enum.GetValues().Where(GameUtil.IsValidSavedVersion).Reverse()]; [Category(Generate), Description("Set all possible legal ribbons for any generated Pokémon.")] public bool SetAllLegalRibbons { get; set; } diff --git a/SysBot.Pokemon/Settings/StopConditionSettings.cs b/SysBot.Pokemon/Settings/StopConditionSettings.cs index 08821bb3a..409c34b02 100644 --- a/SysBot.Pokemon/Settings/StopConditionSettings.cs +++ b/SysBot.Pokemon/Settings/StopConditionSettings.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using PKHeX.Core; namespace SysBot.Pokemon; @@ -207,7 +207,7 @@ public static string GetPrintName(PKM pk) } public static void ReadUnwantedMarks(StopConditionSettings settings, out IReadOnlyList marks) => - marks = settings.UnwantedMarks.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToList(); + marks = [.. settings.UnwantedMarks.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim())]; public virtual bool IsUnwantedMark(string mark, IReadOnlyList marklist) => marklist.Contains(mark); diff --git a/SysBot.Pokemon/Settings/TradeSettings.cs b/SysBot.Pokemon/Settings/TradeSettings.cs index 454d2da4e..72b41702d 100644 --- a/SysBot.Pokemon/Settings/TradeSettings.cs +++ b/SysBot.Pokemon/Settings/TradeSettings.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System.Collections.Generic; using System.ComponentModel; using System.Threading; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs b/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs index 45b9f99ac..49eab64f2 100644 --- a/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs +++ b/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs @@ -91,7 +91,11 @@ public bool TryPeek(out KeyValuePair result) /// Gets the number of elements contained in the queue. public int Count { - get { lock (_syncLock) return Queue.Count; } + get + { + lock (_syncLock) + return Queue.Count; + } } /// Copies the elements of the collection to an array, starting at a particular array index. @@ -104,7 +108,8 @@ public int Count /// The elements will not be copied to the array in any guaranteed order. public void CopyTo(KeyValuePair[] array, int index) { - lock (_syncLock) Queue.Items.CopyTo(array, index); + lock (_syncLock) + Queue.Items.CopyTo(array, index); } /// Copies the elements stored in the queue to a new array. diff --git a/SysBot.Pokemon/Structures/ISeedSearchHandler.cs b/SysBot.Pokemon/Structures/ISeedSearchHandler.cs index 4cf33cdd6..2764f2237 100644 --- a/SysBot.Pokemon/Structures/ISeedSearchHandler.cs +++ b/SysBot.Pokemon/Structures/ISeedSearchHandler.cs @@ -1,18 +1,20 @@ -using PKHeX.Core; +using System.Threading.Tasks; +using PKHeX.Core; namespace SysBot.Pokemon; public interface ISeedSearchHandler where T : PKM, new() { - void CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot); + Task CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot); } public class NoSeedSearchHandler : ISeedSearchHandler where T : PKM, new() { - public void CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, PokeRoutineExecutor bot) + public async Task CalculateAndNotify(T pkm, PokeTradeDetail detail, SeedCheckSettings settings, + PokeRoutineExecutor bot) { const string msg = "Seed searching implementation not found. " + "Please let the person hosting the bot know that they need to provide the required Z3 files."; - detail.SendNotification(bot, msg); + await detail.SendNotification(bot, msg).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon/Structures/Ledy/LedyDistributor.cs b/SysBot.Pokemon/Structures/Ledy/LedyDistributor.cs index 826cb5469..c470021ca 100644 --- a/SysBot.Pokemon/Structures/Ledy/LedyDistributor.cs +++ b/SysBot.Pokemon/Structures/Ledy/LedyDistributor.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System.Collections.Generic; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Structures/Ledy/PokemonPool.cs b/SysBot.Pokemon/Structures/Ledy/PokemonPool.cs index 67228e59f..029e43c9f 100644 --- a/SysBot.Pokemon/Structures/Ledy/PokemonPool.cs +++ b/SysBot.Pokemon/Structures/Ledy/PokemonPool.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Collections.Generic; using System.IO; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; @@ -75,7 +75,7 @@ public bool LoadFolder(string path, SearchOption opt = SearchOption.AllDirectori if (dest.Species == 0) { - LogUtil.LogInfo("SKIPPED: Provided file is not valid: " + dest.FileName, nameof(PokemonPool)); + LogUtil.LogInfo("SKIPPED: Provided file is not valid: " + dest.FileName, nameof(PokemonPool<>)); continue; } @@ -83,18 +83,18 @@ public bool LoadFolder(string path, SearchOption opt = SearchOption.AllDirectori if (!la.Valid) { var reason = la.Report(); - LogUtil.LogInfo($"SKIPPED: Provided file is not legal: {dest.FileName} -- {reason}", nameof(PokemonPool)); + LogUtil.LogInfo($"SKIPPED: Provided file is not legal: {dest.FileName} -- {reason}", nameof(PokemonPool<>)); continue; } if (!dest.CanBeTraded(la.EncounterOriginal)) { - LogUtil.LogInfo($"SKIPPED: Provided file cannot be traded: {dest.FileName}", nameof(PokemonPool)); + LogUtil.LogInfo($"SKIPPED: Provided file cannot be traded: {dest.FileName}", nameof(PokemonPool<>)); continue; } if (typeof(T) == typeof(PK8) && DisallowRandomRecipientTrade(dest)) { - LogUtil.LogInfo($"Provided file was loaded but can't be Surprise Traded: {dest.FileName}", nameof(PokemonPool)); + LogUtil.LogInfo($"Provided file was loaded but can't be Surprise Traded: {dest.FileName}", nameof(PokemonPool<>)); surpriseBlocked++; } @@ -112,13 +112,13 @@ public bool LoadFolder(string path, SearchOption opt = SearchOption.AllDirectori } else { - LogUtil.LogInfo("Provided file was not added due to duplicate name: " + dest.FileName, nameof(PokemonPool)); + LogUtil.LogInfo("Provided file was not added due to duplicate name: " + dest.FileName, nameof(PokemonPool<>)); } loadedAny = true; } if (typeof(T) == typeof(PK8) && surpriseBlocked == Count) - LogUtil.LogInfo("Surprise trading will fail; failed to load any compatible files.", nameof(PokemonPool)); + LogUtil.LogInfo("Surprise trading will fail; failed to load any compatible files.", nameof(PokemonPool<>)); return loadedAny; } diff --git a/SysBot.Pokemon/Structures/PokeBotRunner.cs b/SysBot.Pokemon/Structures/PokeBotRunner.cs index 0f4bc58da..f9cc3627a 100644 --- a/SysBot.Pokemon/Structures/PokeBotRunner.cs +++ b/SysBot.Pokemon/Structures/PokeBotRunner.cs @@ -1,8 +1,8 @@ -using PKHeX.Core; -using SysBot.Base; using System.IO; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; @@ -103,15 +103,15 @@ private void AddTradeBotMonitors() var path = Hub.Config.Folder.DistributeFolder; if (!Directory.Exists(path)) - LogUtil.LogError("The distribution folder was not found. Please verify that it exists!", "Hub"); + LogUtil.LogError("The distribution folder was not found. Please verify that it exists!"); path = Hub.Config.Folder.DumpFolder; if (Hub.Config.Folder.Dump && !Directory.Exists(path)) - LogUtil.LogError("The program is configured to dump files, but the dump folder was not found. Please verify that it exists!", "Hub"); + LogUtil.LogError("The program is configured to dump files, but the dump folder was not found. Please verify that it exists!"); var pool = Hub.Ledy.Pool; if (!pool.Reload(Hub.Config.Folder.DistributeFolder)) - LogUtil.LogError("Nothing to distribute for Empty Trade Queues!", "Hub"); + LogUtil.LogError("Nothing to distribute for Empty Trade Queues!"); } public PokeRoutineExecutorBase CreateBotFromConfig(PokeBotState cfg) => Factory.CreateBot(Hub, cfg); diff --git a/SysBot.Pokemon/Structures/PokeBotState.cs b/SysBot.Pokemon/Structures/PokeBotState.cs index fcaa92e50..2be1bfac1 100644 --- a/SysBot.Pokemon/Structures/PokeBotState.cs +++ b/SysBot.Pokemon/Structures/PokeBotState.cs @@ -1,5 +1,5 @@ -using SysBot.Base; using System; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Structures/ProgramConfig.cs b/SysBot.Pokemon/Structures/ProgramConfig.cs index 56c267a51..6a7a0b2be 100644 --- a/SysBot.Pokemon/Structures/ProgramConfig.cs +++ b/SysBot.Pokemon/Structures/ProgramConfig.cs @@ -1,5 +1,5 @@ -using SysBot.Base; using System.Text.Json.Serialization; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Structures/QueueMonitor.cs b/SysBot.Pokemon/Structures/QueueMonitor.cs index 848deb020..2b17f2d72 100644 --- a/SysBot.Pokemon/Structures/QueueMonitor.cs +++ b/SysBot.Pokemon/Structures/QueueMonitor.cs @@ -1,7 +1,7 @@ -using PKHeX.Core; -using SysBot.Base; using System.Threading; using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/SysBot.Pokemon.csproj.DotSettings b/SysBot.Pokemon/SysBot.Pokemon.csproj.DotSettings new file mode 100644 index 000000000..89316e414 --- /dev/null +++ b/SysBot.Pokemon/SysBot.Pokemon.csproj.DotSettings @@ -0,0 +1,2 @@ + + Library \ No newline at end of file diff --git a/SysBot.Pokemon/TradeHub/IPokeTradeNotifier.cs b/SysBot.Pokemon/TradeHub/IPokeTradeNotifier.cs index 4f2a72661..9a657f06f 100644 --- a/SysBot.Pokemon/TradeHub/IPokeTradeNotifier.cs +++ b/SysBot.Pokemon/TradeHub/IPokeTradeNotifier.cs @@ -1,26 +1,32 @@ -using PKHeX.Core; using System; +using System.Threading.Tasks; +using PKHeX.Core; namespace SysBot.Pokemon; public interface IPokeTradeNotifier where T : PKM, new() { /// Notifies when a trade bot is initializing at the start. - void TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info); + Task TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info); + /// Notifies when a trade bot is searching for the partner. - void TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info); + Task TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info); + /// Notifies when a trade bot notices the trade was canceled. - void TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg); + Task TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg); + /// Notifies when a trade bot finishes the trade. - void TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result); + Task TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result); /// Sends a notification when called with parameters. - void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message); + Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message); + /// Sends a notification when called with parameters. - void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary message); + Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary trade); + /// Sends a notification when called with parameters. - void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message); + Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message); - /// Notifies when a trade bot is initializing at the start. + /// Notifies when a trade bot is finishing its routine. Action>? OnFinish { set; } } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs b/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs index 393d003b5..2c3ecd728 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs @@ -1,77 +1,58 @@ -using PKHeX.Core; using System; using System.Threading; +using System.Threading.Tasks; +using PKHeX.Core; namespace SysBot.Pokemon; -public class PokeTradeDetail(TPoke TradeData, PokeTradeTrainerInfo Trainer, IPokeTradeNotifier Notifier, PokeTradeType Type, int Code, bool IsFavored = false) : IEquatable>, IFavoredEntry where TPoke : PKM, new() +public sealed record PokeTradeDetail : IFavoredEntry where TPoke : PKM, new() { // ReSharper disable once StaticMemberInGenericType /// Global variable indicating the amount of trades created. - private static int CreatedCount; + private static int _createdCount; + /// Indicates if this trade data should be given priority for queue insertion. - public bool IsFavored { get; } = IsFavored; + public bool IsFavored { get; init; } /// /// Trade Code /// - public readonly int Code = Code; + public required int Code { get; init; } /// Data to be traded - public TPoke TradeData = TradeData; + public required TPoke TradeData { get; set; } /// Trainer details - public readonly PokeTradeTrainerInfo Trainer = Trainer; + public required PokeTradeTrainerInfo Trainer { get; init; } /// Destination to be notified for status updates - public readonly IPokeTradeNotifier Notifier = Notifier; + public required IPokeTradeNotifier Notifier { get; init; } /// Type of trade this object is for - public readonly PokeTradeType Type = Type; + public required PokeTradeType Type { get; init; } /// Time the object was created at public readonly DateTime Time = DateTime.Now; /// Unique incremented ID - public readonly int ID = Interlocked.Increment(ref CreatedCount) % 3000; + public readonly int Id = Interlocked.Increment(ref _createdCount) % 3000; /// Indicates if the trade data should be synchronized with other bots. public bool IsSynchronized => Type == PokeTradeType.Random; /// Indicates if the trade failed at least once and is being tried again. - public bool IsRetry; + public bool IsRetry { get; set; } /// Indicates if the trade data is currently being traded. - public bool IsProcessing; - - public void TradeInitialize(PokeRoutineExecutor routine) => Notifier.TradeInitialize(routine, this); - public void TradeSearching(PokeRoutineExecutor routine) => Notifier.TradeSearching(routine, this); - public void TradeCanceled(PokeRoutineExecutor routine, PokeTradeResult msg) => Notifier.TradeCanceled(routine, this, msg); - - public virtual void TradeFinished(PokeRoutineExecutor routine, TPoke result) - { - Notifier.TradeFinished(routine, this, result); - } - - public void SendNotification(PokeRoutineExecutor routine, string message) => Notifier.SendNotification(routine, this, message); - public void SendNotification(PokeRoutineExecutor routine, PokeTradeSummary obj) => Notifier.SendNotification(routine, this, obj); - public void SendNotification(PokeRoutineExecutor routine, TPoke obj, string message) => Notifier.SendNotification(routine, this, obj, message); + public bool IsProcessing { get; set; } - public bool Equals(PokeTradeDetail? other) - { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; - return ReferenceEquals(Trainer, other.Trainer); - } - - public override bool Equals(object? obj) - { - if (obj is null) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; - return Equals((PokeTradeDetail)obj); - } + public async Task TradeInitialize(PokeRoutineExecutor routine) => await Notifier.TradeInitialize(routine, this).ConfigureAwait(false); + public async Task TradeSearching(PokeRoutineExecutor routine) => await Notifier.TradeSearching(routine, this).ConfigureAwait(false); + public async Task TradeCanceled(PokeRoutineExecutor routine, PokeTradeResult msg) => await Notifier.TradeCanceled(routine, this, msg).ConfigureAwait(false); + public async Task TradeFinished(PokeRoutineExecutor routine, TPoke result) => await Notifier.TradeFinished(routine, this, result).ConfigureAwait(false); + public async Task SendNotification(PokeRoutineExecutor routine, string message) => await Notifier.SendNotification(routine, this, message).ConfigureAwait(false); + public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeSummary obj) => await Notifier.SendNotification(routine, this, obj).ConfigureAwait(false); + public async Task SendNotification(PokeRoutineExecutor routine, TPoke obj, string message) => await Notifier.SendNotification(routine, this, obj, message).ConfigureAwait(false); - public override int GetHashCode() => Trainer.GetHashCode(); public override string ToString() => $"{Trainer.TrainerName} - {Code}"; public string Summary(int queuePosition) diff --git a/SysBot.Pokemon/TradeHub/PokeTradeHub.cs b/SysBot.Pokemon/TradeHub/PokeTradeHub.cs index 1003967c6..b9917ad71 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeHub.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeHub.cs @@ -1,6 +1,6 @@ +using System.Collections.Concurrent; using PKHeX.Core; using SysBot.Base; -using System.Collections.Concurrent; namespace SysBot.Pokemon; @@ -17,7 +17,7 @@ public PokeTradeHub(PokeTradeHubConfig config) Ledy = new LedyDistributor(pool); BotSync = new BotSynchronizer(config.Distribution); var plural = BotSync.Barrier.ParticipantCount > 1 ? "s" : ""; - BotSync.BarrierReleasingActions.Add(() => LogUtil.LogInfo($"{BotSync.Barrier.ParticipantCount} bot{plural} released.", "Barrier")); + BotSync.BarrierReleasingActions.Add(() => LogUtil.LogInfo($"{BotSync.Barrier.ParticipantCount} bot{plural} released.")); Queues = new TradeQueueManager(this); } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeLogNotifier.cs b/SysBot.Pokemon/TradeHub/PokeTradeLogNotifier.cs index f57722f00..4af2bfc73 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeLogNotifier.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeLogNotifier.cs @@ -1,29 +1,33 @@ -using PKHeX.Core; -using SysBot.Base; using System; using System.Linq; +using System.Threading.Tasks; +using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon; public class PokeTradeLogNotifier : IPokeTradeNotifier where T : PKM, new() { - public void TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) + public Task TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) { LogUtil.LogInfo($"Starting trade loop for {info.Trainer.TrainerName}, sending {routine.GetSpeciesName(info.TradeData.Species)}", routine.Connection.Label); + return Task.CompletedTask; } - public void TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) + public Task TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail info) { LogUtil.LogInfo($"Searching for trade with {info.Trainer.TrainerName}, sending {routine.GetSpeciesName(info.TradeData.Species)}", routine.Connection.Label); + return Task.CompletedTask; } - public void TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) + public Task TradeCanceled(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeResult msg) { LogUtil.LogInfo($"Canceling trade with {info.Trainer.TrainerName}, because {msg}.", routine.Connection.Label); OnFinish?.Invoke(routine); + return Task.CompletedTask; } - public void TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) + public Task TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail info, T result) { // Print the nickname for Ledy trades so we can see what was requested. var ledyname = string.Empty; @@ -32,25 +36,29 @@ public void TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail inf LogUtil.LogInfo($"Finished trading {info.Trainer.TrainerName} {routine.GetSpeciesName(info.TradeData.Species)} for {routine.GetSpeciesName(result.Species)}{ledyname}", routine.Connection.Label); OnFinish?.Invoke(routine); + return Task.CompletedTask; } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) + public Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) { LogUtil.LogInfo(message, routine.Connection.Label); + return Task.CompletedTask; } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary message) + public Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, PokeTradeSummary trade) { - var msg = message.Summary; - if (message.Details.Count > 0) - msg += ", " + string.Join(", ", message.Details.Select(z => $"{z.Heading}: {z.Detail}")); + var msg = trade.Summary; + if (trade.Details.Count > 0) + msg += ", " + string.Join(", ", trade.Details.Select(z => $"{z.Heading}: {z.Detail}")); LogUtil.LogInfo(msg, routine.Connection.Label); + return Task.CompletedTask; } - public void SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) + public Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, T result, string message) { LogUtil.LogInfo($"Notifying {info.Trainer.TrainerName} about their {routine.GetSpeciesName(result.Species)}", routine.Connection.Label); LogUtil.LogInfo(message, routine.Connection.Label); + return Task.CompletedTask; } public Action>? OnFinish { get; set; } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs b/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs index 370ccd8ab..d87c42f3b 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System; using System.Linq; +using PKHeX.Core; namespace SysBot.Pokemon; @@ -40,6 +40,6 @@ public bool TryPeek(out PokeTradeDetail detail, out uint priority) public string Summary() { var list = Queue.Select((x, i) => x.Value.Summary(i + 1)); - return string.Join("\n", list); + return string.Join('\n', list); } } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeResult.cs b/SysBot.Pokemon/TradeHub/PokeTradeResult.cs index 62bef65fe..13280637a 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeResult.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeResult.cs @@ -28,5 +28,8 @@ public enum PokeTradeResult public static class PokeTradeResultExtensions { - public static bool ShouldAttemptRetry(this PokeTradeResult t) => t >= PokeTradeResult.RoutineCancel; + extension(PokeTradeResult result) + { + public bool ShouldAttemptRetry() => result >= PokeTradeResult.RoutineCancel; + } } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeTrainerInfo.cs b/SysBot.Pokemon/TradeHub/PokeTradeTrainerInfo.cs index 8c5c97a1f..4ce24fc16 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeTrainerInfo.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeTrainerInfo.cs @@ -1,3 +1,3 @@ namespace SysBot.Pokemon; -public record PokeTradeTrainerInfo(string TrainerName, ulong ID = 0); +public sealed record PokeTradeTrainerInfo(string TrainerName, ulong ID = 0); diff --git a/SysBot.Pokemon/TradeHub/RequestSignificance.cs b/SysBot.Pokemon/TradeHub/RequestSignificance.cs index 8dc1a3814..0c497564e 100644 --- a/SysBot.Pokemon/TradeHub/RequestSignificance.cs +++ b/SysBot.Pokemon/TradeHub/RequestSignificance.cs @@ -1,4 +1,4 @@ -namespace SysBot.Pokemon; +namespace SysBot.Pokemon; /// /// Indicates the significance of request data. @@ -20,3 +20,13 @@ public enum RequestSignificance /// Owner, } + +public static class RequestSignificanceExtensions +{ + extension(RequestSignificance sig) + { + public bool IsFavored => sig is RequestSignificance.Owner or RequestSignificance.Favored; + public bool IsOwner => sig is RequestSignificance.Owner; + } +} + diff --git a/SysBot.Pokemon/TradeHub/TradeEntry.cs b/SysBot.Pokemon/TradeHub/TradeEntry.cs index 3b589536d..fc92ef47a 100644 --- a/SysBot.Pokemon/TradeHub/TradeEntry.cs +++ b/SysBot.Pokemon/TradeHub/TradeEntry.cs @@ -19,5 +19,5 @@ public bool Equals(ulong uid, PokeRoutineType type = 0) return type == 0 || type == Type; } - public override string ToString() => $"(ID {Trade.ID}) {Username} {UserID:D19} - {Type}"; + public override string ToString() => $"(ID {Trade.Id}) {Username} {UserID:D19} - {Type}"; } diff --git a/SysBot.Pokemon/Util/LoadUtil.cs b/SysBot.Pokemon/Util/LoadUtil.cs index 348320b05..34bb83804 100644 --- a/SysBot.Pokemon/Util/LoadUtil.cs +++ b/SysBot.Pokemon/Util/LoadUtil.cs @@ -1,6 +1,6 @@ -using PKHeX.Core; using System.Collections.Generic; using System.IO; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Util/PokeTradeBotUtil.cs b/SysBot.Pokemon/Util/PokeTradeBotUtil.cs index 09e4a77f3..9713ce3fc 100644 --- a/SysBot.Pokemon/Util/PokeTradeBotUtil.cs +++ b/SysBot.Pokemon/Util/PokeTradeBotUtil.cs @@ -1,7 +1,9 @@ -namespace SysBot.Pokemon; +using System; + +namespace SysBot.Pokemon; public static class PokeTradeBotUtil { - public static readonly byte[] EMPTY_EC = new byte[4]; - public static readonly byte[] EMPTY_SLOT = new byte[344]; + public static readonly ReadOnlyMemory EMPTY_U32 = new byte[4]; + public static readonly ReadOnlyMemory EMPTY_SLOT = new byte[344]; } diff --git a/SysBot.Pokemon/Util/SeedSearchResult.cs b/SysBot.Pokemon/Util/SeedSearchResult.cs index 4b0b1b298..df347fca3 100644 --- a/SysBot.Pokemon/Util/SeedSearchResult.cs +++ b/SysBot.Pokemon/Util/SeedSearchResult.cs @@ -3,19 +3,16 @@ namespace SysBot.Pokemon; -public record SeedSearchResult(Z3SearchResult Type, ulong Seed, int FlawlessIVCount, SeedCheckResults Mode) +public sealed record SeedSearchResult(Z3SearchResult Type, ulong Seed, int FlawlessIVCount, SeedCheckResults Mode) { public static readonly SeedSearchResult None = new(Z3SearchResult.SeedNone, 0, 0, SeedCheckResults.ClosestOnly); - public override string ToString() + public override string ToString() => Type switch { - return Type switch - { - Z3SearchResult.SeedMismatch => $"Seed found, but not an exact match {Seed:X16}", - Z3SearchResult.Success => string.Join(Environment.NewLine, GetLines()), - _ => "The Pokémon is not a raid Pokémon!", - }; - } + Z3SearchResult.SeedMismatch => $"Seed found, but not an exact match {Seed:X16}", + Z3SearchResult.Success => string.Join(Environment.NewLine, GetLines()), + _ => "The Pokémon is not a raid Pokémon!", + }; private IEnumerable GetLines() { @@ -23,7 +20,7 @@ private IEnumerable GetLines() yield return $"IVCount: {FlawlessIVCount}"; yield return "Spreads are listed by flawless IV count."; - SeedSearchUtil.GetShinyFrames(Seed, out int[] frames, out uint[] type, out List IVs, Mode); + SeedSearchUtil.GetShinyFrames(Seed, out var frames, out var type, out var ivs, Mode); for (int i = 0; i < 3 && frames[i] != 0; i++) { @@ -35,7 +32,7 @@ private IEnumerable GetLines() var ivlist = $"{ivcount + 1} - "; for (int j = 0; j < 6; j++) { - ivlist += IVs[i][ivcount, j]; + ivlist += ivs[i][ivcount, j]; if (j < 5) ivlist += "/"; } diff --git a/SysBot.Pokemon/Util/SeedSearchUtil.cs b/SysBot.Pokemon/Util/SeedSearchUtil.cs index fcc9d7172..1b9f935a1 100644 --- a/SysBot.Pokemon/Util/SeedSearchUtil.cs +++ b/SysBot.Pokemon/Util/SeedSearchUtil.cs @@ -1,5 +1,5 @@ -using PKHeX.Core; using System.Collections.Generic; +using PKHeX.Core; namespace SysBot.Pokemon; diff --git a/SysBot.Pokemon/Util/TradeUtil.cs b/SysBot.Pokemon/Util/TradeUtil.cs index cc78e6495..01bdfac71 100644 --- a/SysBot.Pokemon/Util/TradeUtil.cs +++ b/SysBot.Pokemon/Util/TradeUtil.cs @@ -1,15 +1,15 @@ -using SysBot.Base; using System; using System.Collections.Generic; +using SysBot.Base; using static SysBot.Base.SwitchButton; namespace SysBot.Pokemon; public static class TradeUtil { - public static int GetCodeDigit(int code, int c) + public static int GetCodeDigit(int code, int indexOfChar) { - for (int i = 7; i > c; i--) + for (int i = 7; i > indexOfChar; i--) code /= 10; return code % 10; } diff --git a/SysBot.Tests/GenerateTests.cs b/SysBot.Tests/GenerateTests.cs index 101170486..83cae851f 100644 --- a/SysBot.Tests/GenerateTests.cs +++ b/SysBot.Tests/GenerateTests.cs @@ -54,7 +54,7 @@ public void TestAbilityTwitch(string set, int abilNumber) var sav = AutoLegalityWrapper.GetTrainerInfo(); for (int i = 0; i < 10; i++) { - var twitch = set.Replace("\r\n", " ").Replace("\n", " "); + var twitch = set.Replace("\r\n", " ").Replace('\n', ' '); var s = ShowdownUtil.ConvertToShowdown(twitch); var template = s == null ? null : AutoLegalityWrapper.GetTemplate(s); var pk = template == null ? null : sav.GetLegal(template, out _); diff --git a/SysBot.Tests/MiscTests.cs b/SysBot.Tests/MiscTests.cs index 15ecde699..16805b7a1 100644 --- a/SysBot.Tests/MiscTests.cs +++ b/SysBot.Tests/MiscTests.cs @@ -1,8 +1,8 @@ -using FluentAssertions; -using SysBot.Base; -using SysBot.Pokemon; using System.Collections.Generic; using System.Linq; +using FluentAssertions; +using SysBot.Base; +using SysBot.Pokemon; using Xunit; using static SysBot.Base.SwitchButton; diff --git a/SysBot.Tests/QueueTests.cs b/SysBot.Tests/QueueTests.cs index e39a915b5..bd6a8d25a 100644 --- a/SysBot.Tests/QueueTests.cs +++ b/SysBot.Tests/QueueTests.cs @@ -1,10 +1,10 @@ -using FluentAssertions; -using PKHeX.Core; -using SysBot.Pokemon; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; +using PKHeX.Core; +using SysBot.Pokemon; using Xunit; namespace SysBot.Tests; @@ -108,7 +108,15 @@ public override void SoftStop() { } private static TradeEntry GetTestTrade(int tag, bool favor) where T : PKM, new() { - var d3 = new PokeTradeDetail(new T { Species = (ushort)tag }, new PokeTradeTrainerInfo($"{(favor ? "*" : "")}Test {tag}"), new PokeTradeLogNotifier(), PokeTradeType.Specific, tag, favor); + var d3 = new PokeTradeDetail + { + IsFavored = favor, + Code = 0, + TradeData = new T { Species = (ushort)tag }, + Trainer = new PokeTradeTrainerInfo($"{(favor ? "*" : "")}Test {tag}"), + Notifier = new PokeTradeLogNotifier(), + Type = PokeTradeType.Specific, + }; return new TradeEntry(d3, (ulong)tag, PokeRoutineType.LinkTrade, $"Test Trade {tag}"); } diff --git a/SysBot.Tests/SysBot.Tests.csproj b/SysBot.Tests/SysBot.Tests.csproj index 2a2058bc1..c8126f358 100644 --- a/SysBot.Tests/SysBot.Tests.csproj +++ b/SysBot.Tests/SysBot.Tests.csproj @@ -5,8 +5,8 @@ - - + + all From cca5198d228bcf42e1827ea915ea0978550bb3c6 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 01:02:08 -0500 Subject: [PATCH 02/23] Misc reply chain deduplication it doesn't like responding more than twice, so reuse the DeferAsync message with our update once we've thought about it. --- .../Commands/Sudo/RemoteControlModule.cs | 4 +++- .../Commands/Trade/CloneModule.cs | 2 ++ .../Commands/Trade/DumpModule.cs | 2 ++ .../Commands/Trade/SeedCheckModule.cs | 8 ++++++-- .../Commands/Trade/TradeModule.cs | 16 ++++++++-------- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs index 1831ddf98..acc71acf0 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs @@ -148,9 +148,11 @@ private async Task SetStickAsyncImpl(SwitchStick s, short x, short y, ushort? ms await RespondAsync($"{b.Connection.Name} has performed: {s} and will hold the position until changed.").ConfigureAwait(false); return; } + + await DeferAsync().ConfigureAwait(false); await Task.Delay(value).ConfigureAwait(false); await b.Connection.SendAsync(SwitchCommand.ResetStick(s, crlf)).ConfigureAwait(false); - await RespondAsync($"{b.Connection.Name} has performed: {s} and reset the stick position.").ConfigureAwait(false); + await FollowupAsync($"{b.Connection.Name} has performed: {s} and reset the stick position.").ConfigureAwait(false); } private static BotSource? GetBot(string ip) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index 1f3ec0afc..33069fa00 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -17,6 +17,8 @@ private async Task JoinAsync(int? code) if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Clone, out var e), e).ConfigureAwait(false)) return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Clone, PokeTradeType.Clone, Context).ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index 3f86d1387..2b8d99304 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -17,6 +17,8 @@ public async Task DumpAsync( if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Dump, out var error), error).ConfigureAwait(false)) return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Dump, PokeTradeType.Dump, Context).ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index b8c7eee56..592deac51 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -18,6 +18,8 @@ public async Task SeedCheckAsync( if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.SeedCheck, out var e), e).ConfigureAwait(false)) return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed, Context).ConfigureAwait(false); @@ -27,6 +29,8 @@ public async Task SeedCheckAsync( public async Task FindFrameAsync( [Summary(nameof(seed), "The seed to find the next shiny frame from.")] string seed) { + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var s = seed.ToLowerInvariant().AsSpan(); if (s.StartsWith("0x")) s = s[2..]; @@ -35,8 +39,8 @@ public async Task FindFrameAsync( var hub = SysCord.Runner.Hub; var r = new SeedSearchResult(Z3SearchResult.Success, value, -1, hub.Config.SeedCheckSWSH.ResultDisplayMode); var embed = new EmbedBuilder { Color = Color.LighterGrey }; - embed.AddField($"Seed: {value:X16}", r.ToString()); - await RespondAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); + embed.AddField($"Seed: 0x{value:X16}", r.ToString()); + await FollowupAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); } /* diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index f166b2fac..99e9eb861 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -120,7 +120,7 @@ private async Task TradeShowdownAsync(int code, string content, SocketInteractio sb.AppendLine("Species could not be identified. Check your spelling."); var msg = sb.ToString(); - await RespondAsync(msg, ephemeral: true).ConfigureAwait(false); + await FollowupAsync(msg, ephemeral: true).ConfigureAwait(false); return; } @@ -142,7 +142,7 @@ private async Task TradeShowdownAsync(int code, string content, SocketInteractio var imsg = $"Oops! {reason}"; if (result == "Failed") imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; - await RespondAsync(imsg, ephemeral: true).ConfigureAwait(false); + await FollowupAsync(imsg).ConfigureAwait(false); return; } @@ -156,7 +156,7 @@ private async Task TradeShowdownAsync(int code, string content, SocketInteractio Oops! An unexpected problem happened with this Showdown Set: {Format.Code(string.Join('\n', set.GetSetLines()),"yml")} """; - await RespondAsync(msg, ephemeral: true).ConfigureAwait(false); + await FollowupAsync(msg).ConfigureAwait(false); } } @@ -166,7 +166,7 @@ private async Task TradeAttachmentAsync(int code, IAttachment attachment, Socket var pk = GetRequest(att); if (pk == null) { - await RespondAsync("Attachment provided is not compatible with this module!", ephemeral: true).ConfigureAwait(false); + await FollowupAsync("Attachment provided is not compatible with this module!").ConfigureAwait(false); return; } @@ -190,26 +190,26 @@ private async Task AddTradeToQueueAsync(int code, T pk, SocketInteractionContext var la = new LegalityAnalysis(pk); if (!la.Valid) { - await RespondAsync($"{typeof(T).Name} attachment is not legal, and cannot be traded!").ConfigureAwait(false); + await FollowupAsync($"{typeof(T).Name} attachment is not legal, and cannot be traded!").ConfigureAwait(false); return; } var enc = la.EncounterOriginal; if (!pk.CanBeTraded(enc)) { - await RespondAsync("Provided Pokémon content is blocked from trading!").ConfigureAwait(false); + await FollowupAsync("Provided Pokémon content is blocked from trading!").ConfigureAwait(false); return; } var cfg = Info.Hub.Config.Trade; if (cfg.DisallowNonNatives && (enc.Context != pk.Context || pk.GO)) { - await RespondAsync($"{typeof(T).Name} attachment is not native, and cannot be traded!").ConfigureAwait(false); + await FollowupAsync($"{typeof(T).Name} attachment is not native, and cannot be traded!").ConfigureAwait(false); return; } if (cfg.DisallowTracked && pk is IHomeTrack { HasTracker: true }) { - await RespondAsync($"{typeof(T).Name} attachment is tracked by HOME, and cannot be traded!").ConfigureAwait(false); + await FollowupAsync($"{typeof(T).Name} attachment is tracked by HOME, and cannot be traded!").ConfigureAwait(false); return; } From f10495e7f5326715bf7106fdb10ae9080e7832be Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 01:15:36 -0500 Subject: [PATCH 03/23] Hide owner/sudo commands from help Can probably make a custom attribute or something but I guess this reduces clutter. --- .../Commands/General/HelpModule.cs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs index 4b59629ad..dce06efd7 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs @@ -30,18 +30,26 @@ public async Task HelpAsync(string? command = null) var matches = service.SlashCommands.Where(x => x.Name.Equals(command, StringComparison.OrdinalIgnoreCase) || x.Name.Contains(command, StringComparison.OrdinalIgnoreCase)).ToList(); - if (matches.Count == 0) - { - await RespondAsync($"Sorry, I couldn't find a command like **{command}**.", ephemeral: true).ConfigureAwait(false); - return; - } + int added = 0; // Use a different description. builder.Description = $"Here are some commands like **{command}**:"; foreach (var cmd in matches) { + var isOwner = cmd.Module.Attributes.Any(z => z.GetType() == typeof(RequireOwnerAttribute)); + if (isOwner && !CheckSudo(out _)) + continue; + if (cmd.Module.GetType().IsAssignableFrom(typeof(SudoModuleBase)) && !CheckSudo(out _)) + continue; + var parameters = GetParameters(cmd.Parameters); builder.AddField(cmd.Name, $"Summary: {cmd.Description}\nParameters:\n{parameters}"); + added++; + } + if (added == 0) + { + await RespondAsync($"Sorry, I couldn't find a command like **{command}**.", ephemeral: true).ConfigureAwait(false); + return; } } await RespondAsync("Help has arrived!", ephemeral: true, embed: builder.Build()).ConfigureAwait(false); From 0b4eff58ef06f572d1c41a7722b9dd85edfc7432 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 01:53:27 -0500 Subject: [PATCH 04/23] Misc visibility adjustments --- .../Commands/Sudo/SudoModuleBase.cs | 3 ++- .../Commands/Trade/CloneModule.cs | 3 ++- .../Commands/Trade/DumpModule.cs | 3 ++- .../Commands/Trade/QueueModule.cs | 3 ++- .../Commands/Trade/SeedCheckModule.cs | 3 ++- .../Commands/Trade/TradeModule.cs | 6 ++++-- SysBot.Pokemon.Discord/SysCord.cs | 17 +++++------------ 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs index 6758993bd..bedcf4f72 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs @@ -3,7 +3,8 @@ namespace SysBot.Pokemon.Discord; -[RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check +[RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check +[DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [CommandContextType(InteractionContextType.Guild)] // must run these inside a guild, not in DMs (more auditable). [RequireContext(ContextType.Guild)] public abstract class SudoModuleBase : SlashModuleBase; diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index 33069fa00..ec1f4f1c0 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -31,7 +31,8 @@ private async Task JoinAsync(int? code) */ [SlashCommand("clone-list", "Prints the users in the Clone queue.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task GetListAsync() { if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index 2b8d99304..6554ece69 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -31,7 +31,8 @@ public async Task DumpAsync( */ [SlashCommand("dump-list", "Prints the users in the Dump queue.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task GetListAsync() { if (!await RequireAsync(CheckSudo(out var error), error).ConfigureAwait(false)) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs index 9e99e7007..4c34bb017 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs @@ -26,7 +26,8 @@ public Task ClearTradeAsync() */ [SlashCommand("clear-user", "Clears a user from the trade queues.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task ClearTradeUserAsync( [Summary(nameof(user), "The user to clear from the trade queues.")] IUser user) { diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index 592deac51..7d36608cb 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -50,7 +50,8 @@ public async Task FindFrameAsync( */ [SlashCommand("seed-list", "Prints the users in the Seed Check queue.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task GetSeedListAsync() { if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index 99e9eb861..fa08deb87 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -58,7 +58,8 @@ public async Task TradeAsync( */ [SlashCommand("trade-list", "Prints the users in the trade queues.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task GetTradeListAsync() { if (!CheckSudo(out var error)) @@ -79,7 +80,8 @@ public async Task GetTradeListAsync() } [SlashCommand("ban-trade", "Ban an Online ID from trading.")] - [RequireUserPermission(ChannelPermission.BypassSlowmode)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task BanTradeAsync( [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, [Summary(nameof(reason), "The reason for banning the user.")] string reason) diff --git a/SysBot.Pokemon.Discord/SysCord.cs b/SysBot.Pokemon.Discord/SysCord.cs index f7c4ec728..89d955860 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -161,22 +161,15 @@ private async Task HandleInteractionAsync(SocketInteraction interaction) if (!_manager.CanUseCommandUser(context.User.Id)) { - await RespondErrorAsync(command, "You are not permitted to use this command.").ConfigureAwait(false); + await RespondErrorAsync(command, "You are not permitted to use this command.", ephemeral: true).ConfigureAwait(false); return; } - if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) + if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) { - if (Hub.Config.Discord.ReplyCannotUseCommandInChannel) - { - // Visibly reply (so that others can see). - await RespondErrorAsync(command, "You can't use that command here.", ephemeral: false).ConfigureAwait(false); - return; - } - - // Clean up the modal. - await command.DeferAsync(ephemeral: true).ConfigureAwait(false); - await command.DeleteOriginalResponseAsync().ConfigureAwait(false); + // Visibly reply if settings require (so that others can see). + var ephemeral = !Hub.Config.Discord.ReplyCannotUseCommandInChannel; + await RespondErrorAsync(command, "You can't use that command here.", ephemeral: ephemeral).ConfigureAwait(false); return; } From b4c9e6331f1e697de547a6aaa7a6f0ff34dd0d74 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 02:16:42 -0500 Subject: [PATCH 05/23] Legalizer: ephemeral --- .../Commands/Extra/LegalizerModule.cs | 4 ++-- .../Helpers/AutoLegalityExtensionsDiscord.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs index a82ffc325..d550d762a 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs @@ -12,7 +12,7 @@ namespace SysBot.Pokemon.Discord; public async Task LegalizeAsync( [Summary(nameof(file), "The file to legalize.")] IAttachment file) { - await DeferAsync().ConfigureAwait(false); + await DeferAsync(ephemeral: true).ConfigureAwait(false); await Context.ReplyWithLegalizedSetAsync(file).ConfigureAwait(false); } @@ -21,7 +21,7 @@ public async Task ConvertShowdownAsync( [Summary(nameof(content), "The Showdown set to convert.")] string content, [Summary(nameof(generation), "Optional")] byte? generation = null) { - await DeferAsync().ConfigureAwait(false); + await DeferAsync(ephemeral: true).ConfigureAwait(false); if (generation is not { } gen) // assume current format if no generation is specified await Context.ReplyWithLegalizedSetAsync(content).ConfigureAwait(false); else diff --git a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs index 220f251a2..cab4fa9b4 100644 --- a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs +++ b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs @@ -15,7 +15,7 @@ public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) { if (set.Species == 0) { - await context.Interaction.RespondAsync("Oops! I wasn't able to interpret your message! If you intended to convert something, please double check what you're pasting!").ConfigureAwait(false); + await context.Interaction.FollowupAsync("Oops! I wasn't able to interpret your message! If you intended to convert something, please double check what you're pasting!").ConfigureAwait(false); return; } @@ -37,7 +37,7 @@ public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) if (result == "Failed") issue += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pk)}"; - await context.Interaction.RespondAsync(issue).ConfigureAwait(false); + await context.Interaction.FollowupAsync(issue).ConfigureAwait(false); return; } @@ -52,7 +52,7 @@ public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) var formatted = Format.Code(lines, "yml"); var message = $"Oops! An unexpected problem happened with this Showdown Set:\n{formatted}"; // No need for everyone to see their goofy set. - await context.Interaction.RespondAsync(message, ephemeral: true).ConfigureAwait(false); + await context.Interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } @@ -77,7 +77,7 @@ public async Task ReplyWithLegalizedSetAsync(IAttachment attachment) var download = await attachment.DownloadEntityAsync().ConfigureAwait(false); if (!download.Success) { - await context.Interaction.RespondAsync(download.ErrorMessage, ephemeral: true).ConfigureAwait(false); + await context.Interaction.FollowupAsync(download.ErrorMessage, ephemeral: true).ConfigureAwait(false); return; } @@ -85,14 +85,14 @@ public async Task ReplyWithLegalizedSetAsync(IAttachment attachment) var fileName = download.SanitizedFileName; if (new LegalityAnalysis(pk).Valid) { - await context.Interaction.RespondAsync($"{fileName}: Already legal.", ephemeral: true).ConfigureAwait(false); + await context.Interaction.FollowupAsync($"{fileName}: Already legal.", ephemeral: true).ConfigureAwait(false); return; } var legal = pk.LegalizePokemon(); if (!new LegalityAnalysis(legal).Valid) { - await context.Interaction.RespondAsync($"{fileName}: Unable to legalize.").ConfigureAwait(false); + await context.Interaction.FollowupAsync($"{fileName}: Unable to legalize.").ConfigureAwait(false); return; } From b40f443aca22c7c10d1757623caf1ac1692f7c84 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 13:22:50 -0500 Subject: [PATCH 06/23] Continued refinements - Ungroup `legality`; only a single command which is weird usage like `legality legality` - Make Batch edit command info checks ephemeral (less channel spam) - Add utility `transfer` command to convert a pkm attachment from one format to another - Make Owner module accessible anywhere to the owner (such as DMs). - Split Trade command in two, as group. Can potentially make it a modal? - Add `convert` to target a specific version rather than game origin when converting a showdown set. - Extract showdown set formatting logic, consistent yml usage and Language (can be easily changed by owners adjusting the code). --- .../Commands/Extra/BatchEditingModule.cs | 4 +- .../Commands/Extra/LegalityCheckModule.cs | 1 - .../Commands/Extra/LegalizerModule.cs | 29 ++++++-- .../Commands/Management/OwnerModule.cs | 13 +++- .../Commands/Sudo/EchoModule.cs | 1 - .../Commands/Trade/TradeModule.cs | 66 +++++++++++-------- .../Helpers/AutoLegalityExtensionsDiscord.cs | 13 ++-- .../Helpers/ReusableActions.cs | 18 ++++- SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs | 3 +- 9 files changed, 98 insertions(+), 50 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs index c20a85fc4..13939b283 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs @@ -17,9 +17,9 @@ public async Task GetBatchInfo( [Summary(nameof(propertyName), "The name of the property to get info about.")] string propertyName) { if (EntityBatchEditor.Instance.TryGetPropertyType(propertyName, out var result)) - await RespondAsync($"{propertyName}: {result}").ConfigureAwait(false); + await RespondAsync($"{propertyName}: {result}", ephemeral: true).ConfigureAwait(false); else - await RespondAsync($"Unable to find info for {propertyName}.").ConfigureAwait(false); + await RespondAsync($"Unable to find info for {propertyName}.", ephemeral: true).ConfigureAwait(false); } [SlashCommand("validate", "Validates batch editor instructions.")] diff --git a/SysBot.Pokemon.Discord/Commands/Extra/LegalityCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/LegalityCheckModule.cs index eef7323da..10dea42e6 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/LegalityCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/LegalityCheckModule.cs @@ -5,7 +5,6 @@ namespace SysBot.Pokemon.Discord; -[Group("legality", "Legality check commands.")] [RequireContext(ContextType.Guild)] public class LegalityCheckModule : SlashModuleBase { diff --git a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs index d550d762a..ebe2635ff 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs @@ -16,15 +16,36 @@ public async Task LegalizeAsync( await Context.ReplyWithLegalizedSetAsync(file).ConfigureAwait(false); } + [SlashCommand("transfer", "Transfers a PKM to another format.")] + public async Task TransferAsync( + [Summary(nameof(file), "The file to legalize.")] IAttachment file, + [Summary(nameof(type), "The target format type.")] string type) + { + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var download = await file.DownloadEntityAsync().ConfigureAwait(false); + if (!download.Success || download.Data is not { } pk) + { + await FollowupAsync(download.ErrorMessage).ConfigureAwait(false); + return; + } + + var blank = EntityBlank.GetBlank(type).GetType(); + var converted = EntityConverter.ConvertToType(pk, blank, out var result); + if (converted is null) + await FollowupAsync($"Failed to convert your attachment to {type}: {result}").ConfigureAwait(false); + else + await Context.SendFileAsync(converted, $"Successfully converted your attached file to {type}.").ConfigureAwait(false); + } + [SlashCommand("convert", "Converts a Showdown Set to PKM data.")] - public async Task ConvertShowdownAsync( + public async Task ConvertAsync( [Summary(nameof(content), "The Showdown set to convert.")] string content, - [Summary(nameof(generation), "Optional")] byte? generation = null) + [Summary(nameof(version), "Optional: Original Trainer version to obtain the encounter with.")] GameVersion? version = null) { await DeferAsync(ephemeral: true).ConfigureAwait(false); - if (generation is not { } gen) // assume current format if no generation is specified + if (version is null) // assume current format if no version is specified await Context.ReplyWithLegalizedSetAsync(content).ConfigureAwait(false); else - await Context.ReplyWithLegalizedSetAsync(content, gen).ConfigureAwait(false); + await Context.ReplyWithLegalizedSetAsync(content, version.Value).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs index d7911b6f1..847a2e682 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs @@ -9,13 +9,13 @@ namespace SysBot.Pokemon.Discord; [Group("owner", "Commands usable by the bot owner.")] [DefaultMemberPermissions(GuildPermission.Administrator)] // hide these commands from the majority of users; bot Owners must have admin on server to manage. [RequireOwner] -public class OwnerModule : SlashModuleBase +public class OwnerModule : InteractionModuleBase { [SlashCommand("add-sudo", "Adds a user to global sudo.")] [CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)] public async Task AddSudo(IUser user) { - SysCordSettings.Settings.GlobalSudoList.AddIfNew(GetReference(user)); + SysCordSettings.Settings.GlobalSudoList.AddIfNew(GetReference(user.Id, user.GlobalName)); await RespondAsync("Done.").ConfigureAwait(false); } @@ -32,7 +32,7 @@ public async Task RemoveSudo(IUser user) public async Task AddChannel() { var c = Context.Interaction.Channel; - SysCordSettings.Settings.ChannelWhitelist.AddIfNew(GetReference(c)); + SysCordSettings.Settings.ChannelWhitelist.AddIfNew(GetReference(c.Id, c.Name)); await RespondAsync("Done.").ConfigureAwait(false); } @@ -85,4 +85,11 @@ public async Task ExitProgram() await RespondAsync("Shutting down... goodbye! **Bot services are going offline.**").ConfigureAwait(false); Environment.Exit(0); } + + private RemoteControlAccess GetReference(ulong id, string name) => new() + { + ID = id, + Name = name, + Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", + }; } diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs index 6623d1682..e518a2a6d 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs @@ -13,7 +13,6 @@ public class EchoModule : SudoModuleBase { // ReSharper disable NotAccessedPositionalProperty.Local private record EchoChannel(ulong ChannelId, string ChannelName, Action Action); - // ReSharper enable NotAccessedPositionalProperty.Local private static readonly Dictionary Channels = []; diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index fa08deb87..8aba9a232 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using Discord; @@ -8,16 +9,16 @@ namespace SysBot.Pokemon.Discord; +[Group("trade", "Commands for starting a trade session with the bot.")] [RequireContext(ContextType.Guild)] public class TradeModule : SlashModuleBase where T : PKM, new() { private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; - [SlashCommand("trade", "Trade a Pokémon file or a Showdown Set.")] + [SlashCommand("set", "Trade a Showdown Set.")] public async Task TradeAsync( - [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null, - [Summary(nameof(set), "Provide if you want to convert text.")] string? set = null, - [Summary(nameof(file), "Provide if you want to trade a file.")] IAttachment? file = null) + [Summary(nameof(showdown), "Provide the Showdown Set to be traded to your game.")] string showdown, + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) { @@ -25,30 +26,29 @@ public async Task TradeAsync( return; } - if (set is not null && file is not null) - { - await RespondAsync("Provide either a Showdown Set or a Pokémon file, not both.", ephemeral: true).ConfigureAwait(false); - return; - } - - if (set is null && file is null) - { - await RespondAsync("Provide a Showdown Set or attach a Pokémon file.", ephemeral: true).ConfigureAwait(false); - return; - } - // Generating from a set might take longer than 3 seconds for some hosts with slower computers. await DeferAsync(ephemeral: true).ConfigureAwait(false); var tradeCode = code ?? Info.GetRandomTradeCode(); + await TradeShowdownAsync(tradeCode, showdown, Context).ConfigureAwait(false); + } - if (file is not null) + [SlashCommand("file", "Trade a Pokémon file.")] + public async Task TradeFileAsync( + [Summary(nameof(file), "Attach a file to be traded to your game.")] IAttachment file, + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) + { + if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) { - await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); return; } - await TradeShowdownAsync(tradeCode, set!, Context).ConfigureAwait(false); + // Match the set->file trade command, which can take longer than 3 seconds for some hosts with slower computers. + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + var tradeCode = code ?? Info.GetRandomTradeCode(); + await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); } /* @@ -57,7 +57,7 @@ public async Task TradeAsync( * */ - [SlashCommand("trade-list", "Prints the users in the trade queues.")] + [SlashCommand("list", "Prints the users in the trade queues.")] [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task GetTradeListAsync() @@ -79,7 +79,7 @@ public async Task GetTradeListAsync() await RespondAsync("These are the users who are currently waiting:", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); } - [SlashCommand("ban-trade", "Ban an Online ID from trading.")] + [SlashCommand("ban", "Ban an Online ID from trading.")] [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check public async Task BanTradeAsync( @@ -110,13 +110,8 @@ private async Task TradeShowdownAsync(int code, string content, SocketInteractio if (invalidlines.Count != 0) { var localization = BattleTemplateParseErrorLocalization.Get(); - sb.AppendLine("Invalid lines detected:\n```yml"); - foreach (var line in invalidlines) - { - var error = line.Humanize(localization); - sb.AppendLine(error); - } - sb.AppendLine("```"); + sb.AppendLine("Invalid lines detected:"); + AddInvalidLines(invalidlines, localization, sb); } if (set.Species is 0) sb.AppendLine("Species could not be identified. Check your spelling."); @@ -156,12 +151,25 @@ private async Task TradeShowdownAsync(int code, string content, SocketInteractio LogUtil.LogSafe(ex); var msg = $""" Oops! An unexpected problem happened with this Showdown Set: - {Format.Code(string.Join('\n', set.GetSetLines()),"yml")} + {ReusableActions.FormatSetCode(set)} """; await FollowupAsync(msg).ConfigureAwait(false); } } + private static void AddInvalidLines(IReadOnlyList invalidlines, BattleTemplateParseErrorLocalization localization, StringBuilder sb) + { + // Build a string of the invalid lines with their human-readable error messages + // Then format it into a readable code block for Discord + var inner = new StringBuilder(); + foreach (var line in invalidlines) + { + var error = line.Humanize(localization); + inner.AppendLine(error); + } + sb.Append(ReusableActions.FormatSetCode(inner.ToString())); + } + private async Task TradeAttachmentAsync(int code, IAttachment attachment, SocketInteractionContext user) { var att = await attachment.DownloadEntityAsync().ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs index cab4fa9b4..5728eac78 100644 --- a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs +++ b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs @@ -48,28 +48,27 @@ public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) catch (Exception ex) { LogUtil.LogSafe(ex); - var lines = string.Join('\n', set.GetSetLines()); - var formatted = Format.Code(lines, "yml"); + var formatted = ReusableActions.FormatSetCode(set); var message = $"Oops! An unexpected problem happened with this Showdown Set:\n{formatted}"; // No need for everyone to see their goofy set. await context.Interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } - public Task ReplyWithLegalizedSetAsync(string content, byte generation) + public async Task ReplyWithLegalizedSetAsync(string content, GameVersion version) { content = ReusableActions.StripCodeBlock(content); var set = new ShowdownSet(content); - var sav = AutoLegalityWrapper.GetTrainerInfo(generation); - return context.ReplyWithLegalizedSetAsync(sav, set); + var tr = AutoLegalityWrapper.GetTrainerInfo(version); + await context.ReplyWithLegalizedSetAsync(tr, set).ConfigureAwait(false); } public async Task ReplyWithLegalizedSetAsync(string content) where T : PKM, new() { content = ReusableActions.StripCodeBlock(content); var set = new ShowdownSet(content); - var sav = AutoLegalityWrapper.GetTrainerInfo(); - await context.ReplyWithLegalizedSetAsync(sav, set).ConfigureAwait(false); + var tr = AutoLegalityWrapper.GetTrainerInfo(); + await context.ReplyWithLegalizedSetAsync(tr, set).ConfigureAwait(false); } public async Task ReplyWithLegalizedSetAsync(IAttachment attachment) diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index 0a59cb862..ef8676b9d 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -48,7 +48,7 @@ public async Task SendFilePrivatelyAsync(IEnumerable list, string message = extension(PKM pk) { - private FileAttachment ToFileAttachment() + public FileAttachment ToFileAttachment() { Span data = stackalloc byte[pk.SIZE_PARTY]; pk.WriteDecryptedDataParty(data); @@ -68,7 +68,21 @@ public static string GetFormattedShowdownText(PKM pk, LanguageID language = Lang var settings = new BattleTemplateExportSettings(config, language); var showdown = ShowdownParsing.GetShowdownText(pk, settings); - return Format.Code(showdown); + return FormatSetCode(showdown); + } + + // yml looks nicest of all code-languages in Discord code blocks, so we use that instead of plain text. + private const string CodeLanguage = "yml"; + private const LanguageID Language = LanguageID.English; + + public static string FormatSetCode(string set) => Format.Code(set, CodeLanguage); + public static string FormatSetCode(IEnumerable lines) => FormatSetCode(string.Join('\n', lines)); + public static string FormatSetCode(ShowdownSet set, LanguageID language = Language) + { + var config = BattleTemplateConfig.Showdown; + var settings = new BattleTemplateExportSettings(config, language); + var lines = set.GetSetLines(settings); + return FormatSetCode(lines); } /// diff --git a/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs b/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs index 3938ac704..ceaae1ab5 100644 --- a/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs +++ b/SysBot.Pokemon/Helpers/AutoLegalityWrapper.cs @@ -178,7 +178,8 @@ public static bool CanBeTraded(this PKM pk, IEncounterTemplate enc) throw new ArgumentException("Type does not have a recognized trainer fetch.", typeof(T).Name); } - public static ITrainerInfo GetTrainerInfo(byte gen) => TrainerSettings.GetSavedTrainerData((EntityContext)gen); + public static ITrainerInfo GetTrainerInfo(GameVersion version) => TrainerSettings.GetSavedTrainerData(version); + public static ITrainerInfo GetTrainerInfo(EntityContext context) => TrainerSettings.GetSavedTrainerData(context); public static PKM GetLegal(this ITrainerInfo sav, IBattleTemplate set, out string res) { From 8c5c650ec6202e1e8f5234cf6b1e437bd8b639ba Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 18:58:06 -0500 Subject: [PATCH 07/23] Misc tweaks Logging: indicate actual channel count restored Logging: indicate which modules were loaded, along with count of commands/modals/etc. Make trade-set a modal command for better user interactions Modals: correctly instantiate, indicate count in Info command Queue status: ephemeral to reduce clutter Update SysCord.cs --- .../Commands/General/InfoModule.cs | 3 +- .../Commands/Sudo/EchoModule.cs | 15 ++- .../Commands/Sudo/EncounterModule.cs | 3 +- .../Commands/Sudo/LogModule.cs | 15 ++- .../Commands/Sudo/TradeStartModule.cs | 17 +++- .../Commands/Trade/QueueModule.cs | 4 +- .../Commands/Trade/TradeModule.cs | 57 +++++++++++- SysBot.Pokemon.Discord/SysCord.cs | 91 +++++++++++-------- SysBot.Pokemon.Discord/SysCordSettings.cs | 12 ++- 9 files changed, 154 insertions(+), 63 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index 51b17549b..43c97eba5 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -42,7 +42,8 @@ public async Task InfoAsync() - {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Base", false)} - {Format.Bold("Core Version")}: {GetVersionInfo("PKHeX.Core")} - {Format.Bold("AutoLegality Version")}: {GetVersionInfo("PKHeX.Core.AutoMod")} -- {Format.Bold("Command Count")}: {SysCordSettings.CommandsRegistered} @ {SysCordSettings.CommandsRegisteredTime:yyyy.MM.dd-hh:mm:ss} UTC +- {Format.Bold("Command Count")}: {SysCordSettings.RegisteredCommands} @ {SysCordSettings.RegisteredTime:yyyy.MM.dd-hh:mm:ss} UTC +- {Format.Bold("Modal Count")}: {SysCordSettings.RegisteredModals} """ ); builder.AddField("Stats", diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs index e518a2a6d..4f1545316 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs @@ -18,13 +18,20 @@ private record EchoChannel(ulong ChannelId, string ChannelName, Action A public static void RestoreChannels(DiscordSocketClient discord, DiscordSettings cfg) { - foreach (var ch in cfg.EchoChannels) + int count = 0; + foreach (var channelAccess in cfg.EchoChannels) { - if (discord.GetChannel(ch.ID) is ISocketMessageChannel channel) - AddEchoChannel(channel, ch.ID); + if (discord.GetChannel(channelAccess.ID) is not ISocketMessageChannel channel) + { + LogUtil.LogInfo($"Failed to add echoes to {channelAccess.Name}."); + continue; + } + + AddEchoChannel(channel, channelAccess.ID); + count++; } - EchoUtil.Echo("Added echo notification to Discord channel(s) on Bot startup."); + EchoUtil.Echo($"Added echo notification to {count} Discord channel(s) on Bot startup."); } [SlashCommand("here", "Makes the bot echo special messages to this channel.")] diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs index dc9098de8..a27455302 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs @@ -9,7 +9,8 @@ namespace SysBot.Pokemon.Discord; public class EncounterModule : SudoModuleBase where T : PKM, new() { [SlashCommand("toss", "Makes waiting bots continue operation.")] - public async Task TossAsync(string name = "") + public async Task TossAsync( + [Summary(nameof(name), "Bot label to match. Leave blank to toss for all.")] string name = "") { if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) return; diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs index 8d98f93c3..991649983 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs @@ -13,13 +13,20 @@ public class LogModule : SudoModuleBase private static readonly Dictionary Channels=[]; public static void RestoreLogging(DiscordSocketClient discord, DiscordSettings settings) { - foreach (var controlAccess in settings.LoggingChannels) + int count = 0; + foreach (var channelAccess in settings.LoggingChannels) { - if (discord.GetChannel(controlAccess.ID) is ISocketMessageChannel c) - AddLogChannel(c, controlAccess.ID); + if (discord.GetChannel(channelAccess.ID) is not ISocketMessageChannel channel) + { + LogUtil.LogInfo($"Failed to add logging to {channelAccess.Name}."); + continue; + } + + AddLogChannel(channel, channelAccess.ID); + count++; } - LogUtil.LogInfo("Added logging to Discord channel(s) on Bot startup."); + LogUtil.LogInfo($"Added logging to {count} Discord channel(s) on Bot startup."); } [SlashCommand("here", "Makes the bot log to this channel.")] diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs index 70cc7f07e..401a75a26 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs @@ -24,15 +24,22 @@ private static void Remove(TradeStartAction e) SysCord.Runner.Hub.Queues.Forwarders.Remove(e.Messager); } - public static void RestoreTradeStarting(DiscordSocketClient discord) + public static void RestoreTradeStarting(DiscordSocketClient discord, DiscordSettings settings) { - foreach (var channelAccess in SysCordSettings.Settings.TradeStartingChannels) + int count = 0; + foreach (var channelAccess in settings.TradeStartingChannels) { - if (discord.GetChannel(channelAccess.ID) is ISocketMessageChannel channel) - AddLogChannel(channel, channelAccess.ID); + if (discord.GetChannel(channelAccess.ID) is not ISocketMessageChannel channel) + { + LogUtil.LogInfo($"Failed to add logging to {channelAccess.Name}."); + continue; + } + + AddLogChannel(channel, channelAccess.ID); + count++; } - LogUtil.LogInfo("Added Trade Start Notification to Discord channel(s) on Bot startup."); + LogUtil.LogInfo($"Added Trade Start Notification to {count} Discord channel(s) on Bot startup."); } [SlashCommand("here", "Makes the bot log trade starts to this channel.")] diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs index 4c34bb017..1735cb4c0 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs @@ -13,11 +13,11 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("status", "Checks the user's position in the queue.")] public Task GetTradePositionAsync() - => RespondAsync(Context.User.Mention + " - " + Info.GetPositionString(Context.User.Id)); + => RespondAsync($"{Context.User.Mention} - {Info.GetPositionString(Context.User.Id)}", ephemeral: true); [SlashCommand("clear", "Clears yourself from the trade queues.")] public Task ClearTradeAsync() - => RespondAsync(GetClearTradeMessage(Info.ClearTrade(Context.User.Id))); + => RespondAsync(GetClearTradeMessage(Info.ClearTrade(Context.User.Id)), ephemeral: true); /* * diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index 8aba9a232..8700ef602 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -14,22 +14,69 @@ namespace SysBot.Pokemon.Discord; public class TradeModule : SlashModuleBase where T : PKM, new() { private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + private const string TradeModalId = "trade-set"; [SlashCommand("set", "Trade a Showdown Set.")] - public async Task TradeAsync( - [Summary(nameof(showdown), "Provide the Showdown Set to be traded to your game.")] string showdown, - [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) + public async Task TradeSetAsync() + { + if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) + { + await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + return; + } + + await RespondWithModalAsync(TradeModalId).ConfigureAwait(false); + } + + public class TradeSetModal : IModal + { + public string Title => "Trade Showdown Set"; + + [InputLabel("Showdown Set")] + [ModalTextInput("showdown", TextInputStyle.Paragraph, placeholder: "Paste your Showdown set here...")] + public string Showdown { get; set; } = string.Empty; + + [RequiredInput(false)] + [InputLabel("Trade Code (optional)")] + [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] + public string? Code { get; set; } + } + + [ModalInteraction(TradeModalId)] + public async Task TradeSetModalAsync(TradeSetModal modal) { + // Re-check if the queue closed in the time between opening the modal and entering the info. if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) { await RespondAsync(error, ephemeral: true).ConfigureAwait(false); return; } - // Generating from a set might take longer than 3 seconds for some hosts with slower computers. + // Sanity check their inputs. + var code = modal.Code; + var showdown = modal.Showdown; + + if (!string.IsNullOrWhiteSpace(code)) + { + if (!int.TryParse(code, out var parsed)) + { + await RespondAsync("The trade code must be a valid integer.", ephemeral: true).ConfigureAwait(false); + return; + } + // Check if it is within the valid range for trade codes (0-99999999) + if (parsed is < 0 or > 99999999) + { + await RespondAsync("The trade code must be between 0 and 99999999.", ephemeral: true).ConfigureAwait(false); + return; + } + } + + // Convert, then join the queue. await DeferAsync(ephemeral: true).ConfigureAwait(false); + var tradeCode = string.IsNullOrWhiteSpace(code) + ? Info.GetRandomTradeCode() + : int.Parse(code); // already validated above, so safe to parse - var tradeCode = code ?? Info.GetRandomTradeCode(); await TradeShowdownAsync(tradeCode, showdown, Context).ConfigureAwait(false); } diff --git a/SysBot.Pokemon.Discord/SysCord.cs b/SysBot.Pokemon.Discord/SysCord.cs index 89d955860..f5f55583a 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -110,13 +110,13 @@ public async Task InitCommands() .ToList(); var assembly = Assembly.GetExecutingAssembly(); - await LoadSlashCommandsFromAssembly(assembly, blacklist).ConfigureAwait(false); + await LoadModulesFromAssembly(assembly, blacklist).ConfigureAwait(false); _client.Ready += LoadCommandsAndChannels; _client.InteractionCreated += HandleInteractionAsync; } - private async Task LoadSlashCommandsFromAssembly(Assembly assembly, List blacklist) + private async Task LoadModulesFromAssembly(Assembly assembly, IReadOnlyList blacklist) { var moduleTypes = assembly.DefinedTypes .Where(z => z is { IsAbstract: false, IsGenericTypeDefinition: false } && typeof(InteractionModuleBase).IsAssignableFrom(z.AsType())) @@ -129,16 +129,21 @@ private async Task LoadSlashCommandsFromAssembly(Assembly assembly, List foreach (var module in types) { - if (!IsBlacklisted(module, blacklist)) - await _interactions.AddModuleAsync(module, _services).ConfigureAwait(false); + var name = GetModuleName(module.Name); + if (IsBlacklisted(name, blacklist)) + continue; + + var registered = await _interactions.AddModuleAsync(module, _services).ConfigureAwait(false); + LogUtil.LogInfo( + $"Loaded module {name}: " + + $"slash={registered?.SlashCommands.Count ?? -1}, " + + $"modal={registered?.ModalCommands.Count ?? -1}, " + + $"component={registered?.ComponentCommands.Count ?? -1}"); } } - private static bool IsBlacklisted(Type module, List blacklist) - { - var name = GetModuleName(module.Name); - return blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase)); - } + private static bool IsBlacklisted(string name, IReadOnlyList blacklist) + => blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase)); private static string GetModuleName(string name) { @@ -152,35 +157,44 @@ private static string GetModuleName(string name) private async Task HandleInteractionAsync(SocketInteraction interaction) { - // Only application commands are relevant here. Keeping this check means - // future component/modal interactions can be handled independently. - if (interaction is not SocketSlashCommand command) - return; - - var context = new SocketInteractionContext(_client, command); + var context = new SocketInteractionContext(_client, interaction); if (!_manager.CanUseCommandUser(context.User.Id)) { - await RespondErrorAsync(command, "You are not permitted to use this command.", ephemeral: true).ConfigureAwait(false); + await RespondErrorAsync(interaction, "You are not permitted to use this command.", ephemeral: true).ConfigureAwait(false); return; } - if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) + if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) { // Visibly reply if settings require (so that others can see). var ephemeral = !Hub.Config.Discord.ReplyCannotUseCommandInChannel; - await RespondErrorAsync(command, "You can't use that command here.", ephemeral: ephemeral).ConfigureAwait(false); + await RespondErrorAsync(interaction, "You can't use that here.", ephemeral: ephemeral).ConfigureAwait(false); return; } - var commandName = command.Data.Name; - var location = context.Interaction.IsDMInteraction ? "Direct Messages" : context.Guild?.Name ?? "Unknown Guild"; - var status = $"Executing command from {location}#{context.Interaction.Channel?.Name ?? $"Unknown Channel: {channel}"}:@{context.User.Username}. Command: {commandName}"; - await Log(GetLog(LogSeverity.Info, status)).ConfigureAwait(false); + await LogInteractionStart(context, channel).ConfigureAwait(false); var result = await _interactions.ExecuteCommandAsync(context, _services).ConfigureAwait(false); - if (!result.IsSuccess && !command.HasResponded) - await RespondErrorAsync(command, result.ErrorReason).ConfigureAwait(false); + if (!result.IsSuccess && !interaction.HasResponded) + await RespondErrorAsync(interaction, result.ErrorReason).ConfigureAwait(false); + } + + private static async Task LogInteractionStart(SocketInteractionContext context, ulong channelId) + { + var interaction = context.Interaction; + var (type, identity) = interaction switch + { + SocketSlashCommand cmd => ("slash command", $"Command: {cmd.CommandName}"), + SocketModal modal => ("modal", $"Modal: {modal.Id}"), + SocketMessageComponent c => ("component", $"Component: {c.Id}"), + _ => (interaction.Type.ToString(), "Unknown"), + }; + var channel = interaction.Channel?.Name ?? $"Unknown Channel: {channelId}"; + var location = interaction.IsDMInteraction ? "Direct Messages" : context.Guild?.Name ?? "Unknown Guild"; + var status = $"Executing {type} from {location}:{channel}:@{context.User.Username}. {identity}"; + + await Log(GetLog(LogSeverity.Info, status)).ConfigureAwait(false); } private static Task RespondErrorAsync(SocketInteraction interaction, string message, bool ephemeral = true) => interaction.HasResponded @@ -248,38 +262,37 @@ private string ComputeSlashCommandHash() private async Task LoadCommandsAndChannels() { + var cfg = Hub.Config.Discord; if (!CommandsRegistered) - await RegisterSlashCommands().ConfigureAwait(false); + await RegisterSlashCommands(cfg).ConfigureAwait(false); if (MessageChannelsLoaded) return; // Restore Echoes - EchoModule.RestoreChannels(_client, Hub.Config.Discord); + EchoModule.RestoreChannels(_client, cfg); // Restore Logging - LogModule.RestoreLogging(_client, Hub.Config.Discord); - TradeStartModule.RestoreTradeStarting(_client); + LogModule.RestoreLogging(_client, cfg); + TradeStartModule.RestoreTradeStarting(_client, cfg); // Don't let it load more than once in case of Discord hiccups. const string status = "Logging and Echo channels loaded!"; await Log(GetLog(LogSeverity.Info, status)).ConfigureAwait(false); MessageChannelsLoaded = true; - var game = Hub.Config.Discord.BotGameStatus; + var game = cfg.BotGameStatus; if (!string.IsNullOrWhiteSpace(game)) await _client.SetGameAsync(game).ConfigureAwait(false); } - private async Task RegisterSlashCommands() + private async Task RegisterSlashCommands(DiscordSettings cfg) { - var cfg = SysCordSettings.HubConfig.Discord; var hash = ComputeSlashCommandHash(); if (hash == cfg.SlashCommandHash) { LogUtil.LogInfo("Skipped registering commands -- no signature changes."); - var count = _interactions.SlashCommands.Count; - SysCordSettings.SetCommandsRegistered(count); + UpdateRegisteredCounts(); return; } @@ -290,11 +303,10 @@ private async Task RegisterSlashCommands() await _interactions.RegisterCommandsGloballyAsync(deleteMissing: true).ConfigureAwait(false); CommandsRegistered = true; - var count = _interactions.SlashCommands.Count; - SysCordSettings.SetCommandsRegistered(count); + UpdateRegisteredCounts(); cfg.SlashCommandHash = hash; - var message = $"Registered {count} slash commands. Hash: {hash}"; + var message = $"Submitted registered interactions. Hash: {hash}"; LogUtil.LogInfo(message); } catch (Exception ex) @@ -302,4 +314,11 @@ private async Task RegisterSlashCommands() LogUtil.LogSafe(ex); } } + + private void UpdateRegisteredCounts() + { + var commands = _interactions.SlashCommands.Count; + var modals = _interactions.ModalCommands.Count; + SysCordSettings.SetCommandsRegistered(commands, modals); + } } diff --git a/SysBot.Pokemon.Discord/SysCordSettings.cs b/SysBot.Pokemon.Discord/SysCordSettings.cs index 09505e051..ba7ba8072 100644 --- a/SysBot.Pokemon.Discord/SysCordSettings.cs +++ b/SysBot.Pokemon.Discord/SysCordSettings.cs @@ -8,12 +8,14 @@ public static class SysCordSettings public static DiscordSettings Settings => Manager.Config; public static PokeTradeHubConfig HubConfig { get; internal set; } = null!; - public static int CommandsRegistered { get; private set; } - public static DateTime CommandsRegisteredTime { get; private set; } + public static int RegisteredCommands { get; private set; } + public static int RegisteredModals { get; private set; } + public static DateTime RegisteredTime { get; private set; } - public static void SetCommandsRegistered(int count) + public static void SetCommandsRegistered(int countCommand, int countModal) { - CommandsRegistered = count; - CommandsRegisteredTime = DateTime.UtcNow; + RegisteredCommands = countCommand; + RegisteredModals = countModal; + RegisteredTime = DateTime.UtcNow; } } From 842aa0c95003130a352d71432c672b44457d0f36 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 20:29:05 -0500 Subject: [PATCH 08/23] Fix modal linking Was in group, needed to be treated as top level. --- SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index 8700ef602..00d796698 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -42,7 +42,7 @@ public class TradeSetModal : IModal public string? Code { get; set; } } - [ModalInteraction(TradeModalId)] + [ModalInteraction(TradeModalId, true)] public async Task TradeSetModalAsync(TradeSetModal modal) { // Re-check if the queue closed in the time between opening the modal and entering the info. From 267a418bbd77182dc0e299b4cc1f778359648b4f Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 22:07:13 -0500 Subject: [PATCH 09/23] Reintroduce precondition attributes Screen command usage thru precondition checker before showing in Help. Extract the methods to make it a little cleaner. Remove unnecessary manual precondition checking. --- SysBot.Base/Util/Logging/LogUtil.cs | 6 +- .../Attributes/RequireQueueRoleAttribute.cs | 40 ++++++++ .../Attributes/RequireRoleAccessAttribute.cs | 36 +++++++ .../Attributes/RequireSudoAttribute.cs | 32 +++++++ .../Attributes/RequireTeamOrOwnerAttribute.cs | 36 +++++++ .../Commands/General/HelpModule.cs | 90 ++++++++++++------ .../Commands/Management/OwnerModule.cs | 5 +- .../Commands/SlashModuleBase.cs | 95 +------------------ .../Commands/Sudo/BotModule.cs | 18 ---- .../Commands/Sudo/EchoModule.cs | 12 --- .../Commands/Sudo/EncounterModule.cs | 3 - .../Commands/Sudo/LogModule.cs | 12 --- .../Commands/Sudo/RemoteControlModule.cs | 29 +----- .../Commands/Sudo/SudoModule.cs | 36 ------- .../Commands/Sudo/SudoModuleBase.cs | 2 +- .../Commands/Sudo/TradeStartModule.cs | 12 --- .../Commands/Trade/CloneModule.cs | 9 +- .../Commands/Trade/DumpModule.cs | 9 +- .../Commands/Trade/QueueModule.cs | 23 ++--- .../Commands/Trade/SeedCheckModule.cs | 9 +- .../Commands/Trade/TradeModule.cs | 39 +++----- .../Helpers/ChannelLogger.cs | 4 +- .../Helpers/DiscordTradeNotifier.cs | 7 +- .../Helpers/ReusableActions.cs | 11 +++ SysBot.Pokemon.Discord/SysCord.cs | 17 +--- SysBot.Pokemon.Discord/SysCordSettings.cs | 1 + 26 files changed, 266 insertions(+), 327 deletions(-) create mode 100644 SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs diff --git a/SysBot.Base/Util/Logging/LogUtil.cs b/SysBot.Base/Util/Logging/LogUtil.cs index 2e2e241f9..d2fb58f0e 100644 --- a/SysBot.Base/Util/Logging/LogUtil.cs +++ b/SysBot.Base/Util/Logging/LogUtil.cs @@ -1,11 +1,11 @@ -using NLog; -using NLog.Config; -using NLog.Targets; using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; using System.Text; +using NLog; +using NLog.Config; +using NLog.Targets; namespace SysBot.Base; diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs new file mode 100644 index 000000000..362308a2c --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord.Commands; +using Discord.WebSocket; + +namespace SysBot.Pokemon.Discord; + +/// +/// Same as with extra consideration for bots accepting Queue requests. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireQueueRoleAttribute(PokeRoutineType type) : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var mgr = SysCordSettings.Manager; + if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + // Check if this user is a Guild User, which is the only context where roles exist + if (context.User is not SocketGuildUser gUser) + return Task.FromResult(PreconditionResult.FromError("You must be sending the message from a guild to run this command.")); + + var roles = gUser.Roles; + if (mgr.CanUseSudo(roles.Select(z => z.Name))) + return Task.FromResult(PreconditionResult.FromSuccess()); + + // Don't bother checking Owner/Team. + + if (!mgr.GetHasRoleAccess(type, roles.Select(z => z.Name))) + return Task.FromResult(PreconditionResult.FromError("You do not have the required role to run this command.")); + + bool canQueue = SysCordSettings.HubConfig.Queues.CanQueue; + if (!canQueue) + return Task.FromResult(PreconditionResult.FromError("Sorry, I am not currently accepting queue requests!")); + + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs new file mode 100644 index 000000000..a6c4904b0 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord.Commands; +using Discord.WebSocket; + +namespace SysBot.Pokemon.Discord; + +/// +/// Requires an assigned role in order to accept commands. Can be used by sudo users if satisfied. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireRoleAccessAttribute(PokeRoutineType type) : PreconditionAttribute +{ + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var mgr = SysCordSettings.Manager; + if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + // Check if this user is a Guild User, which is the only context where roles exist + if (context.User is not SocketGuildUser gUser) + return Task.FromResult(PreconditionResult.FromError("You must be sending the message from a guild to run this command.")); + + var roles = gUser.Roles; + if (mgr.CanUseSudo(roles.Select(z => z.Name))) + return Task.FromResult(PreconditionResult.FromSuccess()); + + // Don't bother checking Owner/Team. + + if (!mgr.GetHasRoleAccess(type, roles.Select(z => z.Name))) + return Task.FromResult(PreconditionResult.FromError("You do not have the required role to run this command.")); + + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs new file mode 100644 index 000000000..7a0309a11 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord.Commands; +using Discord.WebSocket; + +namespace SysBot.Pokemon.Discord; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireSudoAttribute : PreconditionAttribute +{ + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var mgr = SysCordSettings.Manager; + if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) + return PreconditionResult.FromSuccess(); + + // Check if this user is a Guild User, which is the only context where roles exist + if (context.User is not SocketGuildUser gUser) + return PreconditionResult.FromError("You must be in a guild to run this command."); + + if (mgr.CanUseSudo(gUser.Roles.Select(z => z.Name))) + return PreconditionResult.FromSuccess(); + + // Fallback: check if it is the owner or a team member. + if (await RequireTeamOrOwnerAttribute.IsTeamOrOwner(context).ConfigureAwait(false)) + PreconditionResult.FromSuccess(); + + // Since it wasn't, fail + return PreconditionResult.FromError("You are not permitted to run this command."); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs new file mode 100644 index 000000000..9a342c1a8 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.Commands; + +namespace SysBot.Pokemon.Discord; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireTeamOrOwnerAttribute : PreconditionAttribute +{ + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (await IsTeamOrOwner(context).ConfigureAwait(false)) + PreconditionResult.FromSuccess(); + + return PreconditionResult.FromError("You are not permitted to run this command."); + } + + public static async Task IsTeamOrOwner(ICommandContext context) + { + // Get application info from the client + var appInfo = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + + // Owner always gets permission. + if (context.User.Id == appInfo.Owner.Id) + return true; + + // Check if the bot is owned by a team; if so, any marked as Owner are permitted. + if (appInfo.Team is { } team && team.TeamMembers.Any(m => m.Role == TeamRole.Owner)) + return true; + + // Return a silent failure if unauthorized (keeps help menus clean). + return false; + } +} diff --git a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs index dce06efd7..d83129af1 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs @@ -11,50 +11,80 @@ namespace SysBot.Pokemon.Discord; public class HelpModule(InteractionService service) : SlashModuleBase { [SlashCommand("help", "Lists available slash commands.")] - public async Task HelpAsync(string? command = null) + public async Task HelpAsync( + [Summary(nameof(commandName), "Command name to get help for. Leave blank to fetch all available.")] string? commandName = null) { var builder = new EmbedBuilder { Color = Color.Blue }; - if (string.IsNullOrWhiteSpace(command)) + var provider = SysCordSettings.ServiceProvider; + if (string.IsNullOrWhiteSpace(commandName)) { - builder.Description = "These are the commands you can use:"; - foreach (var group in service.SlashCommands.GroupBy(x => x.Module.Name)) - { - var names = group.Select(x => x.Name).Distinct().Order(); - var value = string.Join('\n', names); - if (!string.IsNullOrWhiteSpace(value)) - builder.AddField(group.Key.Replace("Module", string.Empty).Replace("`1", string.Empty), value); - } + // There will be at least one command always (`help` was just used!). + await BuildCommandsAll(builder, provider).ConfigureAwait(false); } else { - var matches = service.SlashCommands.Where(x => - x.Name.Equals(command, StringComparison.OrdinalIgnoreCase) || - x.Name.Contains(command, StringComparison.OrdinalIgnoreCase)).ToList(); - - int added = 0; - // Use a different description. - builder.Description = $"Here are some commands like **{command}**:"; - foreach (var cmd in matches) - { - var isOwner = cmd.Module.Attributes.Any(z => z.GetType() == typeof(RequireOwnerAttribute)); - if (isOwner && !CheckSudo(out _)) - continue; - if (cmd.Module.GetType().IsAssignableFrom(typeof(SudoModuleBase)) && !CheckSudo(out _)) - continue; - - var parameters = GetParameters(cmd.Parameters); - builder.AddField(cmd.Name, $"Summary: {cmd.Description}\nParameters:\n{parameters}"); - added++; - } + var added = await BuildCommandsMatching(builder, provider, commandName).ConfigureAwait(false); if (added == 0) { - await RespondAsync($"Sorry, I couldn't find a command like **{command}**.", ephemeral: true).ConfigureAwait(false); + var message = $"Sorry, I couldn't find a command like {Format.Bold(commandName)}."; + await RespondAsync(message, ephemeral: true).ConfigureAwait(false); return; } + // Use a different description. + builder.Description = $"Here are some commands like {Format.Bold(commandName)}:"; } await RespondAsync("Help has arrived!", ephemeral: true, embed: builder.Build()).ConfigureAwait(false); } + private async Task BuildCommandsMatching(EmbedBuilder builder, IServiceProvider provider, string commandName) + { + var matches = service.SlashCommands.Where(x => + x.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase) || + x.Name.Contains(commandName, StringComparison.OrdinalIgnoreCase)).ToList(); + + int added = 0; + foreach (var cmd in matches) + { + var check = await cmd.CheckPreconditionsAsync(Context, provider).ConfigureAwait(false); + if (!check.IsSuccess) + continue; + + var parameters = GetParameters(cmd.Parameters); + builder.AddField(cmd.Name, $"Summary: {cmd.Description}\nParameters:\n{parameters}"); + added++; + } + + return added; + } + + private async Task BuildCommandsAll(EmbedBuilder builder, IServiceProvider provider) + { + builder.Description = "These are the commands you can use:"; + var list = GetAvailableCommands(service.SlashCommands, Context, provider); + var grouped = list.GroupBy(x => x.Module.Name).ConfigureAwait(false); + + await foreach (var group in grouped.ConfigureAwait(false)) + { + var names = group.Select(x => x.Name).Distinct().Order(); + var value = string.Join('\n', names); + if (value.Length == 0) + continue; // Shouldn't happen, but just in case. + if (!string.IsNullOrWhiteSpace(value)) + builder.AddField(ReusableActions.GetModuleName(group.Key), value); + } + } + + private static async IAsyncEnumerable GetAvailableCommands(IEnumerable possible, + SocketInteractionContext context, IServiceProvider provider) + { + foreach (var cmd in possible) + { + var result = await cmd.CheckPreconditionsAsync(context, provider).ConfigureAwait(false); + if (result.IsSuccess) + yield return cmd; + } + } + private static string GetParameters(IReadOnlyList para) { if (para.Count == 0) diff --git a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs index 847a2e682..44d8f4746 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs @@ -8,7 +8,7 @@ namespace SysBot.Pokemon.Discord; [Group("owner", "Commands usable by the bot owner.")] [DefaultMemberPermissions(GuildPermission.Administrator)] // hide these commands from the majority of users; bot Owners must have admin on server to manage. -[RequireOwner] +[RequireTeamOrOwner] public class OwnerModule : InteractionModuleBase { [SlashCommand("add-sudo", "Adds a user to global sudo.")] @@ -82,7 +82,8 @@ public async Task LeaveAll() [SlashCommand("shutdown", "Causes the entire process to end itself.")] public async Task ExitProgram() { - await RespondAsync("Shutting down... goodbye! **Bot services are going offline.**").ConfigureAwait(false); + var emphasis = Format.Bold("Bot services are going offline."); + await RespondAsync($"Shutting down... goodbye! {emphasis}").ConfigureAwait(false); Environment.Exit(0); } diff --git a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs index ea895a329..a5e0c9c14 100644 --- a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs +++ b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs @@ -1,108 +1,20 @@ using System; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Threading.Tasks; using Discord; using Discord.Interactions; using Discord.WebSocket; namespace SysBot.Pokemon.Discord; +/// +/// Base implementation providing +/// [DefaultMemberPermissions(GuildPermission.SendMessages)] [RequireBotPermission(GuildPermission.SendMessages)] public abstract class SlashModuleBase : InteractionModuleBase { private static DiscordManager Manager => SysCordSettings.Manager; - protected bool CheckSudo([NotNullWhen(false)] out string? error) - { - if (Manager.Config.AllowGlobalSudo && Manager.CanUseSudo(Context.User.Id)) - { - error = null; - return true; - } - - if (Context.User is not SocketGuildUser guildUser) - { - error = "You must be in a guild to run this command."; - return false; - } - - if (Manager.CanUseSudo(guildUser.Roles.Select(z => z.Name))) - { - error = null; - return true; - } - - error = "You are not permitted to run this command."; - return false; - } - - protected bool CheckRoleAccess(PokeRoutineType type, [NotNullWhen(false)] out string? error) - { - if (Context.User is not SocketGuildUser guildUser) - { - error = "You must be in a guild to run this command."; - return false; - } - - if (Manager.GetHasRoleAccess(type, guildUser.Roles.Select(z => z.Name))) - { - error = null; - return true; - } - - error = "You do not have the required role to run this command."; - return false; - } - - protected bool CheckQueueAccess(PokeRoutineType type, [NotNullWhen(false)] out string? error) - { - if (Manager.Config.AllowGlobalSudo && Manager.CanUseSudo(Context.User.Id)) - { - error = null; - return true; - } - - if (Context.User is not SocketGuildUser guildUser) - { - error = "You must be in a guild to run this command."; - return false; - } - - var roles = guildUser.Roles.Select(z => z.Name).ToArray(); - if (Manager.CanUseSudo(roles)) - { - error = null; - return true; - } - - if (!SysCordSettings.HubConfig.Queues.CanQueue) - { - error = "Sorry, I am not currently accepting queue requests!"; - return false; - } - - if (!Manager.GetHasRoleAccess(type, roles)) - { - error = "You do not have the required role to run this command."; - return false; - } - - error = null; - return true; - } - - protected async Task RequireAsync(bool allowed, string? error) - { - if (allowed) - return true; - - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); - return false; - } - - // ReSharper disable once MemberCanBeMadeStatic.Global protected RequestSignificance GetSignificance(SocketUser user) { // Check user ID. @@ -119,6 +31,7 @@ protected RequestSignificance GetSignificance(SocketUser user) : RequestSignificance.None; } + // Used by Sudo commands. protected RemoteControlAccess GetReference(IChannel channel) => GetReference(channel.Id, channel.Name); protected RemoteControlAccess GetReference(IUser user) => GetReference(user.Id, user.Username); protected RemoteControlAccess GetReference(ulong id, string name = "Manual") => new() diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs index 4453cddf2..b6d6d9167 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs @@ -13,9 +13,6 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("status", "Gets the status of the bots.")] public async Task GetStatusAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var sb = new StringBuilder(); foreach (var bot in SysCord.Runner.Bots) { @@ -29,9 +26,6 @@ public async Task GetStatusAsync() [SlashCommand("start", "Starts a bot by IP address/port.")] public async Task StartBotAsync(string ip) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -46,9 +40,6 @@ public async Task StartBotAsync(string ip) [SlashCommand("stop", "Stops a bot by IP address/port.")] public async Task StopBotAsync(string ip) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -63,9 +54,6 @@ public async Task StopBotAsync(string ip) [SlashCommand("idle", "Commands a bot to Idle by IP address/port.")] public async Task IdleBotAsync(string ip) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -80,9 +68,6 @@ public async Task IdleBotAsync(string ip) [SlashCommand("change", "Changes the routine of a bot.")] public async Task ChangeTaskAsync(string ip, PokeRoutineType task) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -97,9 +82,6 @@ public async Task ChangeTaskAsync(string ip, PokeRoutineType task) [SlashCommand("restart", "Restarts bots by comma-separated IP addresses.")] public async Task RestartBotAsync(string ipAddressesCommaSeparated) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var messages = new List(); foreach (var ip in ipAddressesCommaSeparated.Split(',')) { diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs index 4f1545316..25325a19b 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs @@ -37,9 +37,6 @@ public static void RestoreChannels(DiscordSocketClient discord, DiscordSettings [SlashCommand("here", "Makes the bot echo special messages to this channel.")] public async Task AddEchoAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - if (Context.Interaction.Channel is not { } channel) { await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); @@ -72,18 +69,12 @@ private static void AddEchoChannel(ISocketMessageChannel channel, ulong channelI [SlashCommand("info", "Dumps the Echo settings.")] public async Task DumpEchoInfoAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); } [SlashCommand("clear", "Clears Echo settings from this channel.")] public async Task ClearEchosAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var channelId = Context.Interaction.Channel.Id; if (!Channels.TryGetValue(channelId, out var echo)) { @@ -100,9 +91,6 @@ public async Task ClearEchosAsync() [SlashCommand("clear-all", "Clears all Echo channel settings.")] public async Task ClearEchosAllAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - foreach (var l in Channels.Values) EchoUtil.Forwarders.Remove(l.Action); diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs index a27455302..0f19b796d 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs @@ -12,9 +12,6 @@ namespace SysBot.Pokemon.Discord; public async Task TossAsync( [Summary(nameof(name), "Bot label to match. Leave blank to toss for all.")] string name = "") { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - foreach (var b in SysCord.Runner.Bots.Select(z => z.Bot)) { if (!b.Connection.Name.Contains(name) && !b.Connection.Label.Contains(name)) diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs index 991649983..10080e9ae 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs @@ -32,9 +32,6 @@ public static void RestoreLogging(DiscordSocketClient discord, DiscordSettings s [SlashCommand("here", "Makes the bot log to this channel.")] public async Task AddLogAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - if (Context.Interaction.Channel is not { } channel) { await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); @@ -63,18 +60,12 @@ private static void AddLogChannel(ISocketMessageChannel channel, ulong channelId [SlashCommand("info", "Dumps the logging settings.")] public async Task DumpLogInfoAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); } [SlashCommand("clear", "Clears logging from this channel.")] public async Task ClearLogsAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var channelId = Context.Interaction.Channel.Id; if (!Channels.TryGetValue(channelId, out var log)) { @@ -90,9 +81,6 @@ public async Task ClearLogsAsync() [SlashCommand("clear-all", "Clears all logging settings.")] public async Task ClearLogsAllAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - foreach (var l in Channels.Values) LogUtil.Forwarders.Remove(l); diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs index acc71acf0..452116fae 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Discord; using Discord.Interactions; using PKHeX.Core; using SysBot.Base; @@ -7,23 +8,15 @@ namespace SysBot.Pokemon.Discord; [Group("control", "Commands related to controlling the console itself.")] -public class RemoteControlModule : SudoModuleBase where T : PKM, new() +[DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check +[RequireRoleAccess(PokeRoutineType.RemoteControl)] +[RequireContext(ContextType.Guild)] +public class RemoteControlModule : SlashModuleBase where T : PKM, new() { - // Roughly requires Sudo permissions, but checks a separate role for remote control commands. - - private async Task CheckPermission() - { - var check = CheckRoleAccess(PokeRoutineType.RemoteControl, out var e); - return await RequireAsync(check, e).ConfigureAwait(false); - } - [SlashCommand("click", "Clicks the specified button.")] public async Task ClickAsync( [Summary(nameof(button), "The button to press.")] SwitchButton button) { - if (!await CheckPermission().ConfigureAwait(false)) - return; - var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); if (bot == null) { @@ -39,9 +32,6 @@ public async Task ClickAsync( [Summary(nameof(ip), "Which bot to perform the command on.")] string ip, [Summary(nameof(button), "The button to press.")] SwitchButton button) { - if (await CheckPermission().ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -59,9 +49,6 @@ public async Task SetStickAsync( [Summary(nameof(y), "The Y position of the stick angle.")] short y = 0, [Summary(nameof(ms), "The duration to hold the stick in the position. Leave blank for infinite duration until changed")] ushort? ms = 1000) { - if (await CheckPermission().ConfigureAwait(false)) - return; - var bot = SysCord.Runner.Bots.Find(z => IsRemoteControlBot(z.Bot)); if (bot == null) { @@ -80,9 +67,6 @@ public async Task SetStickAsync( [Summary(nameof(y), "The Y position of the stick angle.")] short y = 0, [Summary(nameof(ms), "The duration to hold the stick in the position. Leave blank for infinite duration until changed.")] ushort? ms = 1000) { - if (await CheckPermission().ConfigureAwait(false)) - return; - var bot = SysCord.Runner.GetBot(ip); if (bot == null) { @@ -101,9 +85,6 @@ public Task SetScreenAsync( private async Task SetScreenGuarded(bool on, string ip) { - if (await CheckPermission().ConfigureAwait(false)) - return; - var bot = GetBot(ip); if (bot == null) { diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs index 3728f25d4..26690e4b2 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs @@ -14,9 +14,6 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("blacklist-user", "Blacklists a Discord user.")] public async Task BlackListUser(IUser user) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - SysCordSettings.Settings.UserBlacklist.AddIfNew(GetReference(user)); await RespondAsync("Done.").ConfigureAwait(false); } @@ -24,9 +21,6 @@ public async Task BlackListUser(IUser user) [SlashCommand("blacklist-comment", "Adds a comment for a blacklisted Discord user ID.")] public async Task BlackListComment(ulong id, string comment) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var obj = SysCordSettings.Settings.UserBlacklist.List.Find(z => z.ID == id); if (obj is null) { @@ -42,9 +36,6 @@ public async Task BlackListComment(ulong id, string comment) [SlashCommand("unblacklist-user", "Removes a Discord user from the blacklist.")] public async Task UnBlackListUser(IUser user) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - SysCordSettings.Settings.UserBlacklist.RemoveAll(z => z.ID == user.Id); await RespondAsync("Done.").ConfigureAwait(false); } @@ -52,9 +43,6 @@ public async Task UnBlackListUser(IUser user) [SlashCommand("blacklist-ids", "Blacklists comma-separated Discord user IDs.")] public async Task BlackListIDs(string ids) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - SysCordSettings.Settings.UserBlacklist.AddIfNew(GetIDs(ids).Select(z => GetReference(z, nameof(BlackListIDs)))); await RespondAsync("Done.").ConfigureAwait(false); } @@ -62,9 +50,6 @@ public async Task BlackListIDs(string ids) [SlashCommand("unblacklist-ids", "Removes comma-separated Discord user IDs from the blacklist.")] public async Task UnBlackListIDs(string ids) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var set = GetIDs(ids).ToHashSet(); SysCordSettings.Settings.UserBlacklist.RemoveAll(z => set.Contains(z.ID)); await RespondAsync("Done.").ConfigureAwait(false); @@ -73,18 +58,12 @@ public async Task UnBlackListIDs(string ids) [SlashCommand("blacklist-summary", "Prints the list of blacklisted Discord users.")] public async Task PrintBlacklist() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - await RespondAsync(Format.Code(string.Join('\n', SysCordSettings.Settings.UserBlacklist.Summarize()))).ConfigureAwait(false); } [SlashCommand("ban-ids", "Bans comma-separated online user IDs.")] public async Task BanOnlineIDs(string ids) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.AddIfNew(GetIDs(ids).Select(z => GetReference(z, nameof(BanOnlineIDs)))); await RespondAsync("Done.").ConfigureAwait(false); } @@ -92,9 +71,6 @@ public async Task BanOnlineIDs(string ids) [SlashCommand("banned-id-comment", "Adds a comment for a banned online user ID.")] public async Task BanOnlineIDComment(ulong id, string comment) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var obj = SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.List.Find(z => z.ID == id); if (obj is null) { @@ -109,8 +85,6 @@ public async Task BanOnlineIDComment(ulong id, string comment) [SlashCommand("unban-ids", "Removes comma-separated online IDs from the ban list.")] public async Task UnBanOnlineIDs(string ids) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; var set = GetIDs(ids).ToHashSet(); SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.RemoveAll(z => set.Contains(z.ID)); await RespondAsync("Done.").ConfigureAwait(false); @@ -119,16 +93,12 @@ public async Task UnBanOnlineIDs(string ids) [SlashCommand("banned-id-summary", "Prints the list of banned online IDs.")] public async Task PrintBannedOnlineIDs() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; await RespondAsync(Format.Code(string.Join('\n', SysCord.Runner.Hub.Config.TradeAbuse.BannedIDs.Summarize()))).ConfigureAwait(false); } [SlashCommand("forget-user", "Forgets previously encountered online IDs.")] public async Task ForgetPreviousUser(string ids) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; foreach (var id in GetIDs(ids)) { PokeRoutineExecutorBase.PreviousUsers.RemoveAllNID(id); @@ -140,9 +110,6 @@ public async Task ForgetPreviousUser(string ids) [SlashCommand("previous-user-summary", "Prints previously encountered users.")] public async Task PrintPreviousUsers() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var messages = new List(); List lines = [.. PokeRoutineExecutorBase.PreviousUsers.Summarize()]; if (lines.Count != 0) @@ -164,9 +131,6 @@ private static IEnumerable GetIDs(string content) [SlashCommand("pool-reload", "Reloads the bot pool from the configured folder.")] public async Task ReloadPoolAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var hub = SysCord.Runner.Hub; var ok = hub.Ledy.Pool.Reload(hub.Config.Folder.DistributeFolder); await RespondAsync(ok diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs index bedcf4f72..0cface8df 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs @@ -3,8 +3,8 @@ namespace SysBot.Pokemon.Discord; -[RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [CommandContextType(InteractionContextType.Guild)] // must run these inside a guild, not in DMs (more auditable). [RequireContext(ContextType.Guild)] +[RequireSudo] public abstract class SudoModuleBase : SlashModuleBase; diff --git a/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs index 401a75a26..cb4d9c4f4 100644 --- a/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs @@ -45,9 +45,6 @@ public static void RestoreTradeStarting(DiscordSocketClient discord, DiscordSett [SlashCommand("here", "Makes the bot log trade starts to this channel.")] public async Task AddLogAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - if (Context.Interaction.Channel is not { } channel) { await RespondAsync("This command must be used in a message channel.", ephemeral: true).ConfigureAwait(false); @@ -83,18 +80,12 @@ void Logger(PokeRoutineExecutorBase bot, PokeTradeDetail detail) [SlashCommand("info", "Dumps the Start Notification settings.")] public async Task DumpLogInfoAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - await RespondAsync(string.Join('\n', Channels.Select(c => $"{c.Key} - {c.Value}"))).ConfigureAwait(false); } [SlashCommand("clear", "Clears Start Notification settings from this channel.")] public async Task ClearLogsAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var id = Context.Interaction.Channel.Id; if (Channels.TryGetValue(id, out var entry)) Remove(entry); @@ -105,9 +96,6 @@ public async Task ClearLogsAsync() [SlashCommand("clear-all", "Clears all Start Notification settings.")] public async Task ClearLogsAllAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - foreach (var entry in Channels.Values) SysCord.Runner.Hub.Queues.Forwarders.Remove(entry.Messager); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index ec1f4f1c0..a11e6c5cc 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -11,12 +11,10 @@ namespace SysBot.Pokemon.Discord; private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; [SlashCommand("clone", "Clones the Pokémon you show via Link Trade.")] + [RequireQueueRole(PokeRoutineType.Clone)] public Task CloneAsync(int? code = null) => JoinAsync(code); private async Task JoinAsync(int? code) { - if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Clone, out var e), e).ConfigureAwait(false)) - return; - await DeferAsync(ephemeral: true).ConfigureAwait(false); var sig = GetSignificance(Context.User); @@ -31,13 +29,10 @@ private async Task JoinAsync(int? code) */ [SlashCommand("clone-list", "Prints the users in the Clone queue.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task GetListAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var embed = new EmbedBuilder(); embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.Clone)); await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index 6554ece69..b2e8e46b7 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -11,12 +11,10 @@ namespace SysBot.Pokemon.Discord; private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; [SlashCommand("dump", "Dumps the Pokémon you show via Link Trade.")] + [RequireQueueRole(PokeRoutineType.Dump)] public async Task DumpAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { - if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.Dump, out var error), error).ConfigureAwait(false)) - return; - await DeferAsync(ephemeral: true).ConfigureAwait(false); var sig = GetSignificance(Context.User); @@ -31,13 +29,10 @@ public async Task DumpAsync( */ [SlashCommand("dump-list", "Prints the users in the Dump queue.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task GetListAsync() { - if (!await RequireAsync(CheckSudo(out var error), error).ConfigureAwait(false)) - return; - string msg = Info.GetTradeList(PokeRoutineType.Dump); var embed = new EmbedBuilder(); embed.AddField(x => diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs index 1735cb4c0..8d24d1471 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs @@ -26,59 +26,48 @@ public Task ClearTradeAsync() */ [SlashCommand("clear-user", "Clears a user from the trade queues.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task ClearTradeUserAsync( [Summary(nameof(user), "The user to clear from the trade queues.")] IUser user) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var message = GetClearTradeMessage(Info.ClearTrade(user.Id)); await RespondAsync(message).ConfigureAwait(false); } [SlashCommand("clear-all", "Clears all users from the trade queues.")] + [RequireSudo] public async Task ClearAllTradesAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - Info.ClearAllQueues(); await RespondAsync("Cleared all in the queue.").ConfigureAwait(false); } [SlashCommand("toggle", "Toggles on/off the ability to join the trade queue.")] + [RequireSudo] public async Task ToggleQueueTradeAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var state = Info.ToggleQueue(); var message = state ? "Users are now able to join the trade queue." - : "Changed queue settings: **Users CANNOT join the queue until it is turned back on.**"; + : $"Changed queue settings: {Format.Bold("Users CANNOT join the queue until it is turned back on.")}"; await RespondAsync(message).ConfigureAwait(false); } [SlashCommand("mode", "Changes how queueing is controlled.")] + [RequireSudo] public async Task ChangeQueueModeAsync( [Summary(nameof(mode), "The mode to set for queueing.")] QueueOpening mode) { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - SysCord.Runner.Hub.Config.Queues.QueueToggleMode = mode; await RespondAsync($"Changed queue mode to {mode}.").ConfigureAwait(false); } [SlashCommand("list", "Sends the list of users in the queue by direct message.")] + [RequireSudo] public async Task ListUserQueue() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var message = string.Join('\n', Info.GetUserList("(ID {0}) - Code: {1} - {2} - {3}")); if (message.Length < 3) message = "Queue list is empty."; diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index 7d36608cb..9450f7074 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -12,12 +12,10 @@ namespace SysBot.Pokemon.Discord; private static TradeQueueInfo Info=>SysCord.Runner.Hub.Queues.Info; [SlashCommand("seed-check", "Checks the seed for a Pokémon.")] + [RequireQueueRole(PokeRoutineType.SeedCheck)] public async Task SeedCheckAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { - if (!await RequireAsync(CheckQueueAccess(PokeRoutineType.SeedCheck, out var e), e).ConfigureAwait(false)) - return; - await DeferAsync(ephemeral: true).ConfigureAwait(false); var sig = GetSignificance(Context.User); @@ -50,13 +48,10 @@ public async Task FindFrameAsync( */ [SlashCommand("seed-list", "Prints the users in the Seed Check queue.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task GetSeedListAsync() { - if (!await RequireAsync(CheckSudo(out var e), e).ConfigureAwait(false)) - return; - var embed = new EmbedBuilder(); embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.SeedCheck)); await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index 00d796698..bd8232f5b 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -17,16 +17,9 @@ namespace SysBot.Pokemon.Discord; private const string TradeModalId = "trade-set"; [SlashCommand("set", "Trade a Showdown Set.")] + [RequireQueueRole(PokeRoutineType.LinkTrade)] public async Task TradeSetAsync() - { - if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) - { - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - - await RespondWithModalAsync(TradeModalId).ConfigureAwait(false); - } + => await RespondWithModalAsync(TradeModalId).ConfigureAwait(false); public class TradeSetModal : IModal { @@ -42,13 +35,13 @@ public class TradeSetModal : IModal public string? Code { get; set; } } - [ModalInteraction(TradeModalId, true)] + [ModalInteraction(TradeModalId, ignoreGroupNames: true)] public async Task TradeSetModalAsync(TradeSetModal modal) { // Re-check if the queue closed in the time between opening the modal and entering the info. - if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) + if (!SysCordSettings.HubConfig.Queues.CanQueue) { - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); return; } @@ -81,13 +74,15 @@ public async Task TradeSetModalAsync(TradeSetModal modal) } [SlashCommand("file", "Trade a Pokémon file.")] + [RequireQueueRole(PokeRoutineType.LinkTrade)] public async Task TradeFileAsync( [Summary(nameof(file), "Attach a file to be traded to your game.")] IAttachment file, [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { - if (!CheckQueueAccess(PokeRoutineType.LinkTrade, out var error)) + // Re-check if the queue closed in the time between opening the modal and entering the info. + if (!SysCordSettings.HubConfig.Queues.CanQueue) { - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); + await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); return; } @@ -105,16 +100,10 @@ public async Task TradeFileAsync( */ [SlashCommand("list", "Prints the users in the trade queues.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task GetTradeListAsync() { - if (!CheckSudo(out var error)) - { - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - var embed = new EmbedBuilder(); embed.AddField(x => { @@ -127,18 +116,12 @@ public async Task GetTradeListAsync() } [SlashCommand("ban", "Ban an Online ID from trading.")] - [RequireUserPermission(ChannelPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] public async Task BanTradeAsync( [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, [Summary(nameof(reason), "The reason for banning the user.")] string reason) { - if (!CheckSudo(out var error)) - { - await RespondAsync(error, ephemeral: true).ConfigureAwait(false); - return; - } - await DeferAsync().ConfigureAwait(false); SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew(GetReference(nnid, reason)); await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs b/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs index 14e4f7fc3..4e2c5ead4 100644 --- a/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs +++ b/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs @@ -1,7 +1,7 @@ -using Discord.WebSocket; -using SysBot.Base; using System; using System.Runtime.CompilerServices; +using Discord.WebSocket; +using SysBot.Base; namespace SysBot.Pokemon.Discord; diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs index 01669584c..366e1eaab 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs @@ -21,7 +21,8 @@ public sealed record DiscordTradeNotifier(T Data, PokeTradeTrainerInfo Info, public async Task TradeInitialize(PokeRoutineExecutor routine, PokeTradeDetail info) { var receive = Data.Species == 0 ? string.Empty : $" ({Data.Nickname})"; - var message = $"Initializing trade{receive}. Please be ready. Your code is **{Code:0000 0000}**."; + var code = Format.Bold($"{Code:0000 0000}"); + var message = $"Initializing trade{receive}. Please be ready. Your code is {code}."; await SendNotification(message).ConfigureAwait(false); } @@ -30,7 +31,9 @@ public async Task TradeSearching(PokeRoutineExecutor routine, PokeTradeDetail { var name = Info.TrainerName; var trainer = string.IsNullOrEmpty(name) ? string.Empty : $", {name}"; - var message = $"I'm waiting for you{trainer}! Your code is **{Code:0000 0000}**. My IGN is **{routine.InGameName}**."; + var code = Format.Bold($"{Code:0000 0000}"); + var myName = Format.Bold(routine.InGameName); + var message = $"I'm waiting for you{trainer}! Your code is {code}. My IGN is {myName}."; await SendNotification(message).ConfigureAwait(false); } diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index ef8676b9d..0a53bf329 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -11,6 +11,17 @@ namespace SysBot.Pokemon.Discord; public static class ReusableActions { + public static string GetModuleName(string name) + { + name = name.Replace("Module", ""); + // Trim off any generic type parameters (e.g., `1, `2) from the name for comparison purposes. + var gen = name.IndexOf('`'); + if (gen != -1) + name = name[..gen]; + return name; + } + + extension(IMessageChannel channel) { public async Task SendFileAsync(PKM pkm, string message = "", Embed? embed = null) diff --git a/SysBot.Pokemon.Discord/SysCord.cs b/SysBot.Pokemon.Discord/SysCord.cs index f5f55583a..ab22b0648 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -56,6 +56,7 @@ public SysCord(PokeBotRunner runner) _client.Log += Log; _interactions.Log += Log; _services = ConfigureServices(); + SysCordSettings.ServiceProvider = _services; } private ServiceProvider ConfigureServices() @@ -129,7 +130,7 @@ private async Task LoadModulesFromAssembly(Assembly assembly, IReadOnlyList blacklist) => blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase)); - private static string GetModuleName(string name) - { - name = name.Replace("Module", ""); - // Trim off any generic type parameters (e.g., `1, `2) from the name for comparison purposes. - var gen = name.IndexOf('`'); - if (gen != -1) - name = name[..gen]; - return name; - } - private async Task HandleInteractionAsync(SocketInteraction interaction) { var context = new SocketInteractionContext(_client, interaction); @@ -291,7 +282,7 @@ private async Task RegisterSlashCommands(DiscordSettings cfg) var hash = ComputeSlashCommandHash(); if (hash == cfg.SlashCommandHash) { - LogUtil.LogInfo("Skipped registering commands -- no signature changes."); + LogUtil.LogInfo("Skipped registering interactions; no signature changes detected."); UpdateRegisteredCounts(); return; } @@ -306,7 +297,7 @@ private async Task RegisterSlashCommands(DiscordSettings cfg) UpdateRegisteredCounts(); cfg.SlashCommandHash = hash; - var message = $"Submitted registered interactions. Hash: {hash}"; + var message = $"Registered interactions. Hash: {hash[..6]}"; LogUtil.LogInfo(message); } catch (Exception ex) diff --git a/SysBot.Pokemon.Discord/SysCordSettings.cs b/SysBot.Pokemon.Discord/SysCordSettings.cs index ba7ba8072..d26aac66c 100644 --- a/SysBot.Pokemon.Discord/SysCordSettings.cs +++ b/SysBot.Pokemon.Discord/SysCordSettings.cs @@ -7,6 +7,7 @@ public static class SysCordSettings public static DiscordManager Manager { get; internal set; } = null!; public static DiscordSettings Settings => Manager.Config; public static PokeTradeHubConfig HubConfig { get; internal set; } = null!; + public static IServiceProvider ServiceProvider { get; internal set; } = null!; public static int RegisteredCommands { get; private set; } public static int RegisteredModals { get; private set; } From 839aad277b85a89cb94bf31edae87f072ae5fc79 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 22:54:38 -0500 Subject: [PATCH 10/23] Add fake precondition tag for DMs open Specific commands may need it, but we can't look before we leap. Leave it as a developer tag. Handle the specific error code for DM send fail in the Queue join --- .../Attributes/RequireOpenDmsAttribute.cs | 18 ++++++++++++++++++ .../Attributes/RequireQueueRoleAttribute.cs | 5 +++-- .../Attributes/RequireRoleAccessAttribute.cs | 5 +++-- .../Attributes/RequireSudoAttribute.cs | 5 +++-- .../Attributes/RequireTeamOrOwnerAttribute.cs | 6 +++--- .../Commands/Extra/BatchEditingModule.cs | 3 +-- .../Commands/General/HelpModule.cs | 2 +- .../Commands/SlashModuleBase.cs | 2 +- .../Commands/Trade/CloneModule.cs | 1 + .../Commands/Trade/DumpModule.cs | 1 + .../Commands/Trade/SeedCheckModule.cs | 1 + .../Commands/Trade/TradeModule.cs | 7 ++++--- .../Helpers/DiscordTradeNotifier.cs | 5 ++--- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 16 +++++----------- .../Helpers/ReusableActions.cs | 3 +-- 15 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 SysBot.Pokemon.Discord/Commands/Attributes/RequireOpenDmsAttribute.cs diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireOpenDmsAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireOpenDmsAttribute.cs new file mode 100644 index 000000000..2908f60d9 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireOpenDmsAttribute.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class RequireOpenDmsAttribute : PreconditionAttribute +{ + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + // This is moreso a tag to indicate to the developer that DMs are required for this command. + // You can always obtain the DM channel, but still fail if the user has DMs off. + // Can only know when we DM the user; let it fail then rather than send fake messages at the start of every command. + return Task.FromResult(PreconditionResult.FromSuccess()); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs index 362308a2c..27fbe7b6c 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs @@ -1,7 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; -using Discord.Commands; +using Discord; +using Discord.Interactions; using Discord.WebSocket; namespace SysBot.Pokemon.Discord; @@ -12,7 +13,7 @@ namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireQueueRoleAttribute(PokeRoutineType type) : PreconditionAttribute { - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs index a6c4904b0..b8941de0c 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs @@ -1,7 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; -using Discord.Commands; +using Discord; +using Discord.Interactions; using Discord.WebSocket; namespace SysBot.Pokemon.Discord; @@ -12,7 +13,7 @@ namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireRoleAccessAttribute(PokeRoutineType type) : PreconditionAttribute { - public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs index 7a0309a11..0bdc1e438 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs @@ -1,7 +1,8 @@ using System; using System.Linq; using System.Threading.Tasks; -using Discord.Commands; +using Discord; +using Discord.Interactions; using Discord.WebSocket; namespace SysBot.Pokemon.Discord; @@ -9,7 +10,7 @@ namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireSudoAttribute : PreconditionAttribute { - public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs index 9a342c1a8..2cd602cbf 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs @@ -2,14 +2,14 @@ using System.Linq; using System.Threading.Tasks; using Discord; -using Discord.Commands; +using Discord.Interactions; namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireTeamOrOwnerAttribute : PreconditionAttribute { - public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { if (await IsTeamOrOwner(context).ConfigureAwait(false)) PreconditionResult.FromSuccess(); @@ -17,7 +17,7 @@ public override async Task CheckPermissionsAsync(ICommandCon return PreconditionResult.FromError("You are not permitted to run this command."); } - public static async Task IsTeamOrOwner(ICommandContext context) + public static async Task IsTeamOrOwner(IInteractionContext context) { // Get application info from the client var appInfo = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); diff --git a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs index 13939b283..a84287bca 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs @@ -71,8 +71,7 @@ public async Task ApplyBatchInfo( return; } - await Context.SendFilePrivatelyAsync(pk, "Modified result attached:").ConfigureAwait(false); - await FollowupAsync("Result sent via direct message.").ConfigureAwait(false); + await Context.SendFileAsync(pk, "Modified result attached:").ConfigureAwait(false); } private static bool IsValidInstructionSet(ReadOnlySpan split, out List invalid) diff --git a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs index d83129af1..0316fbe10 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs @@ -75,7 +75,7 @@ private async Task BuildCommandsAll(EmbedBuilder builder, IServiceProvider provi } private static async IAsyncEnumerable GetAvailableCommands(IEnumerable possible, - SocketInteractionContext context, IServiceProvider provider) + IInteractionContext context, IServiceProvider provider) { foreach (var cmd in possible) { diff --git a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs index a5e0c9c14..c6c9c3470 100644 --- a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs +++ b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs @@ -15,7 +15,7 @@ public abstract class SlashModuleBase : InteractionModuleBase SysCordSettings.Manager; - protected RequestSignificance GetSignificance(SocketUser user) + protected RequestSignificance GetSignificance(IUser user) { // Check user ID. var userId = user.Id; diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index a11e6c5cc..db038ea25 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -12,6 +12,7 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("clone", "Clones the Pokémon you show via Link Trade.")] [RequireQueueRole(PokeRoutineType.Clone)] + [RequireOpenDms] public Task CloneAsync(int? code = null) => JoinAsync(code); private async Task JoinAsync(int? code) { diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index b2e8e46b7..d516cf41d 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -12,6 +12,7 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("dump", "Dumps the Pokémon you show via Link Trade.")] [RequireQueueRole(PokeRoutineType.Dump)] + [RequireOpenDms] public async Task DumpAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index 9450f7074..8fbc4cbf6 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -13,6 +13,7 @@ namespace SysBot.Pokemon.Discord; [SlashCommand("seed-check", "Checks the seed for a Pokémon.")] [RequireQueueRole(PokeRoutineType.SeedCheck)] + [RequireOpenDms] public async Task SeedCheckAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index bd8232f5b..7f200c3ee 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -11,6 +11,7 @@ namespace SysBot.Pokemon.Discord; [Group("trade", "Commands for starting a trade session with the bot.")] [RequireContext(ContextType.Guild)] +[RequireOpenDms] public class TradeModule : SlashModuleBase where T : PKM, new() { private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; @@ -127,7 +128,7 @@ public async Task BanTradeAsync( await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); } - private async Task TradeShowdownAsync(int code, string content, SocketInteractionContext user) + private async Task TradeShowdownAsync(int code, string content, IInteractionContext user) { content = ReusableActions.StripCodeBlock(content); var set = new ShowdownSet(content); @@ -200,7 +201,7 @@ private static void AddInvalidLines(IReadOnlyList inva sb.Append(ReusableActions.FormatSetCode(inner.ToString())); } - private async Task TradeAttachmentAsync(int code, IAttachment attachment, SocketInteractionContext user) + private async Task TradeAttachmentAsync(int code, IAttachment attachment, IInteractionContext user) { var att = await attachment.DownloadEntityAsync().ConfigureAwait(false); var pk = GetRequest(att); @@ -225,7 +226,7 @@ private async Task TradeAttachmentAsync(int code, IAttachment attachment, Socket }; } - private async Task AddTradeToQueueAsync(int code, T pk, SocketInteractionContext context) + private async Task AddTradeToQueueAsync(int code, T pk, IInteractionContext context) { var la = new LegalityAnalysis(pk); if (!la.Valid) diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs index 366e1eaab..0bcd04ba0 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs @@ -2,19 +2,18 @@ using System.Linq; using System.Threading.Tasks; using Discord; -using Discord.Interactions; using PKHeX.Core; namespace SysBot.Pokemon.Discord; -public sealed record DiscordTradeNotifier(T Data, PokeTradeTrainerInfo Info, int Code, SocketInteractionContext Trader) +public sealed record DiscordTradeNotifier(T Data, PokeTradeTrainerInfo Info, int Code, IInteractionContext Trader) : IPokeTradeNotifier where T : PKM, new() { private T Data { get; } = Data; private PokeTradeTrainerInfo Info { get; } = Info; private int Code { get; } = Code; - private SocketInteractionContext Trader { get; } = Trader; + private IInteractionContext Trader { get; } = Trader; public Action>? OnFinish { private get; set; } public readonly PokeTradeHub Hub = SysCord.Runner.Hub; diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index cebef4b61..27fba43bf 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -10,7 +10,7 @@ namespace SysBot.Pokemon.Discord; { private const uint MaxTradeCode = 9999_9999; - public static async Task AddToQueueAsync(SocketInteractionContext context, int code, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type, SocketInteractionContext trader) + public static async Task AddToQueueAsync(SocketInteractionContext context, int code, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type, IInteractionContext trader) { if ((uint)code > MaxTradeCode) { @@ -54,7 +54,7 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c } } - private static bool AddToTradeQueue(SocketInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type, out string message) + private static bool AddToTradeQueue(IInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type, out string message) { var channel = trader.Channel; var user = trader.User; @@ -126,6 +126,7 @@ private static async Task HandleDiscordExceptionAsync(SocketInteractionContext c } } break; + case DiscordErrorCode.CannotSendMessagesToThisUserDueToHavingNoMutualGuilds: case DiscordErrorCode.CannotSendMessageToUser: message = "You must enable private messages in order to be queued!"; break; @@ -140,14 +141,7 @@ private static async Task HandleDiscordExceptionAsync(SocketInteractionContext c return; var interaction = context.Interaction; - if (interaction.HasResponded) - { - var channel = await interaction.GetChannelAsync().ConfigureAwait(false); - await channel.SendMessageAsync(message).ConfigureAwait(false); - } - else - { - await interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); - } + // Can still respond to their command. + await interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index 0a53bf329..603be4932 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading.Tasks; using Discord; -using Discord.Interactions; using PKHeX.Core; namespace SysBot.Pokemon.Discord; @@ -31,7 +30,7 @@ public async Task SendFileAsync(PKM pkm, string message = "", Embed? embed = nul } } - extension(SocketInteractionContext context) + extension(IInteractionContext context) { public async Task SendFileAsync(PKM pk, string message = "", Embed? embed = null) => await context.SendFileAsync([pk], message, embed).ConfigureAwait(false); From b819c619fddba6753e00abe9a5794ecae3bf8021 Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 23:04:26 -0500 Subject: [PATCH 11/23] Add missing sudo hiding for Queue module --- SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs index 8d24d1471..077400689 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs @@ -36,6 +36,7 @@ public async Task ClearTradeUserAsync( } [SlashCommand("clear-all", "Clears all users from the trade queues.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [RequireSudo] public async Task ClearAllTradesAsync() { @@ -44,6 +45,7 @@ public async Task ClearAllTradesAsync() } [SlashCommand("toggle", "Toggles on/off the ability to join the trade queue.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [RequireSudo] public async Task ToggleQueueTradeAsync() { @@ -56,6 +58,7 @@ public async Task ToggleQueueTradeAsync() } [SlashCommand("mode", "Changes how queueing is controlled.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [RequireSudo] public async Task ChangeQueueModeAsync( [Summary(nameof(mode), "The mode to set for queueing.")] QueueOpening mode) @@ -65,6 +68,7 @@ public async Task ChangeQueueModeAsync( } [SlashCommand("list", "Sends the list of users in the queue by direct message.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check [RequireSudo] public async Task ListUserQueue() { From 22d764c2bd231b179bc7a01619196d7599d46c1c Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 9 Aug 2026 23:14:03 -0500 Subject: [PATCH 12/23] Revise datetime formatting Fix the Uptime showing incorrect days uptime if running for longer than 31 Use datetime format (dash separating date and time, using periods for date separators) consistent with command push time. The version build-date format now matches the file-date format. --- SysBot.Pokemon.Discord/Commands/General/InfoModule.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index 43c97eba5..71eb639b7 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -56,7 +56,7 @@ public async Task InfoAsync() await RespondAsync("Here's a bit about me!", embed: builder.Build()).ConfigureAwait(false); } - private static string GetUptime() => (DateTime.Now - Process.GetCurrentProcess().StartTime).ToString(@"dd\.hh\:mm\:ss"); + private static string GetUptime() => (DateTime.Now - Process.GetCurrentProcess().StartTime).ToString(@"d\-hh\:mm\:ss"); private static string GetHeapSize() => Math.Round(GC.GetTotalMemory(true) / (1024.0 * 1024.0), 2).ToString(CultureInfo.CurrentCulture); private static string GetVersionInfo(string assemblyName, bool inclVersion = true) @@ -78,7 +78,7 @@ private static string GetVersionInfo(string assemblyName, bool inclVersion = tru var version = split[0]; var revision = split[1]; if (DateTime.TryParseExact(revision, "yyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var buildTime)) - return (inclVersion ? $"{version} " : "") + $@"{buildTime:yy-MM-dd\.hh\:mm}"; + return (inclVersion ? $"{version} " : "") + $"{buildTime:yyyy.MM.dd-hh:mm:ss}"; return !inclVersion ? unknownVersion : version; } } From 9ed942e37800ff80ee8945a32e040cddb727debd Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 00:34:04 -0500 Subject: [PATCH 13/23] Update InfoModule.cs build time match discord dll (parent) print as short datetime local timestamps within discord --- SysBot.Pokemon.Discord/Commands/General/InfoModule.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index 71eb639b7..6d7555e17 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -39,10 +39,10 @@ public async Task InfoAsync() - {Format.Bold("Library")}: Discord.Net ({DiscordConfig.Version}) - {Format.Bold("Uptime")}: {GetUptime()} - {Format.Bold("Runtime")}: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}) -- {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Base", false)} +- {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Pokemon.Discord", false)} - {Format.Bold("Core Version")}: {GetVersionInfo("PKHeX.Core")} - {Format.Bold("AutoLegality Version")}: {GetVersionInfo("PKHeX.Core.AutoMod")} -- {Format.Bold("Command Count")}: {SysCordSettings.RegisteredCommands} @ {SysCordSettings.RegisteredTime:yyyy.MM.dd-hh:mm:ss} UTC +- {Format.Bold("Command Count")}: {SysCordSettings.RegisteredCommands} @ {TimestampTag.FromDateTime(SysCordSettings.RegisteredTime, TimestampTagStyles.ShortDateTime)} - {Format.Bold("Modal Count")}: {SysCordSettings.RegisteredModals} """ ); @@ -78,7 +78,7 @@ private static string GetVersionInfo(string assemblyName, bool inclVersion = tru var version = split[0]; var revision = split[1]; if (DateTime.TryParseExact(revision, "yyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var buildTime)) - return (inclVersion ? $"{version} " : "") + $"{buildTime:yyyy.MM.dd-hh:mm:ss}"; + return (inclVersion ? $"{version} " : "") + $"{TimestampTag.FromDateTime(buildTime, TimestampTagStyles.ShortDateTime)}"; return !inclVersion ? unknownVersion : version; } } From a7323f09483cb8ac1a347a5a9b0c6775891b5d16 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 00:59:02 -0500 Subject: [PATCH 14/23] Start time relative, core timestamp --- SysBot.Pokemon.Discord/Commands/General/InfoModule.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index 6d7555e17..82836fb03 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -37,7 +37,7 @@ public async Task InfoAsync() - [Source Code]({Repo}) - {Format.Bold("Owner")}: {app.Owner} ({app.Owner.Id}) - {Format.Bold("Library")}: Discord.Net ({DiscordConfig.Version}) -- {Format.Bold("Uptime")}: {GetUptime()} +- {Format.Bold("Started")}: {GetStartTimeRelative()} - {Format.Bold("Runtime")}: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}) - {Format.Bold("Buildtime")}: {GetVersionInfo("SysBot.Pokemon.Discord", false)} - {Format.Bold("Core Version")}: {GetVersionInfo("PKHeX.Core")} @@ -56,7 +56,7 @@ public async Task InfoAsync() await RespondAsync("Here's a bit about me!", embed: builder.Build()).ConfigureAwait(false); } - private static string GetUptime() => (DateTime.Now - Process.GetCurrentProcess().StartTime).ToString(@"d\-hh\:mm\:ss"); + private static string GetStartTimeRelative() => TimestampTag.FromDateTime(Process.GetCurrentProcess().StartTime.ToUniversalTime(), TimestampTagStyles.Relative).ToString(); private static string GetHeapSize() => Math.Round(GC.GetTotalMemory(true) / (1024.0 * 1024.0), 2).ToString(CultureInfo.CurrentCulture); private static string GetVersionInfo(string assemblyName, bool inclVersion = true) @@ -77,6 +77,7 @@ private static string GetVersionInfo(string assemblyName, bool inclVersion = tru var version = split[0]; var revision = split[1]; + revision = revision.Split('.')[^1]; // sometimes builds have extra metadata prepended, followed by .timestamp -- just keep the ending. if (DateTime.TryParseExact(revision, "yyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var buildTime)) return (inclVersion ? $"{version} " : "") + $"{TimestampTag.FromDateTime(buildTime, TimestampTagStyles.ShortDateTime)}"; return !inclVersion ? unknownVersion : version; From c69d9d9d96f09caeb9e4fa860e8e3592eafc12d1 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 01:13:36 -0500 Subject: [PATCH 15/23] Backlink to channel joined from Quality of life. --- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index 27fba43bf..684150b91 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -28,7 +28,9 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c } // Message the user in their DMs. If this fails, the event handler will abort and let them know to enable DMs. + var channelRef = $"<#{context.Channel.Id}>"; var secret = $""" + {channelRef} {message} I'll message you here when your trade is starting. """; From 84972ce5d1256cd8f727ca4c8b4536909f725614 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 01:24:50 -0500 Subject: [PATCH 16/23] No longer need Manage Messages perm revise the send messages ask to ping the owner to fix their setup --- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index 684150b91..2a41b3672 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -113,16 +113,9 @@ private static async Task HandleDiscordExceptionAsync(SocketInteractionContext c var self = await guild.GetCurrentUserAsync().ConfigureAwait(false); var permissions = self.GetPermissions(guildChannel); if (!permissions.SendMessages) - { - message = "You must grant me \"Send Messages\" permissions!"; - Base.LogUtil.LogError(message); - return; - } - if (!permissions.ManageMessages) { var app = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); - message = $"<@{app.Owner.Id}> You must grant me \"Manage Messages\" permissions!"; - await context.Interaction.RespondAsync(message, ephemeral: true).ConfigureAwait(false); + message = $"{app.Owner.Mention} You must grant me \"Send Messages\" permissions!"; Base.LogUtil.LogError(message); return; } From e8f257bb7d27db2892a90de714b6bdcd88b25698 Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 20:56:39 -0500 Subject: [PATCH 17/23] Use Task Dialogs instead of MessageBox Autodetect dark mode if unconfigured; restyle controls same as PKHeX. --- .../Controls/BotController.cs | 4 +- SysBot.Pokemon.WinForms/Main.cs | 30 ++--- SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs | 7 +- SysBot.Pokemon.WinForms/Program.cs | 10 +- SysBot.Pokemon.WinForms/TaskDialogUtil.cs | 109 ++++++++++++++++++ SysBot.Pokemon.WinForms/WinFormsUtil.cs | 85 +++++++++----- SysBot.Pokemon/Structures/ProgramConfig.cs | 2 +- 7 files changed, 194 insertions(+), 53 deletions(-) create mode 100644 SysBot.Pokemon.WinForms/TaskDialogUtil.cs diff --git a/SysBot.Pokemon.WinForms/Controls/BotController.cs b/SysBot.Pokemon.WinForms/Controls/BotController.cs index 01bfacb3a..012e7c9f4 100644 --- a/SysBot.Pokemon.WinForms/Controls/BotController.cs +++ b/SysBot.Pokemon.WinForms/Controls/BotController.cs @@ -160,7 +160,7 @@ public void SendCommand(BotControlCommand cmd, bool echo = true) case BotControlCommand.Resume: bot.Resume(); break; case BotControlCommand.Restart: { - var prompt = WinFormsUtil.Prompt(MessageBoxButtons.YesNo, "Are you sure you want to restart the connection?"); + var prompt = this.Prompt(MessageBoxButtons.YesNo, "Are you sure you want to restart the connection?"); if (prompt != DialogResult.Yes) return; @@ -169,7 +169,7 @@ public void SendCommand(BotControlCommand cmd, bool echo = true) break; } default: - WinFormsUtil.Alert($"{cmd} is not a command that can be sent to the Bot."); + this.Alert($"{cmd} is not a command that can be sent to the Bot."); return; } if (echo) diff --git a/SysBot.Pokemon.WinForms/Main.cs b/SysBot.Pokemon.WinForms/Main.cs index 949c53b14..c9fdd5d35 100644 --- a/SysBot.Pokemon.WinForms/Main.cs +++ b/SysBot.Pokemon.WinForms/Main.cs @@ -36,10 +36,10 @@ public Main() var trainer = Config.Hub.Legality; InitUtil.InitializeStubs(Config.Mode, trainer.GenerateOT, trainer.GenerateLanguage); - if (Config.DarkMode) + if (Application.IsDarkModeEnabled) { - foreach (TabPage tab in TC_Main.TabPages) - tab.UseVisualStyleBackColor = false; + foreach (var control in this.GetChildrenOfType()) + WinFormsUtil.ReformatDark(control); } if (Config is not { Width: 0, Height: 0 }) @@ -58,13 +58,13 @@ protected override void ScaleControl(SizeF factor, BoundsSpecified specified) TC_Main.ItemSize = new((int)(TC_Main.ItemSize.Width * factor.Width), (int)(TC_Main.ItemSize.Height * factor.Height)); } - private static IPokeBotRunner GetRunner(ProgramConfig cfg) => cfg.Mode switch + private IPokeBotRunner GetRunner(ProgramConfig cfg) => cfg.Mode switch { - ProgramMode.SWSH => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8SWSH()), - ProgramMode.BDSP => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8BS()), - ProgramMode.LA => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8LA()), - ProgramMode.SV => new PokeBotRunnerImpl(cfg.Hub, new BotFactory9SV()), - ProgramMode.LZA => new PokeBotRunnerImpl(cfg.Hub, new BotFactory9LZA()), + ProgramMode.SWSH => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8SWSH()) { Owner = this }, + ProgramMode.BDSP => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8BS()) { Owner = this }, + ProgramMode.LA => new PokeBotRunnerImpl(cfg.Hub, new BotFactory8LA()) { Owner = this }, + ProgramMode.SV => new PokeBotRunnerImpl(cfg.Hub, new BotFactory9SV()) { Owner = this }, + ProgramMode.LZA => new PokeBotRunnerImpl(cfg.Hub, new BotFactory9LZA()) { Owner = this }, _ => throw new IndexOutOfRangeException("Unsupported mode."), }; @@ -152,7 +152,7 @@ private void B_Start_Click(object sender, EventArgs e) Tab_Logs.Select(); if (Bots.Count == 0) - WinFormsUtil.Alert("No bots configured, but all supporting services have been started."); + this.Alert("No bots configured, but all supporting services have been started."); } private void SendAll(BotControlCommand cmd) @@ -168,7 +168,7 @@ private void B_Stop_Click(object sender, EventArgs e) var env = RunningEnvironment; if (!env.IsRunning && (ModifierKeys & Keys.Alt) == 0) { - WinFormsUtil.Alert("Nothing is currently running."); + this.Alert("Nothing is currently running."); return; } @@ -178,12 +178,12 @@ private void B_Stop_Click(object sender, EventArgs e) { if (env.IsRunning) { - WinFormsUtil.Alert("Commanding all bots to Idle.", "Press Stop (without a modifier key) to hard-stop and unlock control, or press Stop with the modifier key again to resume."); + this.Alert("Commanding all bots to Idle.", "Press Stop (without a modifier key) to hard-stop and unlock control, or press Stop with the modifier key again to resume."); cmd = BotControlCommand.Idle; } else { - WinFormsUtil.Alert("Commanding all bots to resume their original task.", "Press Stop (without a modifier key) to hard-stop and unlock control."); + this.Alert("Commanding all bots to resume their original task.", "Press Stop (without a modifier key) to hard-stop and unlock control."); cmd = BotControlCommand.Resume; } } @@ -195,7 +195,7 @@ private void B_New_Click(object sender, EventArgs e) var cfg = CreateNewBotConfig(); if (!AddBot(cfg)) { - WinFormsUtil.Alert("Unable to add bot; ensure details are valid and not duplicate with an already existing bot."); + this.Alert("Unable to add bot; ensure details are valid and not duplicate with an already existing bot."); return; } System.Media.SystemSounds.Asterisk.Play(); @@ -226,7 +226,7 @@ private bool AddBot(PokeBotState cfg) } catch (ArgumentException ex) { - WinFormsUtil.Error(ex.Message); + this.Error(ex.Message); return false; } diff --git a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs index 165d0aee6..7fdc7157c 100644 --- a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs +++ b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs @@ -1,12 +1,12 @@ using System.Threading; using System.Threading.Tasks; +using System.Windows.Forms; using PKHeX.Core; using SysBot.Pokemon.Discord; using SysBot.Pokemon.Twitch; -using SysBot.Pokemon.WinForms; using SysBot.Pokemon.YouTube; -namespace SysBot.Pokemon; +namespace SysBot.Pokemon.WinForms; /// /// Bot Environment implementation with Integrations added. @@ -18,6 +18,7 @@ public PokeBotRunnerImpl(PokeTradeHubConfig config, BotFactory fac) : base(co private TwitchBot? Twitch; private YouTubeBot? YouTube; + public required Form Owner { get; init; } protected override void AddIntegrations() { @@ -52,7 +53,7 @@ private void AddYouTubeBot(YouTubeSettings config) if (YouTube != null) return; // already created - WinFormsUtil.Alert("Please Login with your Browser"); + Owner.Alert("Please log in with your web browser."); if (string.IsNullOrWhiteSpace(config.ChannelID)) return; if (string.IsNullOrWhiteSpace(config.ClientID)) diff --git a/SysBot.Pokemon.WinForms/Program.cs b/SysBot.Pokemon.WinForms/Program.cs index a9aef77da..4728b3533 100644 --- a/SysBot.Pokemon.WinForms/Program.cs +++ b/SysBot.Pokemon.WinForms/Program.cs @@ -15,8 +15,14 @@ static Program() var use = Array.Find(cmd, z => z.EndsWith(".json")); var cfg = Config = ConfigLoader.LoadConfig(use); Application.SetCompatibleTextRenderingDefault(false); - if (cfg.DarkMode) - Application.SetColorMode(SystemColorMode.Dark); + + var mode = cfg.DarkMode switch + { + true => SystemColorMode.Dark, + false => SystemColorMode.Classic, + _ => SystemColorMode.System, + }; + Application.SetColorMode(mode); PokeTradeBotSWSH.SeedChecker = new Z3SeedSearchHandler(); } diff --git a/SysBot.Pokemon.WinForms/TaskDialogUtil.cs b/SysBot.Pokemon.WinForms/TaskDialogUtil.cs new file mode 100644 index 000000000..745faeb47 --- /dev/null +++ b/SysBot.Pokemon.WinForms/TaskDialogUtil.cs @@ -0,0 +1,109 @@ +using System; +using System.Media; +using System.Windows.Forms; + +namespace SysBot.Pokemon.WinForms; + +internal static class TaskDialogUtil +{ + /// The window that owns the dialog. + extension(IWin32Window owner) + { + /// + /// Displays a dialog showing the details of an error. + /// + /// User-friendly message about the error. + /// The associated with the dialog. + public DialogResult Error(params ReadOnlySpan lines) + { + SystemSounds.Hand.Play(); + + var msg = string.Join(Environment.NewLine + Environment.NewLine, lines); + var page = new TaskDialogPage + { + Caption = "Error", + Text = msg, + Icon = TaskDialogIcon.Error, + Buttons = [TaskDialogButton.OK], + AllowCancel = true, // Allows Esc key to close + SizeToContent = true + }; + + var button = TaskDialog.ShowDialog(owner, page); + return ToDialogResult(button); + } + + public DialogResult Alert(params ReadOnlySpan lines) + => owner.Alert(true, lines); + + public DialogResult Alert(bool sound, params ReadOnlySpan lines) + { + if (sound) + SystemSounds.Asterisk.Play(); + + var msg = string.Join(Environment.NewLine + Environment.NewLine, lines); + var page = new TaskDialogPage + { + Caption = "Alert", + Text = msg, + Icon = sound ? TaskDialogIcon.Information : TaskDialogIcon.None, + Buttons = [TaskDialogButton.OK], + AllowCancel = true, // Allows Esc key to close + SizeToContent = true + }; + + var button = TaskDialog.ShowDialog(owner, page); + return ToDialogResult(button); + } + + public DialogResult Prompt(MessageBoxButtons btn, params ReadOnlySpan lines) + { + SystemSounds.Asterisk.Play(); + + var msg = string.Join(Environment.NewLine + Environment.NewLine, lines); + var page = new TaskDialogPage + { + Caption = "Prompt", + Text = msg, + Icon = TaskDialogIcon.Information, + Buttons = GetTaskDialogButtons(btn), + AllowCancel = true, // Allows Esc key to close + SizeToContent = true + }; + + var button = TaskDialog.ShowDialog(owner, page); + return ToDialogResult(button); + } + } + + private static TaskDialogButtonCollection GetTaskDialogButtons(MessageBoxButtons buttons) => buttons switch + { + MessageBoxButtons.OK => [TaskDialogButton.OK], + MessageBoxButtons.OKCancel => [TaskDialogButton.OK, TaskDialogButton.Cancel], + MessageBoxButtons.AbortRetryIgnore => [TaskDialogButton.Abort, TaskDialogButton.Retry, TaskDialogButton.Ignore], + MessageBoxButtons.YesNo => [TaskDialogButton.Yes, TaskDialogButton.No], + MessageBoxButtons.YesNoCancel => [TaskDialogButton.Yes, TaskDialogButton.No, TaskDialogButton.Cancel], + MessageBoxButtons.RetryCancel => [TaskDialogButton.Retry, TaskDialogButton.Cancel], + + _ => throw new ArgumentOutOfRangeException(nameof(buttons), buttons, null), + }; + + private static DialogResult ToDialogResult(TaskDialogButton button) + { + if (button == TaskDialogButton.OK) + return DialogResult.OK; + if (button == TaskDialogButton.Cancel) + return DialogResult.Cancel; + if (button == TaskDialogButton.Abort) + return DialogResult.Abort; + if (button == TaskDialogButton.Retry) + return DialogResult.Retry; + if (button == TaskDialogButton.Ignore) + return DialogResult.Ignore; + if (button == TaskDialogButton.Yes) + return DialogResult.Yes; + if (button == TaskDialogButton.No) + return DialogResult.No; + return DialogResult.None; + } +} diff --git a/SysBot.Pokemon.WinForms/WinFormsUtil.cs b/SysBot.Pokemon.WinForms/WinFormsUtil.cs index d081485dc..d14fb6c5b 100644 --- a/SysBot.Pokemon.WinForms/WinFormsUtil.cs +++ b/SysBot.Pokemon.WinForms/WinFormsUtil.cs @@ -1,47 +1,72 @@ -using System; +using System.Collections.Generic; +using System.Linq; using System.Windows.Forms; namespace SysBot.Pokemon.WinForms; -public static class WinFormsUtil +internal static class WinFormsUtil { - #region Message Displays /// - /// Displays a dialog showing the details of an error. + /// Gets the selected value of the input . If no value is selected, will return 0. /// - /// User-friendly message about the error. - /// The associated with the dialog. - internal static DialogResult Error(params string[] lines) + /// ComboBox to retrieve value for. + internal static int GetIndex(ListControl cb) { - System.Media.SystemSounds.Hand.Play(); - string msg = string.Join(Environment.NewLine + Environment.NewLine, lines); - return MessageBox.Show(msg, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + return (int)(cb.SelectedValue ?? 0); } - internal static DialogResult Alert(params string[] lines) => Alert(true, lines); - - internal static DialogResult Alert(bool sound, params string[] lines) + public static IEnumerable GetChildrenOfType(this Control control) where T : class { - if (sound) - System.Media.SystemSounds.Asterisk.Play(); - string msg = string.Join(Environment.NewLine + Environment.NewLine, lines); - return MessageBox.Show(msg, "Alert", MessageBoxButtons.OK, sound ? MessageBoxIcon.Information : MessageBoxIcon.None); - } + foreach (var child in control.Controls.OfType()) + { + if (child is T childOfT) + yield return childOfT; - internal static DialogResult Prompt(MessageBoxButtons btn, params string[] lines) - { - System.Media.SystemSounds.Asterisk.Play(); - string msg = string.Join(Environment.NewLine + Environment.NewLine, lines); - return MessageBox.Show(msg, "Prompt", btn, MessageBoxIcon.Question); + if (!child.HasChildren) continue; + foreach (var descendant in child.GetChildrenOfType()) + yield return descendant; + } } - #endregion - /// - /// Gets the selected value of the input . If no value is selected, will return 0. - /// - /// ComboBox to retrieve value for. - internal static int GetIndex(ListControl cb) + public static void ReformatDark(Control z) { - return (int)(cb.SelectedValue ?? 0); + if (z is TabControl tc) + { + foreach (TabPage tab in tc.TabPages) + tab.UseVisualStyleBackColor = false; + } + else if (z is DataGridView dg) + { + dg.EnableHeadersVisualStyles = false; + dg.BorderStyle = BorderStyle.None; + } + else if (z is ComboBox cb) + { + cb.FlatStyle = FlatStyle.Popup; + } + else if (z is ListBox lb) + { + lb.BorderStyle = BorderStyle.None; + } + else if (z is RichTextBox rtb) + { + rtb.BorderStyle = BorderStyle.None; + } + else if (z is TextBoxBase tb) + { + tb.BorderStyle = BorderStyle.FixedSingle; + } + else if (z is NumericUpDown nud) + { + nud.BorderStyle = BorderStyle.FixedSingle; + } + else if (z is GroupBox gb) + { + gb.FlatStyle = FlatStyle.Popup; + } + else if (z is ButtonBase b) + { + b.FlatStyle = FlatStyle.Popup; + } } } diff --git a/SysBot.Pokemon/Structures/ProgramConfig.cs b/SysBot.Pokemon/Structures/ProgramConfig.cs index 6a7a0b2be..47993efda 100644 --- a/SysBot.Pokemon/Structures/ProgramConfig.cs +++ b/SysBot.Pokemon/Structures/ProgramConfig.cs @@ -7,7 +7,7 @@ public class ProgramConfig : BotList { public ProgramMode Mode { get; set; } = ProgramMode.LZA; public PokeTradeHubConfig Hub { get; set; } = new(); - public bool DarkMode { get; set; } + public bool? DarkMode { get; set; } public int Width { get; set; } public int Height { get; set; } } From ff207b465f152035ce199031bacb3f045c83237b Mon Sep 17 00:00:00 2001 From: Kurt Date: Mon, 10 Aug 2026 22:46:10 -0500 Subject: [PATCH 18/23] Slightly-more-rich embed w/ sprite Handle error-after-join such as DMs off; need to ensure they're not in the queue if we handled any error. --- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 67 ++++++++++++++----- .../Helpers/ReusableActions.cs | 37 ++++++++++ SysBot.Pokemon.Discord/SysCordSettings.cs | 2 + SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs | 17 +++++ 4 files changed, 108 insertions(+), 15 deletions(-) diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index 2a41b3672..64556f2d7 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -18,12 +18,14 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c return; } + QueueJoinResult? check = null; try { - var result = AddToTradeQueue(trader, trade, code, sig, routine, type, out var message); + check = AddToTradeQueue(trader, trade, code, sig, routine, type); + var result = check.Result; if (!result) { - await context.Interaction.FollowupAsync(message).ConfigureAwait(false); + await context.Interaction.FollowupAsync(check.Message).ConfigureAwait(false); return; } @@ -31,32 +33,70 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c var channelRef = $"<#{context.Channel.Id}>"; var secret = $""" {channelRef} - {message} + {check.Message} I'll message you here when your trade is starting. """; - var builder = new EmbedBuilder { Color = Color.Blue }; + var builder = new EmbedBuilder { Color = ((PersonalColor)trade.PersonalInfo.Color).ToDiscordColor() }; builder.AddField(x => { x.Name = "Trade Code:"; x.Value = Format.Bold($"{code:0000 0000}"); }); - var msg = await trader.Interaction.User.SendMessageAsync(secret, embed: builder.Build()).ConfigureAwait(false); + + if (trade.Species != 0) + { + builder.AddField(x => + { + x.Name = "Receiving:"; + x.Value = ReusableActions.FormatSetCode(trade); + }); + } + + Task toMessage; + var sprite = ReusableActions.GetSprite?.Invoke(trade); + if (sprite is not null) + { + const string fileName = "sprite.png"; + var thumb = new FileAttachment(sprite, fileName); + builder.WithThumbnailUrl($"attachment://{fileName}"); + toMessage = trader.Interaction.User.SendFileAsync(thumb, text: secret, embed: builder.Build()); + } + else + { + toMessage = trader.Interaction.User.SendMessageAsync(text: secret, embed: builder.Build()); + } + + var msg = await toMessage.ConfigureAwait(false); + if (sprite != null) + await sprite.DisposeAsync().ConfigureAwait(false); // Keep a public log of them joining the queue. - await context.Interaction.Channel.SendMessageAsync($"{trader.User.Mention} - {message}").ConfigureAwait(false); + await context.Interaction.Channel.SendMessageAsync($"{trader.User.Mention} - {check.Message}").ConfigureAwait(false); - // Update the ephermal command message to backlink to the DM we just sent the user. - await context.Interaction.FollowupAsync($"Please check your direct messages: {msg.GetJumpUrl()}").ConfigureAwait(false); + // Update the ephemeral command message to backlink to the DM we just sent the user. + await context.Interaction.FollowupAsync($"Success! Please check your direct messages: {msg.GetJumpUrl()}").ConfigureAwait(false); // All further communication is in Direct Messages to the user (no further input needed). } catch (HttpException ex) { await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); + // They might have been added to the queue with DMs off; dequeue them. + + if (check?.Result is true) + { + var detail = check.Join; + var hub = SysCord.Runner.Hub; + var info = hub.Queues.Info; + info.Remove(detail); + // Already followed up in the above event handling. + } } } - private static bool AddToTradeQueue(IInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type, out string message) + private sealed record QueueJoinResult(bool Result, TradeEntry Join, string Message); + + private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type) { var channel = trader.Channel; var user = trader.User; @@ -79,10 +119,7 @@ private static bool AddToTradeQueue(IInteractionContext trader, T pk, int code, var info = hub.Queues.Info; var added = info.AddToTradeQueue(trade, userId, sig == RequestSignificance.Owner); if (added == QueueResultAdd.AlreadyInQueue) - { - message = "Sorry, you are already in the queue."; - return false; - } + return new(false, trade, "Sorry, you are already in the queue."); var position = info.CheckPosition(userId, routine); var ticketId = TradeStartModule.IsStartChannel(channel.Id) ? $", unique ID: {detail.Id}" : ""; @@ -90,14 +127,14 @@ private static bool AddToTradeQueue(IInteractionContext trader, T pk, int code, ? $" Receiving: {GameInfo.GetStrings("en").Species[pk.Species]}." : ""; - message = $"Added to the {routine} queue{ticketId}. Current Position: {position.Position}.{pokeName}"; + var message = $"Added to the {routine} queue{ticketId}. Current Position: {position.Position}.{pokeName}"; var botct = info.Hub.Bots.Count; if (position.Position > botct) { var eta = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); message += $" Estimated: {eta:F1} minutes."; } - return true; + return new(true, trade, message); } private static async Task HandleDiscordExceptionAsync(SocketInteractionContext context, HttpException ex) diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index 603be4932..bbf9683d8 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -10,6 +10,8 @@ namespace SysBot.Pokemon.Discord; public static class ReusableActions { + public static Func? GetSprite { get; set; } + public static string GetModuleName(string name) { name = name.Replace("Module", ""); @@ -95,6 +97,12 @@ public static string FormatSetCode(ShowdownSet set, LanguageID language = Langua return FormatSetCode(lines); } + public static string FormatSetCode(T trade, LanguageID language = Language) where T : PKM, new() + { + var set = new ShowdownSet(trade); + return FormatSetCode(set, language); + } + /// /// Removes the Discord code formatting. /// @@ -104,3 +112,32 @@ public static string StripCodeBlock(string message) => message .Replace("`", "") .Trim(); } + +public static class PersonalColorExtensions +{ + private static readonly Dictionary Map = new() + { + [PersonalColor.Red] = new Color(0xE5, 0x3D, 0x3D), + [PersonalColor.Blue] = new Color(0x3D, 0x7D, 0xE5), + [PersonalColor.Yellow] = new Color(0xE5, 0xD3, 0x3D), + [PersonalColor.Green] = new Color(0x4C, 0xAF, 0x50), + [PersonalColor.Black] = new Color(0x2C, 0x2C, 0x2C), + [PersonalColor.Brown] = new Color(0x8D, 0x5B, 0x3D), + [PersonalColor.Purple] = new Color(0x9B, 0x59, 0xB6), + [PersonalColor.Gray] = new Color(0x95, 0xA5, 0xA6), + [PersonalColor.White] = new Color(0xEC, 0xF0, 0xF1), + [PersonalColor.Pink] = new Color(0xE9, 0x1E, 0x8C), + }; + + extension(PKM pk) + { + public Color ToDiscordColor() => + Map.TryGetValue((PersonalColor)pk.PersonalInfo.Color, out var c) ? c : Color.Default; + } + + extension(PersonalColor color) + { + public Color ToDiscordColor() => + Map.TryGetValue(color, out var c) ? c : Color.Default; + } +} diff --git a/SysBot.Pokemon.Discord/SysCordSettings.cs b/SysBot.Pokemon.Discord/SysCordSettings.cs index d26aac66c..dc1949ba7 100644 --- a/SysBot.Pokemon.Discord/SysCordSettings.cs +++ b/SysBot.Pokemon.Discord/SysCordSettings.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using PKHeX.Core; namespace SysBot.Pokemon.Discord; diff --git a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs index 7fdc7157c..5f95e368d 100644 --- a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs +++ b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs @@ -1,7 +1,9 @@ +using System.IO; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using PKHeX.Core; +using PKHeX.Drawing.PokeSprite; using SysBot.Pokemon.Discord; using SysBot.Pokemon.Twitch; using SysBot.Pokemon.YouTube; @@ -71,5 +73,20 @@ private void AddDiscordBot(string apiToken) return; var bot = new SysCord(this); Task.Run(() => bot.MainAsync(apiToken, CancellationToken.None)); + + // Set up sprite generating; allows fetching a stream to attach without referencing the sprite dll. + AddSpriteGenerating(); + } + + private static void AddSpriteGenerating() + { + SpriteName.AllowShinySprite = true; + ReusableActions.GetSprite = pk => + { + var img = pk.Sprite(); + var ms = new MemoryStream(); + img.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + return ms; + }; } } From 8e65ed55360aaf0e8fa8b901aed15efa9ef90ea1 Mon Sep 17 00:00:00 2001 From: Kurt Date: Wed, 12 Aug 2026 22:35:47 -0500 Subject: [PATCH 19/23] Readiness flag for DM fails, minor clean Adds friendship to recognized Twitch lines Adds "trade text" base64 input: With the rise of web implementations of PKHeX-like editors, users might want to copypaste a base64 set instead of dragdrop a file. This is a more web-friendly method of trading. Currently, no web editors support it, but could support it soon. Cleans up trade join flow parameter passing Handle DM closed enqueue, where bot tries to trade with a DMs-closed user (silent failure). --- .../Commands/Management/HubModule.cs | 2 +- .../Commands/SlashModuleBase.cs | 20 -- .../Commands/Trade/CloneModule.cs | 6 +- .../Commands/Trade/DumpModule.cs | 6 +- .../Commands/Trade/SeedCheckModule.cs | 6 +- .../Commands/Trade/TradeModule.cs | 250 ++++++++++++------ .../Helpers/AutoLegalityExtensionsDiscord.cs | 18 +- .../Helpers/DiscordTradeNotifier.cs | 4 + SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 35 ++- .../Helpers/QueueRestrictions.cs | 79 ++++++ .../Helpers/ReusableActions.cs | 15 +- .../Helpers/TwitchCommandsHelper.cs | 5 +- SysBot.Pokemon.Twitch/TwitchBot.cs | 12 +- SysBot.Pokemon/Actions/PokeRoutineExecutor.cs | 2 +- .../BDSP/BotTrade/PokeTradeBotBS.cs | 4 +- SysBot.Pokemon/Helpers/RemoteControlAccess.cs | 22 +- SysBot.Pokemon/Helpers/ShowdownUtil.cs | 19 +- SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs | 4 +- .../LZA/BotTrade/PokeTradeBotLZA.cs | 4 +- SysBot.Pokemon/Queues/QueueResultAdd.cs | 5 +- SysBot.Pokemon/Queues/TradeQueueInfo.cs | 28 +- SysBot.Pokemon/Queues/TradeQueueManager.cs | 12 +- SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs | 4 +- .../SWSH/BotTrade/PokeTradeBotSWSH.cs | 4 +- .../Settings/Integrations/StreamSettings.cs | 4 +- SysBot.Pokemon/Settings/QueueSettings.cs | 4 +- .../Structures/ConcurrentPriorityQueue.cs | 19 +- SysBot.Pokemon/Structures/FavoredCPQ.cs | 2 +- SysBot.Pokemon/Structures/IReadyStatus.cs | 9 + SysBot.Pokemon/Structures/UserTracker.cs | 2 +- SysBot.Pokemon/TradeHub/PokeTradeDetail.cs | 11 +- SysBot.Pokemon/TradeHub/PokeTradeQueue.cs | 8 +- SysBot.Tests/GenerateTests.cs | 45 ++-- SysBot.Tests/PokeHubTests.cs | 4 +- SysBot.Tests/QueueTests.cs | 12 +- 35 files changed, 454 insertions(+), 232 deletions(-) create mode 100644 SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs create mode 100644 SysBot.Pokemon/Structures/IReadyStatus.cs diff --git a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs index c22a03373..7e7aa0321 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs @@ -71,7 +71,7 @@ public async Task GetStatusAsync() private static string GetNextName(PokeTradeQueue q) { - if (!q.TryPeek(out var detail, out _)) + if (!q.TryPeek(out var detail, out _, checkReady: false)) // can be soon-ready return "None!"; var name = detail.Trainer.TrainerName; diff --git a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs index c6c9c3470..1716e04b6 100644 --- a/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs +++ b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs @@ -1,8 +1,6 @@ using System; -using System.Linq; using Discord; using Discord.Interactions; -using Discord.WebSocket; namespace SysBot.Pokemon.Discord; @@ -13,24 +11,6 @@ namespace SysBot.Pokemon.Discord; [RequireBotPermission(GuildPermission.SendMessages)] public abstract class SlashModuleBase : InteractionModuleBase { - private static DiscordManager Manager => SysCordSettings.Manager; - - protected RequestSignificance GetSignificance(IUser user) - { - // Check user ID. - var userId = user.Id; - if (userId == Manager.Owner) - return RequestSignificance.Owner; - if (Manager.CanUseSudo(userId)) - return RequestSignificance.Favored; - - // Check roles, might be a special role granted. - // Stringy names are for user convenience; must trust externally managed guilds the bot is added to (else we should use role IDs). - return user is SocketGuildUser g - ? Manager.GetSignificance(g.Roles.Select(z => z.Name)) - : RequestSignificance.None; - } - // Used by Sudo commands. protected RemoteControlAccess GetReference(IChannel channel) => GetReference(channel.Id, channel.Name); protected RemoteControlAccess GetReference(IUser user) => GetReference(user.Id, user.Username); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index db038ea25..f907aca92 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -16,11 +16,13 @@ namespace SysBot.Pokemon.Discord; public Task CloneAsync(int? code = null) => JoinAsync(code); private async Task JoinAsync(int? code) { + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); - var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); - await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Clone, PokeTradeType.Clone, Context).ConfigureAwait(false); + await QueueHelper.AddToQueueAsync(Context, (int)code, new T(), PokeRoutineType.Clone, PokeTradeType.Clone).ConfigureAwait(false); } /* diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index d516cf41d..61ddb78f5 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -16,11 +16,13 @@ namespace SysBot.Pokemon.Discord; public async Task DumpAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); - var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); - await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.Dump, PokeTradeType.Dump, Context).ConfigureAwait(false); + await QueueHelper.AddToQueueAsync(Context, (int)code, new T(), PokeRoutineType.Dump, PokeTradeType.Dump).ConfigureAwait(false); } /* diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index 8fbc4cbf6..21a552d5f 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -17,11 +17,13 @@ namespace SysBot.Pokemon.Discord; public async Task SeedCheckAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; + await DeferAsync(ephemeral: true).ConfigureAwait(false); - var sig = GetSignificance(Context.User); code ??= Info.GetRandomTradeCode(); - await QueueHelper.AddToQueueAsync(Context, (int)code, sig, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed, Context).ConfigureAwait(false); + await QueueHelper.AddToQueueAsync(Context, (int)code, new T(), PokeRoutineType.SeedCheck, PokeTradeType.Seed).ConfigureAwait(false); } [SlashCommand("find-frame", "Prints the next shiny frame from a seed.")] diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index 7f200c3ee..c35319e7d 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -15,55 +15,56 @@ namespace SysBot.Pokemon.Discord; public class TradeModule : SlashModuleBase where T : PKM, new() { private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; + private const string TradeModalId = "trade-set"; [SlashCommand("set", "Trade a Showdown Set.")] [RequireQueueRole(PokeRoutineType.LinkTrade)] - public async Task TradeSetAsync() - => await RespondWithModalAsync(TradeModalId).ConfigureAwait(false); + public async Task TradeSetAsync() => await RespondWithModalAsync(TradeModalId).ConfigureAwait(false); + + private const string TradeTextModalId = "trade-text"; + [SlashCommand("text", "Trade a Base64 text file input.")] + [RequireQueueRole(PokeRoutineType.LinkTrade)] + public async Task TradeTextAsync() => await RespondWithModalAsync(TradeTextModalId).ConfigureAwait(false); - public class TradeSetModal : IModal + /// + /// Handles the submission of the , validating the input and processing the trade. + /// + [ModalInteraction(TradeModalId, ignoreGroupNames: true)] + public async Task TradeSetModalAsync(TradeSetModal modal) { - public string Title => "Trade Showdown Set"; + // Re-check if the queue closed in the time between opening the modal and entering the info. + if (await CheckQueue().ConfigureAwait(false)) return; - [InputLabel("Showdown Set")] - [ModalTextInput("showdown", TextInputStyle.Paragraph, placeholder: "Paste your Showdown set here...")] - public string Showdown { get; set; } = string.Empty; + // Sanity check their inputs. + var code = modal.Code; + + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; + + // Convert, then join the queue. + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var tradeCode = string.IsNullOrWhiteSpace(code) + ? Info.GetRandomTradeCode() + : int.Parse(code); // already validated above, so safe to parse - [RequiredInput(false)] - [InputLabel("Trade Code (optional)")] - [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] - public string? Code { get; set; } + await TradeTextAsync(tradeCode, modal.Showdown, Context).ConfigureAwait(false); } - [ModalInteraction(TradeModalId, ignoreGroupNames: true)] - public async Task TradeSetModalAsync(TradeSetModal modal) + /// + /// Handles the submission of the , which allows users to trade a Base64 text file input. + /// + [ModalInteraction(TradeTextModalId, ignoreGroupNames: true)] + public async Task TradeTextModalAsync(TradeBase64Modal modal) { // Re-check if the queue closed in the time between opening the modal and entering the info. - if (!SysCordSettings.HubConfig.Queues.CanQueue) - { - await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); + if (!await CheckQueue().ConfigureAwait(false)) return; - } // Sanity check their inputs. var code = modal.Code; - var showdown = modal.Showdown; - - if (!string.IsNullOrWhiteSpace(code)) - { - if (!int.TryParse(code, out var parsed)) - { - await RespondAsync("The trade code must be a valid integer.", ephemeral: true).ConfigureAwait(false); - return; - } - // Check if it is within the valid range for trade codes (0-99999999) - if (parsed is < 0 or > 99999999) - { - await RespondAsync("The trade code must be between 0 and 99999999.", ephemeral: true).ConfigureAwait(false); - return; - } - } + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; // Convert, then join the queue. await DeferAsync(ephemeral: true).ConfigureAwait(false); @@ -71,9 +72,10 @@ public async Task TradeSetModalAsync(TradeSetModal modal) ? Info.GetRandomTradeCode() : int.Parse(code); // already validated above, so safe to parse - await TradeShowdownAsync(tradeCode, showdown, Context).ConfigureAwait(false); + await TradeTextAsync(tradeCode, modal.Base64, Context).ConfigureAwait(false); } + // Can't have a modal with file attachment, use regular slash command instead. [SlashCommand("file", "Trade a Pokémon file.")] [RequireQueueRole(PokeRoutineType.LinkTrade)] public async Task TradeFileAsync( @@ -81,16 +83,16 @@ public async Task TradeFileAsync( [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { // Re-check if the queue closed in the time between opening the modal and entering the info. - if (!SysCordSettings.HubConfig.Queues.CanQueue) - { - await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); + if (!await CheckQueue().ConfigureAwait(false)) + return; + + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) return; - } // Match the set->file trade command, which can take longer than 3 seconds for some hosts with slower computers. await DeferAsync(ephemeral: true).ConfigureAwait(false); + var tradeCode = code ?? Info.GetRandomTradeCode(); // already validated above, so safe to take if provided - var tradeCode = code ?? Info.GetRandomTradeCode(); await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); } @@ -123,59 +125,51 @@ public async Task BanTradeAsync( [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, [Summary(nameof(reason), "The reason for banning the user.")] string reason) { + // Display not-ephemeral message to the sudo user, since this is a sudo command and they should be aware of the action being taken. await DeferAsync().ConfigureAwait(false); SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew(GetReference(nnid, reason)); await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); } - private async Task TradeShowdownAsync(int code, string content, IInteractionContext user) + private async Task TradeTextAsync(int code, string content, IInteractionContext user) { content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); - var template = AutoLegalityWrapper.GetTemplate(set); - if (set.InvalidLines.Count != 0 || set.Species is 0) + + // Try parsing as any language. + if (!ShowdownParsing.TryParseAnyLanguage(content, out var set)) { - var sb = new StringBuilder(128); - sb.AppendLine("Unable to parse Showdown Set."); - var invalidlines = set.InvalidLines; - if (invalidlines.Count != 0) + // Try as base64 byte[] input. + try { - var localization = BattleTemplateParseErrorLocalization.Get(); - sb.AppendLine("Invalid lines detected:"); - AddInvalidLines(invalidlines, localization, sb); + var convert = Convert.FromBase64String(content); + var pk = EntityFormat.GetFromBytes(convert, new T().Context); + if (pk is not null && EntityConverter.ConvertToType(pk, typeof(T), out _) is T pkOfT) + { + await AddTradeToQueueAsync(code, pkOfT).ConfigureAwait(false); + return; + } + } + catch + { + // Probably not base64, or not a valid PKM file. Ignore and return the error message. } - if (set.Species is 0) - sb.AppendLine("Species could not be identified. Check your spelling."); - var msg = sb.ToString(); - await FollowupAsync(msg, ephemeral: true).ConfigureAwait(false); + await FollowupAsync("Unable to detect valid data. Please check your inputs.").ConfigureAwait(false); return; } - try + // Interpret set via Auto-Legality Mod, which can ingest some of the unhandled lines. + var template = AutoLegalityWrapper.GetTemplate(set); + if (set.InvalidLines.Count != 0 || set.Species is 0) { - var sav = AutoLegalityWrapper.GetTrainerInfo(); - var pkm = sav.GetLegal(template, out var result); - var la = new LegalityAnalysis(pkm); - var spec = GameInfo.Strings.Species[template.Species]; - pkm = EntityConverter.ConvertToType(pkm, typeof(T), out _) ?? pkm; - if (pkm is not T pk || !la.Valid) - { - var reason = result switch - { - "Timeout" => $"That {spec} set took too long to generate.", - "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", - _ => $"I wasn't able to create a {spec} from that set.", - }; - var imsg = $"Oops! {reason}"; - if (result == "Failed") - imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; - await FollowupAsync(imsg).ConfigureAwait(false); - return; - } + var msg = GetInvalidSetMessage(set); + await FollowupAsync(msg).ConfigureAwait(false); + return; + } - pk.ResetPartyStats(); - await AddTradeToQueueAsync(code, pk, user).ConfigureAwait(false); + try + { + await TradeShowdownAsync(code, template, user).ConfigureAwait(false); } catch (Exception ex) { @@ -188,6 +182,35 @@ private async Task TradeShowdownAsync(int code, string content, IInteractionCont } } + private static T? GetRequest(Download dl) + { + if (!dl.Success) + return null; + return dl.Data switch + { + null => null, + T pk => pk, + _ => EntityConverter.ConvertToType(dl.Data, typeof(T), out _) as T, + }; + } + + private static string GetInvalidSetMessage(ShowdownSet set) + { + var sb = new StringBuilder(128); + sb.AppendLine("Unable to parse Showdown Set."); + var invalidlines = set.InvalidLines; + if (invalidlines.Count != 0) + { + var localization = BattleTemplateParseErrorLocalization.Get(); + sb.AppendLine("Invalid lines detected:"); + AddInvalidLines(invalidlines, localization, sb); + } + if (set.Species is 0) + sb.AppendLine("Species could not be identified. Check your spelling."); + + return sb.ToString(); + } + private static void AddInvalidLines(IReadOnlyList invalidlines, BattleTemplateParseErrorLocalization localization, StringBuilder sb) { // Build a string of the invalid lines with their human-readable error messages @@ -201,6 +224,15 @@ private static void AddInvalidLines(IReadOnlyList inva sb.Append(ReusableActions.FormatSetCode(inner.ToString())); } + private async Task CheckQueue() + { + if (SysCordSettings.HubConfig.Queues.CanQueue) + return true; + await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); + return false; + + } + private async Task TradeAttachmentAsync(int code, IAttachment attachment, IInteractionContext user) { var att = await attachment.DownloadEntityAsync().ConfigureAwait(false); @@ -211,22 +243,37 @@ private async Task TradeAttachmentAsync(int code, IAttachment attachment, IInter return; } - await AddTradeToQueueAsync(code, pk, user).ConfigureAwait(false); + await AddTradeToQueueAsync(code, pk).ConfigureAwait(false); } - private static T? GetRequest(Download dl) + private async Task TradeShowdownAsync(int code, IBattleTemplate template, IInteractionContext user) { - if (!dl.Success) - return null; - return dl.Data switch + var sav = AutoLegalityWrapper.GetTrainerInfo(); + var pkm = sav.GetLegal(template, out var result); + var la = new LegalityAnalysis(pkm); + var spec = GameInfo.Strings.Species[template.Species]; + pkm = EntityConverter.ConvertToType(pkm, typeof(T), out _) ?? pkm; + if (pkm is not T pk || !la.Valid) { - null => null, - T pk => pk, - _ => EntityConverter.ConvertToType(dl.Data, typeof(T), out _) as T, - }; + var reason = result switch + { + "Timeout" => $"That {spec} set took too long to generate.", + "VersionMismatch" => "Request refused: PKHeX and Auto-Legality Mod version mismatch.", + _ => $"I wasn't able to create a {spec} from that set.", + }; + var imsg = $"Oops! {reason}"; + if (result == "Failed") + imsg += $"\n{AutoLegalityWrapper.GetLegalizationHint(template, sav, pkm)}"; + + await FollowupAsync(imsg).ConfigureAwait(false); + return; + } + + pk.ResetPartyStats(); + await AddTradeToQueueAsync(code, pk).ConfigureAwait(false); } - private async Task AddTradeToQueueAsync(int code, T pk, IInteractionContext context) + private async Task AddTradeToQueueAsync(int code, T pk) { var la = new LegalityAnalysis(pk); if (!la.Valid) @@ -254,7 +301,34 @@ private async Task AddTradeToQueueAsync(int code, T pk, IInteractionContext cont return; } - var sig = GetSignificance(context.User); - await QueueHelper.AddToQueueAsync(Context, code, sig, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific, context).ConfigureAwait(false); + await QueueHelper.AddToQueueAsync(Context, code, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific).ConfigureAwait(false); } } + +public class TradeSetModal : IModal +{ + public string Title => "Trade Showdown Set"; + + [InputLabel("Showdown Set")] + [ModalTextInput("showdown", TextInputStyle.Paragraph, placeholder: "Paste your Showdown set here...")] + public string Showdown { get; set; } = string.Empty; + + [RequiredInput(false)] + [InputLabel("Trade Code (optional)")] + [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] + public string? Code { get; set; } +} + +public class TradeBase64Modal : IModal +{ + public string Title => "Trade Base64 File"; + + [InputLabel("Base64 Text")] + [ModalTextInput("base64", TextInputStyle.Paragraph, placeholder: "Paste the Base64 text here...")] + public string Base64 { get; set; } = string.Empty; + + [RequiredInput(false)] + [InputLabel("Trade Code (optional)")] + [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] + public string? Code { get; set; } +} diff --git a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs index 5728eac78..54e9ecd23 100644 --- a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs +++ b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs @@ -11,7 +11,7 @@ public static class AutoLegalityExtensionsDiscord { extension(SocketInteractionContext context) { - public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) + public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set, LanguageID displayLanguage) { if (set.Species == 0) { @@ -42,33 +42,33 @@ public async Task ReplyWithLegalizedSetAsync(ITrainerInfo sav, ShowdownSet set) } var message = $"Here's your ({result}) legalized PKM for {species} ({la.EncounterOriginal.Name})!"; - var formatted = ReusableActions.GetFormattedShowdownText(pk); + var formatted = ReusableActions.GetFormattedShowdownText(pk, displayLanguage); await context.SendFileAsync(pk, $"{message}\n{formatted}").ConfigureAwait(false); } catch (Exception ex) { LogUtil.LogSafe(ex); - var formatted = ReusableActions.FormatSetCode(set); + var formatted = ReusableActions.FormatSetCode(set, displayLanguage); var message = $"Oops! An unexpected problem happened with this Showdown Set:\n{formatted}"; // No need for everyone to see their goofy set. await context.Interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } - public async Task ReplyWithLegalizedSetAsync(string content, GameVersion version) + public async Task ReplyWithLegalizedSetAsync(string content, GameVersion version, LanguageID displayLanguage = LanguageID.English) { content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); + var set = ShowdownParsing.GetShowdownSet(content, out _); var tr = AutoLegalityWrapper.GetTrainerInfo(version); - await context.ReplyWithLegalizedSetAsync(tr, set).ConfigureAwait(false); + await context.ReplyWithLegalizedSetAsync(tr, set, displayLanguage).ConfigureAwait(false); } - public async Task ReplyWithLegalizedSetAsync(string content) where T : PKM, new() + public async Task ReplyWithLegalizedSetAsync(string content, LanguageID displayLanguage = LanguageID.English) where T : PKM, new() { content = ReusableActions.StripCodeBlock(content); - var set = new ShowdownSet(content); + var set = ShowdownParsing.GetShowdownSet(content, out _); var tr = AutoLegalityWrapper.GetTrainerInfo(); - await context.ReplyWithLegalizedSetAsync(tr, set).ConfigureAwait(false); + await context.ReplyWithLegalizedSetAsync(tr, set, displayLanguage).ConfigureAwait(false); } public async Task ReplyWithLegalizedSetAsync(IAttachment attachment) diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs index 0bcd04ba0..9209aacfb 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Discord; using PKHeX.Core; +using SysBot.Base; namespace SysBot.Pokemon.Discord; @@ -54,10 +55,13 @@ public async Task TradeFinished(PokeRoutineExecutor routine, PokeTradeDetail< await SendNotification(message).ConfigureAwait(false); if (result.Species != 0 && Hub.Config.Discord.ReturnPKMs) await Trader.SendFilePrivatelyAsync(result, "Here's what you traded me!").ConfigureAwait(false); + + LogUtil.LogInfo($"Total time since queueing: {info.Age:g}"); } public async Task SendNotification(PokeRoutineExecutor routine, PokeTradeDetail info, string message) => await SendNotification(message).ConfigureAwait(false); + private async Task SendNotification(string message, Embed? embed = null) { // Discord makes all interaction modals stale after 15 minutes. diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index 64556f2d7..c5a618d6f 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -1,6 +1,5 @@ using System.Threading.Tasks; using Discord; -using Discord.Interactions; using Discord.Net; using PKHeX.Core; @@ -8,20 +7,12 @@ namespace SysBot.Pokemon.Discord; public static class QueueHelper where T : PKM, new() { - private const uint MaxTradeCode = 9999_9999; - - public static async Task AddToQueueAsync(SocketInteractionContext context, int code, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type, IInteractionContext trader) + public static async Task AddToQueueAsync(IInteractionContext context, int code, T trade, PokeRoutineType routine, PokeTradeType type) { - if ((uint)code > MaxTradeCode) - { - await context.Interaction.FollowupAsync("Trade code should be 00000000-99999999!").ConfigureAwait(false); - return; - } - QueueJoinResult? check = null; try { - check = AddToTradeQueue(trader, trade, code, sig, routine, type); + check = AddToTradeQueue(context, trade, code, routine, type); var result = check.Result; if (!result) { @@ -53,17 +44,18 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c } Task toMessage; + var user = context.Interaction.User; var sprite = ReusableActions.GetSprite?.Invoke(trade); if (sprite is not null) { const string fileName = "sprite.png"; var thumb = new FileAttachment(sprite, fileName); builder.WithThumbnailUrl($"attachment://{fileName}"); - toMessage = trader.Interaction.User.SendFileAsync(thumb, text: secret, embed: builder.Build()); + toMessage = user.SendFileAsync(thumb, text: secret, embed: builder.Build()); } else { - toMessage = trader.Interaction.User.SendMessageAsync(text: secret, embed: builder.Build()); + toMessage = user.SendMessageAsync(text: secret, embed: builder.Build()); } var msg = await toMessage.ConfigureAwait(false); @@ -71,18 +63,17 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c await sprite.DisposeAsync().ConfigureAwait(false); // Keep a public log of them joining the queue. - await context.Interaction.Channel.SendMessageAsync($"{trader.User.Mention} - {check.Message}").ConfigureAwait(false); + await context.Channel.SendMessageAsync($"{context.User.Mention} - {check.Message}").ConfigureAwait(false); // Update the ephemeral command message to backlink to the DM we just sent the user. await context.Interaction.FollowupAsync($"Success! Please check your direct messages: {msg.GetJumpUrl()}").ConfigureAwait(false); // All further communication is in Direct Messages to the user (no further input needed). + check.Join.Trade.IsReady = true; // If we failed, we'd remove (see below). Mark it as ready to trade. } catch (HttpException ex) { - await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); - // They might have been added to the queue with DMs off; dequeue them. - + // They might have been added to the queue with DMs off; dequeue them immediately if so. if (check?.Result is true) { var detail = check.Join; @@ -91,12 +82,14 @@ public static async Task AddToQueueAsync(SocketInteractionContext context, int c info.Remove(detail); // Already followed up in the above event handling. } + + await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); } } private sealed record QueueJoinResult(bool Result, TradeEntry Join, string Message); - private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, int code, RequestSignificance sig, PokeRoutineType routine, PokeTradeType type) + private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, int code, PokeRoutineType routine, PokeTradeType type) { var channel = trader.Channel; var user = trader.User; @@ -104,6 +97,7 @@ private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, var name = user.Username; var trainer = new PokeTradeTrainerInfo(name, userId); var notifier = new DiscordTradeNotifier(pk, trainer, code, trader); + var sig = trader.GetSignificance(); var detail = new PokeTradeDetail { Type = type, @@ -134,16 +128,17 @@ private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, var eta = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); message += $" Estimated: {eta:F1} minutes."; } + // Don't mark as ready yet; notifying the user may fail (DMs disabled). If so, we'll remove from the queue and not mark as ready. return new(true, trade, message); } - private static async Task HandleDiscordExceptionAsync(SocketInteractionContext context, HttpException ex) + private static async Task HandleDiscordExceptionAsync(IInteractionContext context, HttpException ex) { string message = string.Empty; switch (ex.DiscordCode) { case DiscordErrorCode.InsufficientPermissions or DiscordErrorCode.MissingPermissions: - var channel = context.Interaction.Channel; + var channel = context.Channel; IGuild? guild = context.Guild; if (guild is not null && channel is IGuildChannel guildChannel) { diff --git a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs new file mode 100644 index 000000000..5dee3dbf8 --- /dev/null +++ b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs @@ -0,0 +1,79 @@ +using System.Linq; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; + +namespace SysBot.Pokemon.Discord; + +public static class QueueRestrictions +{ + private const uint MaxTradeCode = 9999_9999; + private static DiscordManager Manager => SysCordSettings.Manager; + + /// The interaction context. + extension(IInteractionContext context) + { + /// + /// Checks if the user provided trade code is valid or empty. If invalid, responds to the interaction with an error message to the user. + /// + /// The trade code to check. + /// True if the trade code is valid or empty, false otherwise. + public async Task IsTradeCodeValidOrEmpty(string? code) + { + if (string.IsNullOrWhiteSpace(code)) + return true; // can be null or empty, which means random code will be generated + + // Check if it is within the valid range for trade codes (0-99999999) + if (uint.TryParse(code, out var parsed) && parsed <= MaxTradeCode) + return true; + + await context.Interaction.RespondAsync($"The trade code must be between 0 and {MaxTradeCode}.", ephemeral: true).ConfigureAwait(false); + return false; + } + + /// + /// Checks if the user provided trade code is valid or empty. If invalid, responds to the interaction with an error message to the user. + /// + /// The trade code to check. + /// True if the trade code is valid or empty, false otherwise. + public async Task IsTradeCodeValidOrEmpty(int? code) + { + if (code is null) + return true; // can be null or empty, which means random code will be generated + + // Check if it is within the valid range for trade codes (0-99999999) + if ((uint)code.Value <= MaxTradeCode) + return true; + + await context.Interaction.RespondAsync($"The trade code must be between 0 and {MaxTradeCode}.", ephemeral: true).ConfigureAwait(false); + return false; + } + + public RequestSignificance GetSignificance() => context.User.GetSignificance(); + } + + extension(IUser user) + { + /// + /// Gets the significance of the user based on their ID and roles. + /// + public RequestSignificance GetSignificance() + { + // Check user ID. + var userId = user.Id; + if (userId == Manager.Owner) + return RequestSignificance.Owner; + + // Don't check Team membership for special favor. + + if (Manager.CanUseSudo(userId)) + return RequestSignificance.Favored; + + // Check roles, might be a special role granted. + // Stringy names are for user convenience; must trust externally managed guilds the bot is added to (else we should use role IDs). + return user is SocketGuildUser g + ? Manager.GetSignificance(g.Roles.Select(z => z.Name)) + : RequestSignificance.None; + } + } +} diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index bbf9683d8..977462aa0 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -10,6 +10,10 @@ namespace SysBot.Pokemon.Discord; public static class ReusableActions { + /// + /// Weak binding to a function that returns a sprite png data stream for a given PKM. + /// This is used to provide a thumbnail in Discord messages. + /// public static Func? GetSprite { get; set; } public static string GetModuleName(string name) @@ -73,11 +77,11 @@ public FileAttachment ToFileAttachment() } } - public static string GetFormattedShowdownText(PKM pk, LanguageID language = LanguageID.English) + public static string GetFormattedShowdownText(PKM pk, LanguageID displayLanguage = LanguageID.English) { var config = BattleTemplateConfig.Showdown; - var settings = new BattleTemplateExportSettings(config, language); + var settings = new BattleTemplateExportSettings(config, displayLanguage); var showdown = ShowdownParsing.GetShowdownText(pk, settings); return FormatSetCode(showdown); @@ -89,17 +93,18 @@ public static string GetFormattedShowdownText(PKM pk, LanguageID language = Lang public static string FormatSetCode(string set) => Format.Code(set, CodeLanguage); public static string FormatSetCode(IEnumerable lines) => FormatSetCode(string.Join('\n', lines)); - public static string FormatSetCode(ShowdownSet set, LanguageID language = Language) + public static string FormatSetCode(ShowdownSet set, LanguageID displayLanguage = Language) { var config = BattleTemplateConfig.Showdown; - var settings = new BattleTemplateExportSettings(config, language); + var settings = new BattleTemplateExportSettings(config, displayLanguage); var lines = set.GetSetLines(settings); return FormatSetCode(lines); } public static string FormatSetCode(T trade, LanguageID language = Language) where T : PKM, new() { - var set = new ShowdownSet(trade); + var localization = BattleTemplateLocalization.GetLocalization(Language); + var set = new ShowdownSet(trade, localization); return FormatSetCode(set, language); } diff --git a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs index 0e486cf10..508f67050 100644 --- a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs +++ b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs @@ -24,10 +24,9 @@ public static bool AddToWaitingList(string showdownSet, string display, string u return false; } - var set = ShowdownUtil.ConvertToShowdown(showdownSet); - if (set == null) + if (!ShowdownUtil.TryConvertSingleLine(showdownSet, out var set)) { - message = $"Skipping trade, @{username}: Empty nickname provided for the species."; + message = $"Skipping trade, @{username}: Invalid/Empty nickname provided for the species."; return false; } var template = AutoLegalityWrapper.GetTemplate(set); diff --git a/SysBot.Pokemon.Twitch/TwitchBot.cs b/SysBot.Pokemon.Twitch/TwitchBot.cs index 3f3cff4e2..0869cd343 100644 --- a/SysBot.Pokemon.Twitch/TwitchBot.cs +++ b/SysBot.Pokemon.Twitch/TwitchBot.cs @@ -119,13 +119,17 @@ private bool AddToTradeQueue(T pk, int code, OnWhisperReceivedArgs e, RequestSig }; var trade = new TradeEntry(detail, userId, type, name); - var added = Info.AddToTradeQueue(trade, userId, sig.IsOwner); - if (added == QueueResultAdd.AlreadyInQueue) + var canAdd = Info.IsAbleToJoinQueue(trade, userId, sig.IsOwner); + if (canAdd != QueueResultAdd.CanAdd) { - message = $"@{name}: Sorry, you are already in the queue."; + if (canAdd == QueueResultAdd.AlreadyInQueue) + message = $"@{name}: Sorry, you are already in the queue."; + else + message = $"@{name}: Sorry, can't add you."; return false; } + Info.AddToTradeQueue(trade, userId, sig.IsOwner); var position = Info.CheckPosition(userId, type); message = $"@{name}: Added to the {type} queue, unique ID: {detail.Id}. Current Position: {position.Position}"; @@ -135,6 +139,8 @@ private bool AddToTradeQueue(T pk, int code, OnWhisperReceivedArgs e, RequestSig var eta = Info.Hub.Config.Queues.EstimateDelay(position.Position, botct); message += $". Estimated: {eta:F1} minutes."; } + + detail.IsReady = true; // Now that we've messaged the user, the trade is ready for a bot to pick up. return true; } diff --git a/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs b/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs index 0cfb42196..4c0d872f4 100644 --- a/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs +++ b/SysBot.Pokemon/Actions/PokeRoutineExecutor.cs @@ -163,7 +163,7 @@ protected async Task CheckPartnerReputation(PokeRoutineExecutor var previous = list.TryGetPreviousNID(TrainerNID); if (previous != null) { - var delta = DateTime.Now - previous.Time; // Time that has passed since last trade. + var delta = DateTime.UtcNow - previous.Time; // Time that has passed since last trade. Log($"Last traded with {user.TrainerName} {delta.TotalMinutes:F1} minutes ago (OT: {TrainerName})."); // Allows setting a cooldown for repeat trades. If the same user is encountered within the cooldown period for the same trade type, the user is warned and the trade will be ignored. diff --git a/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs b/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs index 45ff5bb10..f4607ea1a 100644 --- a/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs +++ b/SysBot.Pokemon/BDSP/BotTrade/PokeTradeBotBS.cs @@ -627,10 +627,10 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d { int ctr = 0; var time = TimeSpan.FromSeconds(Hub.Config.Trade.MaxDumpTradeTime); - var start = DateTime.Now; + var start = DateTime.UtcNow; var bctr = 0; - while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.Now - start < time) + while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.UtcNow - start < time) { // We're no longer talking, so they probably quit on us. if (!await IsUnionWork(UnionTalkingOffset, token).ConfigureAwait(false)) diff --git a/SysBot.Pokemon/Helpers/RemoteControlAccess.cs b/SysBot.Pokemon/Helpers/RemoteControlAccess.cs index 75b0383df..61efb07fe 100644 --- a/SysBot.Pokemon/Helpers/RemoteControlAccess.cs +++ b/SysBot.Pokemon/Helpers/RemoteControlAccess.cs @@ -49,26 +49,36 @@ public class RemoteControlAccessList /// Adds new items if not already present by . /// /// List of items to add - public void AddIfNew(params ReadOnlySpan list) + public bool AddIfNew(params ReadOnlySpan list) { + bool result = false; foreach (var item in list) { - if (!Contains(item.ID)) - List.Add(item); + if (Contains(item.ID)) + continue; + + List.Add(item); + result = true; } + return result; } /// /// Adds new items if not already present by . /// /// List of items to add - public void AddIfNew(IEnumerable list) + public bool AddIfNew(IEnumerable list) { + bool result = false; foreach (var item in list) { - if (!Contains(item.ID)) - List.Add(item); + if (Contains(item.ID)) + continue; + + List.Add(item); + result = true; } + return result; } /// diff --git a/SysBot.Pokemon/Helpers/ShowdownUtil.cs b/SysBot.Pokemon/Helpers/ShowdownUtil.cs index dfe2462a7..a6cdea642 100644 --- a/SysBot.Pokemon/Helpers/ShowdownUtil.cs +++ b/SysBot.Pokemon/Helpers/ShowdownUtil.cs @@ -1,4 +1,5 @@ -using PKHeX.Core; +using System.Diagnostics.CodeAnalysis; +using PKHeX.Core; namespace SysBot.Pokemon; @@ -8,9 +9,11 @@ public static class ShowdownUtil /// Converts a single line to a showdown set /// /// single string - /// ShowdownSet object - public static ShowdownSet? ConvertToShowdown(string setstring) + /// output ShowdownSet object + /// True if conversion was successful, otherwise false + public static bool TryConvertSingleLine(string setstring, [NotNullWhen(true)] out ShowdownSet? set) { + set = null; // LiveStreams remove new lines, so we are left with a single line set var restorenick = string.Empty; @@ -19,7 +22,7 @@ public static class ShowdownUtil { restorenick = setstring[..(nickIndex + 1)]; if (restorenick.TrimStart().StartsWith('(')) - return null; + return false; setstring = setstring[(nickIndex + 1)..]; } @@ -30,13 +33,17 @@ public static class ShowdownUtil } var finalset = restorenick + setstring; - return new ShowdownSet(finalset); + + // The split table below only supports English, so we don't need to try parsing in all languages. + var localization = BattleTemplateLocalization.GetLocalization(LanguageID.English); + set = new ShowdownSet(finalset, localization); + return set.Species != 0; } private static readonly string[] splittables = [ "Ability:", "EVs:", "IVs:", "Shiny:", "Gigantamax:", "Ball:", "- ", "Level:", - "Happiness:", "Language:", "OT:", "OTGender:", "TID:", "SID:", "Alpha:", "Tera Type:", + "Happiness:", "Friendship", "Language:", "OT:", "OTGender:", "TID:", "SID:", "Alpha:", "Tera Type:", "Adamant Nature", "Bashful Nature", "Brave Nature", "Bold Nature", "Calm Nature", "Careful Nature", "Docile Nature", "Gentle Nature", "Hardy Nature", "Hasty Nature", "Impish Nature", "Jolly Nature", "Lax Nature", "Lonely Nature", "Mild Nature", diff --git a/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs b/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs index 00809cff9..c6764a3e5 100644 --- a/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs +++ b/SysBot.Pokemon/LA/BotTrade/PokeTradeBotLA.cs @@ -493,11 +493,11 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d { int ctr = 0; var time = TimeSpan.FromSeconds(Hub.Config.Trade.MaxDumpTradeTime); - var start = DateTime.Now; + var start = DateTime.UtcNow; var pkprev = new PA8(); var bctr = 0; - while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.Now - start < time) + while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.UtcNow - start < time) { if (await IsOnOverworld(OverworldOffset, token).ConfigureAwait(false)) break; diff --git a/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs b/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs index 011f45106..b4ebfa7e3 100644 --- a/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs +++ b/SysBot.Pokemon/LZA/BotTrade/PokeTradeBotLZA.cs @@ -646,11 +646,11 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d { int dumped = 0; var time = TimeSpan.FromSeconds(Hub.Config.Trade.MaxDumpTradeTime); - var start = DateTime.Now; + var start = DateTime.UtcNow; var pkprev = new PA9(); var pressB = 0; - while (dumped < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.Now - start < time) + while (dumped < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.UtcNow - start < time) { if (!await IsOnMenu(MenuState.InBox, token).ConfigureAwait(false)) break; diff --git a/SysBot.Pokemon/Queues/QueueResultAdd.cs b/SysBot.Pokemon/Queues/QueueResultAdd.cs index d3c46d358..802de75af 100644 --- a/SysBot.Pokemon/Queues/QueueResultAdd.cs +++ b/SysBot.Pokemon/Queues/QueueResultAdd.cs @@ -1,10 +1,13 @@ -namespace SysBot.Pokemon; +namespace SysBot.Pokemon; public enum QueueResultAdd { /// Successfully added to the queue. Added, + /// Can add to the queue, but was not added yet. + CanAdd, + /// Did not add; was already in the queue. AlreadyInQueue, } diff --git a/SysBot.Pokemon/Queues/TradeQueueInfo.cs b/SysBot.Pokemon/Queues/TradeQueueInfo.cs index 279af15b5..2b54fd922 100644 --- a/SysBot.Pokemon/Queues/TradeQueueInfo.cs +++ b/SysBot.Pokemon/Queues/TradeQueueInfo.cs @@ -15,6 +15,13 @@ public sealed record TradeQueueInfo(PokeTradeHub Hub) where T : PKM, new() { private readonly Lock _sync = new(); + + /// + /// Currently queued users, including those currently being handled via trade bots (actively trading). + /// + /// + /// We need to keep track of users currently being traded. They can re-join AFTER their trade completes. + /// private readonly List> _queue = []; public readonly PokeTradeHub Hub = Hub; @@ -103,7 +110,7 @@ public QueueResultRemove ClearTrade(ulong userId) return ClearTrade(details); } - private QueueResultRemove ClearTrade(ICollection> details) + private QueueResultRemove ClearTrade(IReadOnlyCollection> details) { if (details.Count == 0) return QueueResultRemove.NotInQueue; @@ -158,12 +165,10 @@ public string[] GetUserList(string format) private static string FormatUser(string format, TradeEntry z) => string.Format(format, z.Trade.Id, z.Trade.Code, z.Trade.Type, z.Username, (Species)z.Trade.TradeData.Species); - public IList> GetIsUserQueued(Func, bool> match) + public IReadOnlyList> GetIsUserQueued(Func, bool> match) { lock (_sync) - { return [.. _queue.Where(match)]; - } } public bool Remove(TradeEntry detail) @@ -175,22 +180,35 @@ public bool Remove(TradeEntry detail) } } + public QueueResultAdd IsAbleToJoinQueue(TradeEntry trade, ulong userId, bool sudo = false) + { + lock (_sync) + { + if (_queue.Any(z => z.UserID == userId) && !sudo) + return QueueResultAdd.AlreadyInQueue; + return QueueResultAdd.CanAdd; + } + } + public QueueResultAdd AddToTradeQueue(TradeEntry trade, ulong userId, bool sudo = false) { lock (_sync) { + // Check again. The check above should immediately precede an Add operation, but ya never know. if (_queue.Any(z => z.UserID == userId) && !sudo) return QueueResultAdd.AlreadyInQueue; + // Update the trade data based on settings. if (Hub.Config.Legality.ResetHOMETracker && trade.Trade.TradeData is IHomeTrack t) t.Tracker = 0; + // Enqueue with the proper priority. var priority = sudo ? PokeTradePriorities.Tier1 : PokeTradePriorities.TierFree; var queue = Hub.Queues.GetQueue(trade.Type); - queue.Enqueue(trade.Trade, priority); _queue.Add(trade); + // Once the trade is finished, remove the user from the list of currently queued users. trade.Trade.Notifier.OnFinish = _ => Remove(trade); return QueueResultAdd.Added; } diff --git a/SysBot.Pokemon/Queues/TradeQueueManager.cs b/SysBot.Pokemon/Queues/TradeQueueManager.cs index 8f6842e2f..4b902bd17 100644 --- a/SysBot.Pokemon/Queues/TradeQueueManager.cs +++ b/SysBot.Pokemon/Queues/TradeQueueManager.cs @@ -63,18 +63,18 @@ public bool TryDequeueLedy(out PokeTradeDetail detail, bool force = false) return true; } - public bool TryDequeue(PokeRoutineType type, out PokeTradeDetail detail, out uint priority) + public bool TryDequeue(PokeRoutineType type, out PokeTradeDetail detail, out uint priority, bool checkReady = true) { if (type == PokeRoutineType.FlexTrade) return GetFlexDequeue(out detail, out priority); - return TryDequeueInternal(type, out detail, out priority); + return TryDequeueInternal(type, out detail, out priority, checkReady); } - private bool TryDequeueInternal(PokeRoutineType type, out PokeTradeDetail detail, out uint priority) + private bool TryDequeueInternal(PokeRoutineType type, out PokeTradeDetail detail, out uint priority, bool checkReady = true) { var queue = GetQueue(type); - return queue.TryDequeue(out detail, out priority); + return queue.TryDequeue(out detail, out priority, checkReady); } private bool GetFlexDequeue(out PokeTradeDetail detail, out uint priority) @@ -85,7 +85,7 @@ private bool GetFlexDequeue(out PokeTradeDetail detail, out uint priority) return GetFlexDequeueWeighted(cfg, out detail, out priority); } - private bool GetFlexDequeueWeighted(QueueSettings cfg, out PokeTradeDetail detail, out uint priority) + private bool GetFlexDequeueWeighted(QueueSettings cfg, out PokeTradeDetail detail, out uint priority, bool checkReady = true) { PokeTradeQueue? preferredQueue = null; long bestWeight = 0; // prefer higher weights @@ -120,7 +120,7 @@ private bool GetFlexDequeueWeighted(QueueSettings cfg, out PokeTradeDetail de return false; } - return preferredQueue.TryDequeue(out detail, out priority); + return preferredQueue.TryDequeue(out detail, out priority, checkReady); } private bool GetFlexDequeueOld(out PokeTradeDetail detail, out uint priority) diff --git a/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs b/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs index 5af919dcf..dc38f1309 100644 --- a/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs +++ b/SysBot.Pokemon/SV/BotTrade/PokeTradeBotSV.cs @@ -748,11 +748,11 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d { int ctr = 0; var time = TimeSpan.FromSeconds(Hub.Config.Trade.MaxDumpTradeTime); - var start = DateTime.Now; + var start = DateTime.UtcNow; var pkprev = new PK9(); var bctr = 0; - while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.Now - start < time) + while (ctr < Hub.Config.Trade.MaxDumpsPerTrade && DateTime.UtcNow - start < time) { if (!await IsInBox(PortalOffset, token).ConfigureAwait(false)) break; diff --git a/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs b/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs index e5e73f3ae..31ec88f6f 100644 --- a/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs +++ b/SysBot.Pokemon/SWSH/BotTrade/PokeTradeBotSWSH.cs @@ -588,10 +588,10 @@ private async Task ProcessDumpTradeAsync(PokeTradeDetail d { int ctr = 0; var time = TimeSpan.FromSeconds(hub.Config.Trade.MaxDumpTradeTime); - var start = DateTime.Now; + var start = DateTime.UtcNow; var pkprev = new PK8(); var bctr = 0; - while (ctr < hub.Config.Trade.MaxDumpsPerTrade && DateTime.Now - start < time) + while (ctr < hub.Config.Trade.MaxDumpsPerTrade && DateTime.UtcNow - start < time) { if (await IsOnOverworld(OverworldOffset, token).ConfigureAwait(false)) break; diff --git a/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs b/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs index b4c08879d..55099720c 100644 --- a/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs @@ -201,7 +201,7 @@ private void GenerateUsersInQueue(int count) private void GenerateWaitedTime(DateTime time) { - var now = DateTime.Now; + var now = DateTime.UtcNow; var difference = now - time; var value = difference.ToString(WaitedTimeFormat); File.WriteAllText("waited.txt", value); @@ -217,7 +217,7 @@ private void GenerateWaitedTime(DateTime time) File.WriteAllText("estimatedTime.txt", wait); // Expected to be fulfilled at this time - var now = DateTime.Now; + var now = DateTime.UtcNow; var difference = now.AddMinutes(estimate); var date = difference.ToString(EstimatedFulfillmentFormat); File.WriteAllText("estimatedTimestamp.txt", date); diff --git a/SysBot.Pokemon/Settings/QueueSettings.cs b/SysBot.Pokemon/Settings/QueueSettings.cs index 847d599c7..8e1a6b365 100644 --- a/SysBot.Pokemon/Settings/QueueSettings.cs +++ b/SysBot.Pokemon/Settings/QueueSettings.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.ComponentModel; // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global @@ -102,7 +102,7 @@ public class QueueSettings /// Effective weight for the trade type. public long GetWeight(int count, DateTime time, PokeTradeType type) { - var now = DateTime.Now; + var now = DateTime.UtcNow; var seconds = (now - time).Seconds; var cb = GetCountBias(type) * count; diff --git a/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs b/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs index 49eab64f2..c3025fa9d 100644 --- a/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs +++ b/SysBot.Pokemon/Structures/ConcurrentPriorityQueue.cs @@ -1,8 +1,9 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading; +using SysBot.Pokemon; -// ms-lpl, removed from their website but archived on the internet, with alterations to be inheritable +// ms-lpl, removed from their website but archived on the internet, with alterations to be inheritable and deriving an interface namespace System.Collections.Concurrent; @@ -10,7 +11,9 @@ namespace System.Collections.Concurrent; /// Specifies the type of keys used to prioritize values. /// Specifies the type of elements in the queue. [DebuggerDisplay("Count={" + nameof(Count) + "}")] -public class ConcurrentPriorityQueue : IProducerConsumerCollection> where TKey : IComparable where TValue : IEquatable +public class ConcurrentPriorityQueue : IProducerConsumerCollection> + where TKey : IComparable + where TValue : IEquatable, IReadyStatus { protected readonly Lock _syncLock = new(); protected readonly MinQueue Queue = new(); @@ -47,16 +50,21 @@ public void Enqueue(KeyValuePair item) /// When this method returns, if the operation was successful, result contains the object removed. If /// no object was available to be removed, the value is unspecified. /// + /// Ensure the object is ready to be dequeued. /// /// true if an element was removed and returned from the queue successfully; otherwise, false. /// - public bool TryDequeue(out KeyValuePair result) + public bool TryDequeue(out KeyValuePair result, bool checkReady = true) { result = default; lock (_syncLock) { if (Queue.Count == 0) return false; + + if (checkReady && !Queue.Peek().Value.IsReady) + return false; + result = Queue.Remove(); return true; } @@ -67,10 +75,11 @@ public bool TryDequeue(out KeyValuePair result) /// When this method returns, if the operation was successful, result contains the object. /// The queue was not modified by the operation. /// + /// Ensure the object is ready to be dequeued. /// /// true if an element was returned from the queue successfully; otherwise, false. /// - public bool TryPeek(out KeyValuePair result) + public bool TryPeek(out KeyValuePair result, bool checkReady = true) { result = default; lock (_syncLock) @@ -78,7 +87,7 @@ public bool TryPeek(out KeyValuePair result) if (Queue.Count == 0) return false; result = Queue.Peek(); - return true; + return !checkReady || result.Value.IsReady; } } diff --git a/SysBot.Pokemon/Structures/FavoredCPQ.cs b/SysBot.Pokemon/Structures/FavoredCPQ.cs index 5f0d522f3..acdc47696 100644 --- a/SysBot.Pokemon/Structures/FavoredCPQ.cs +++ b/SysBot.Pokemon/Structures/FavoredCPQ.cs @@ -7,7 +7,7 @@ namespace SysBot.Pokemon; /// /// Allows Enqueue requests to have favored requests inserted ahead of a fraction of unfavored requests. /// -public sealed class FavoredCPQ : ConcurrentPriorityQueue where TKey : IComparable where TValue : IEquatable, IFavoredEntry +public sealed class FavoredCPQ : ConcurrentPriorityQueue where TKey : IComparable where TValue : IEquatable, IFavoredEntry, IReadyStatus { public IFavoredCPQSetting Settings { get; set; } diff --git a/SysBot.Pokemon/Structures/IReadyStatus.cs b/SysBot.Pokemon/Structures/IReadyStatus.cs new file mode 100644 index 000000000..fd7aba5b2 --- /dev/null +++ b/SysBot.Pokemon/Structures/IReadyStatus.cs @@ -0,0 +1,9 @@ +namespace SysBot.Pokemon; + +public interface IReadyStatus +{ + /// + /// Short-lived delay on baking a request to allow for it to be pruned shortly after adding. + /// + bool IsReady { get; } +} diff --git a/SysBot.Pokemon/Structures/UserTracker.cs b/SysBot.Pokemon/Structures/UserTracker.cs index 68e34dfb9..eb57001d0 100644 --- a/SysBot.Pokemon/Structures/UserTracker.cs +++ b/SysBot.Pokemon/Structures/UserTracker.cs @@ -86,5 +86,5 @@ public IEnumerable Summarize() public sealed record TrackedUser(ulong NetworkID, string Name, ulong RemoteID) { - public readonly DateTime Time = DateTime.Now; + public readonly DateTime Time = DateTime.UtcNow; } diff --git a/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs b/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs index 2c3ecd728..a29135d03 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs @@ -5,7 +5,7 @@ namespace SysBot.Pokemon; -public sealed record PokeTradeDetail : IFavoredEntry where TPoke : PKM, new() +public sealed record PokeTradeDetail : IFavoredEntry, IReadyStatus where TPoke : PKM, new() { // ReSharper disable once StaticMemberInGenericType /// Global variable indicating the amount of trades created. @@ -32,7 +32,14 @@ namespace SysBot.Pokemon; public required PokeTradeType Type { get; init; } /// Time the object was created at - public readonly DateTime Time = DateTime.Now; + public DateTime Time { get; } = DateTime.UtcNow; + + /// Indicates how old the request is. + public TimeSpan Age => DateTime.UtcNow - Time; + + /// Internal readiness state to prevent a bot from picking up the trade too early in the event it shouldn't have been queued. + public bool IsReady { get; set; } + /// Unique incremented ID public readonly int Id = Interlocked.Increment(ref _createdCount) % 3000; diff --git a/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs b/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs index d87c42f3b..2d12caecb 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeQueue.cs @@ -17,17 +17,17 @@ public class PokeTradeQueue(PokeTradeType Type) public void Enqueue(PokeTradeDetail detail, uint priority = PokeTradePriorities.TierFree) => Queue.Add(priority, detail); - public bool TryDequeue(out PokeTradeDetail detail, out uint priority) + public bool TryDequeue(out PokeTradeDetail detail, out uint priority, bool checkReady) { - var result = Queue.TryDequeue(out var kvp); + var result = Queue.TryDequeue(out var kvp, checkReady); detail = kvp.Value; priority = kvp.Key; return result; } - public bool TryPeek(out PokeTradeDetail detail, out uint priority) + public bool TryPeek(out PokeTradeDetail detail, out uint priority, bool checkReady = true) { - var result = Queue.TryPeek(out var kvp); + var result = Queue.TryPeek(out var kvp, checkReady); detail = kvp.Value; priority = kvp.Key; return result; diff --git a/SysBot.Tests/GenerateTests.cs b/SysBot.Tests/GenerateTests.cs index 83cae851f..ecbb7c9f5 100644 --- a/SysBot.Tests/GenerateTests.cs +++ b/SysBot.Tests/GenerateTests.cs @@ -27,7 +27,8 @@ public void CanGenerate(string set) public void ShouldNotGenerate(string set) { _ = AutoLegalityWrapper.GetTrainerInfo(); - var s = ShowdownUtil.ConvertToShowdown(set); + var success = ShowdownUtil.TryConvertSingleLine(set, out var s); + success.Should().BeFalse(); s.Should().BeNull(); } @@ -55,16 +56,18 @@ public void TestAbilityTwitch(string set, int abilNumber) for (int i = 0; i < 10; i++) { var twitch = set.Replace("\r\n", " ").Replace('\n', ' '); - var s = ShowdownUtil.ConvertToShowdown(twitch); - var template = s == null ? null : AutoLegalityWrapper.GetTemplate(s); - var pk = template == null ? null : sav.GetLegal(template, out _); - pk.Should().NotBeNull(); + if (!ShowdownUtil.TryConvertSingleLine(twitch, out var s)) + Assert.Fail(); + var template = AutoLegalityWrapper.GetTemplate(s); + var pk = sav.GetLegal(template, out var result); + result.Should().Be("Regenerated"); pk.AbilityNumber.Should().Be(abilNumber); } } private const string Gengar = - @"Gengar-Gmax @ Life Orb +""" +Gengar-Gmax @ Life Orb Ability: Cursed Body Shiny: Yes EVs: 252 SpA / 4 SpD / 252 Spe @@ -72,20 +75,24 @@ Timid Nature - Dream Eater - Fling - Giga Impact -- Headbutt"; +- Headbutt +"""; private const string Braviary = - @"Braviary (F) @ Master Ball +""" +Braviary (F) @ Master Ball Ability: Defiant EVs: 252 Atk / 4 SpD / 252 Spe Jolly Nature - Brave Bird - Close Combat - Tailwind -- Iron Head"; +- Iron Head +"""; private const string Drednaw = - @"Drednaw-Gmax @ Fossilized Drake +""" +Drednaw-Gmax @ Fossilized Drake Ability: Shell Armor Level: 60 EVs: 252 Atk / 4 SpD / 252 Spe @@ -93,10 +100,12 @@ Adamant Nature - Earthquake - Liquidation - Swords Dance -- Head Smash"; +- Head Smash +"""; private const string Torkoal2 = - @"Torkoal (M) @ Assault Vest +""" +Torkoal (M) @ Assault Vest IVs: 0 Atk EVs: 248 HP / 8 Atk / 252 SpA Ability: Drought @@ -104,10 +113,12 @@ Quiet Nature - Body Press - Earth Power - Eruption -- Fire Blast"; +- Fire Blast +"""; private const string Charizard4 = - @"Charizard @ Choice Scarf +""" +Charizard @ Choice Scarf Ability: Solar Power Level: 50 Shiny: Yes @@ -116,8 +127,8 @@ Timid Nature - Heat Wave - Air Slash - Solar Beam -- Beat Up"; +- Beat Up +"""; - private const string InvalidSpec = - "(Pikachu)"; + private const string InvalidSpec = "(Pikachu)"; } diff --git a/SysBot.Tests/PokeHubTests.cs b/SysBot.Tests/PokeHubTests.cs index 553464ab0..4973809ec 100644 --- a/SysBot.Tests/PokeHubTests.cs +++ b/SysBot.Tests/PokeHubTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using FluentAssertions; using PKHeX.Core; using SysBot.Pokemon; using Xunit; @@ -19,7 +19,7 @@ public class PokeHubTests var a = new T { Species = 5 }; pool.Add(a); - var trade = hub.Queues.TryDequeue(PokeRoutineType.FlexTrade, out _, out _); + var trade = hub.Queues.TryDequeue(PokeRoutineType.FlexTrade, out _, out _, checkReady: false); trade.Should().BeFalse(); var ledy = hub.Queues.TryDequeueLedy(out var detail); diff --git a/SysBot.Tests/QueueTests.cs b/SysBot.Tests/QueueTests.cs index bd6a8d25a..bd56df2ae 100644 --- a/SysBot.Tests/QueueTests.cs +++ b/SysBot.Tests/QueueTests.cs @@ -42,13 +42,13 @@ public class QueueTests // Sudo add with the same ID var id = t1.UserID; - var sr = info.AddToTradeQueue(s, id); + var sr = info.IsAbleToJoinQueue(s, id); sr.Should().Be(QueueResultAdd.AlreadyInQueue); sr = info.AddToTradeQueue(s, id, true); sr.Should().Be(QueueResultAdd.Added); - var dequeue = queue.TryDequeue(out var first, out uint priority); + var dequeue = queue.TryDequeue(out var first, out uint priority, checkReady: false); priority.Should().Be(PokeTradePriorities.Tier1); // sudo dequeue.Should().BeTrue(); ReferenceEquals(first, s.Trade).Should().BeTrue(); @@ -63,7 +63,7 @@ public class QueueTests count.Should().Be(3); queue.Count.Should().Be(3); - dequeue = queue.TryDequeue(out var second, out priority); + dequeue = queue.TryDequeue(out var second, out priority, checkReady: false); priority.Should().Be(PokeTradePriorities.TierFree); // sudo dequeue.Should().BeTrue(); ReferenceEquals(second, t1.Trade).Should().BeTrue(); @@ -147,7 +147,7 @@ public override void SoftStop() { } // Enqueue some favorites for (int i = 0; i < count / 10; i++) { - var s = GetTestTrade(info, count + i + 1, true); + var s = GetTestTrade(info, count + i + 1, favor: true); var r = info.AddToTradeQueue(s, s.UserID); r.Should().Be(QueueResultAdd.Added); } @@ -155,12 +155,12 @@ public override void SoftStop() { } int expectedPosition = (int)Math.Ceiling(Math.Pow(count, f.Exponent)); for (int i = 0; i < expectedPosition; i++) { - queue.TryDequeue(out var detail, out _); + queue.TryDequeue(out var detail, out _, checkReady: false); detail.IsFavored.Should().Be(false); } { - queue.TryDequeue(out var detail, out _); + queue.TryDequeue(out var detail, out _, checkReady: false); detail.IsFavored.Should().Be(true); } } From c9e53c55c3aff45182db458c187ca8bd79fd2135 Mon Sep 17 00:00:00 2001 From: Kurt Date: Thu, 13 Aug 2026 00:53:57 -0500 Subject: [PATCH 20/23] Extract entity embed builder, color consistency --- .../Commands/Management/HubModule.cs | 5 +- .../Commands/Management/PoolModule.cs | 2 +- .../Commands/Trade/CloneModule.cs | 11 ++- .../Commands/Trade/DumpModule.cs | 2 +- .../Commands/Trade/SeedCheckModule.cs | 13 ++- .../Commands/Trade/TradeModule.cs | 6 +- .../Helpers/EntityEmbedBuilder.cs | 74 ++++++++++++++ SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 97 ++++++++++--------- .../Helpers/ReusableActions.cs | 2 +- 9 files changed, 150 insertions(+), 62 deletions(-) create mode 100644 SysBot.Pokemon.Discord/Helpers/EntityEmbedBuilder.cs diff --git a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs index 7e7aa0321..72b880282 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs @@ -18,10 +18,7 @@ public async Task GetStatusAsync() var me = SysCord.Runner; var hub = me.Hub; - var builder = new EmbedBuilder - { - Color = Color.Gold, - }; + var builder = new EmbedBuilder { Color = Color.Gold }; var all = me.Bots.ConvertAll(z => z.Bot); builder.AddField(x => { diff --git a/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs b/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs index 853f08519..d23dc858f 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs @@ -26,7 +26,7 @@ public async Task DisplayPoolCountAsync() => $"{i + 1:00}: {z.Key} = {(Species)z.Value.RequestInfo.Species}"); var msg = string.Join('\n', entries); - var embed = new EmbedBuilder(); + var embed = new EmbedBuilder { Color = Color.Gold }; embed.AddField($"Count: {count}", msg); await RespondAsync("Pool Details", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); } diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs index f907aca92..915bb5f42 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs @@ -36,8 +36,15 @@ private async Task JoinAsync(int? code) [RequireSudo] public async Task GetListAsync() { - var embed = new EmbedBuilder(); - embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.Clone)); + string msg = Info.GetTradeList(PokeRoutineType.Clone); + var embed = new EmbedBuilder { Color = Color.LightGrey }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = msg; + x.IsInline = false; + }); + await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); } diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs index 61ddb78f5..6c78144b2 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs @@ -37,7 +37,7 @@ public async Task DumpAsync( public async Task GetListAsync() { string msg = Info.GetTradeList(PokeRoutineType.Dump); - var embed = new EmbedBuilder(); + var embed = new EmbedBuilder { Color = Color.LightGrey }; embed.AddField(x => { x.Name = "Pending Trades"; diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs index 21a552d5f..127fb20f5 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs @@ -39,7 +39,7 @@ public async Task FindFrameAsync( var hub = SysCord.Runner.Hub; var r = new SeedSearchResult(Z3SearchResult.Success, value, -1, hub.Config.SeedCheckSWSH.ResultDisplayMode); - var embed = new EmbedBuilder { Color = Color.LighterGrey }; + var embed = new EmbedBuilder { Color = Color.LightGrey }; embed.AddField($"Seed: 0x{value:X16}", r.ToString()); await FollowupAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); } @@ -55,8 +55,15 @@ public async Task FindFrameAsync( [RequireSudo] public async Task GetSeedListAsync() { - var embed = new EmbedBuilder(); - embed.AddField("Pending Trades", Info.GetTradeList(PokeRoutineType.SeedCheck)); + string msg = Info.GetTradeList(PokeRoutineType.SeedCheck); + var embed = new EmbedBuilder { Color = Color.LightGrey }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = msg; + x.IsInline = false; + }); + await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); } diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs index c35319e7d..d0d787436 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs @@ -34,7 +34,8 @@ namespace SysBot.Pokemon.Discord; public async Task TradeSetModalAsync(TradeSetModal modal) { // Re-check if the queue closed in the time between opening the modal and entering the info. - if (await CheckQueue().ConfigureAwait(false)) return; + if (!await CheckQueue().ConfigureAwait(false)) + return; // Sanity check their inputs. var code = modal.Code; @@ -107,7 +108,7 @@ public async Task TradeFileAsync( [RequireSudo] public async Task GetTradeListAsync() { - var embed = new EmbedBuilder(); + var embed = new EmbedBuilder { Color = Color.LightGrey }; embed.AddField(x => { x.Name = "Pending Trades"; @@ -230,7 +231,6 @@ private async Task CheckQueue() return true; await RespondAsync("The trade queue has closed.", ephemeral: true).ConfigureAwait(false); return false; - } private async Task TradeAttachmentAsync(int code, IAttachment attachment, IInteractionContext user) diff --git a/SysBot.Pokemon.Discord/Helpers/EntityEmbedBuilder.cs b/SysBot.Pokemon.Discord/Helpers/EntityEmbedBuilder.cs new file mode 100644 index 000000000..0fef5a3d7 --- /dev/null +++ b/SysBot.Pokemon.Discord/Helpers/EntityEmbedBuilder.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Discord; +using PKHeX.Core; + +namespace SysBot.Pokemon.Discord; + +public class EntityEmbedBuilder : EmbedBuilder +{ + private readonly PKM _entity; + + public EntityEmbedBuilder(PKM pk) + { + _entity = pk; + Color = ((PersonalColor)pk.PersonalInfo.Color).ToDiscordColor(); + Title = "Pokémon Info"; + Timestamp = DateTime.UtcNow; + } + + public EntityEmbedBuilder AddTradeCode(int tradeCode) + { + AddField(x => + { + x.Name = "Trade Code:"; + x.Value = Format.Bold($"{tradeCode:0000 0000}"); + x.IsInline = true; + }); + return this; + } + + public EntityEmbedBuilder AddWaitTime(float minutes) + { + AddField(x => + { + x.Name = "Estimated Wait:"; + x.Value = $"{minutes:F1} minutes."; + x.IsInline = true; + }); + return this; + } + + public EntityEmbedBuilder AddReceiving() + { + if (_entity.Species == 0) + return this; + + AddField(x => + { + x.Name = "Receiving:"; + x.Value = ReusableActions.FormatSetCode(_entity); + x.IsInline = true; + }); + return this; + } + + public void AddQueuePosition(int checkPosition) => Footer = new EmbedFooterBuilder + { + Text = $"Position: {checkPosition}" + }; + + public bool TryAddSpriteThumbnail([NotNullWhen(true)] out MemoryStream? sprite, [NotNullWhen(true)] out FileAttachment? thumb) + { + thumb = null; + sprite = ReusableActions.GetSprite?.Invoke(_entity); + if (sprite is null) + return false; + + const string fileName = "sprite.png"; + thumb = new FileAttachment(sprite, fileName); + WithThumbnailUrl($"attachment://{fileName}"); + return true; + } +} diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index c5a618d6f..f11372fe8 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Threading.Tasks; using Discord; using Discord.Net; @@ -7,12 +8,12 @@ namespace SysBot.Pokemon.Discord; public static class QueueHelper where T : PKM, new() { - public static async Task AddToQueueAsync(IInteractionContext context, int code, T trade, PokeRoutineType routine, PokeTradeType type) + public static async Task AddToQueueAsync(IInteractionContext context, int code, T pk, PokeRoutineType routine, PokeTradeType type) { QueueJoinResult? check = null; try { - check = AddToTradeQueue(context, trade, code, routine, type); + check = AddToTradeQueue(context, pk, code, routine, type); var result = check.Result; if (!result) { @@ -21,44 +22,8 @@ public static async Task AddToQueueAsync(IInteractionContext context, int code, } // Message the user in their DMs. If this fails, the event handler will abort and let them know to enable DMs. - var channelRef = $"<#{context.Channel.Id}>"; - var secret = $""" - {channelRef} - {check.Message} - I'll message you here when your trade is starting. - """; - var builder = new EmbedBuilder { Color = ((PersonalColor)trade.PersonalInfo.Color).ToDiscordColor() }; - builder.AddField(x => - { - x.Name = "Trade Code:"; - x.Value = Format.Bold($"{code:0000 0000}"); - }); - - if (trade.Species != 0) - { - builder.AddField(x => - { - x.Name = "Receiving:"; - x.Value = ReusableActions.FormatSetCode(trade); - }); - } - - Task toMessage; - var user = context.Interaction.User; - var sprite = ReusableActions.GetSprite?.Invoke(trade); - if (sprite is not null) - { - const string fileName = "sprite.png"; - var thumb = new FileAttachment(sprite, fileName); - builder.WithThumbnailUrl($"attachment://{fileName}"); - toMessage = user.SendFileAsync(thumb, text: secret, embed: builder.Build()); - } - else - { - toMessage = user.SendMessageAsync(text: secret, embed: builder.Build()); - } - - var msg = await toMessage.ConfigureAwait(false); + var task = GetPrivateMessageTradeJoin(context, pk, check, out var sprite); + var message = await task.ConfigureAwait(false); if (sprite != null) await sprite.DisposeAsync().ConfigureAwait(false); @@ -66,10 +31,10 @@ public static async Task AddToQueueAsync(IInteractionContext context, int code, await context.Channel.SendMessageAsync($"{context.User.Mention} - {check.Message}").ConfigureAwait(false); // Update the ephemeral command message to backlink to the DM we just sent the user. - await context.Interaction.FollowupAsync($"Success! Please check your direct messages: {msg.GetJumpUrl()}").ConfigureAwait(false); + await context.Interaction.FollowupAsync($"Success! Please check your direct messages: {message.GetJumpUrl()}").ConfigureAwait(false); // All further communication is in Direct Messages to the user (no further input needed). - check.Join.Trade.IsReady = true; // If we failed, we'd remove (see below). Mark it as ready to trade. + check.Join.Trade.IsReady = true; // If we failed, we'd instead remove via the exception handling below. } catch (HttpException ex) { @@ -80,14 +45,51 @@ public static async Task AddToQueueAsync(IInteractionContext context, int code, var hub = SysCord.Runner.Hub; var info = hub.Queues.Info; info.Remove(detail); - // Already followed up in the above event handling. } await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); } } - private sealed record QueueJoinResult(bool Result, TradeEntry Join, string Message); + private static Task GetPrivateMessageTradeJoin(IInteractionContext context, T pk, QueueJoinResult check, + out MemoryStream? sprite) + { + // Prepend the embed with a message letting the user know about the trade. + var channelRef = $"<#{context.Channel.Id}>"; + var secret = $""" + {channelRef} + {check.Message} + I'll message you here when your trade is starting. + """; + return GetPrivateMessageTradeJoin(context, pk, check, secret, out sprite); + } + + private static Task GetPrivateMessageTradeJoin(IInteractionContext context, T pk, QueueJoinResult check, string message, + out MemoryStream? sprite) + { + var builder = new EntityEmbedBuilder(pk); + builder + .AddReceiving() + .AddTradeCode(check.Join.Trade.Code) + .AddQueuePosition(check.Position); + + var user = context.Interaction.User; + if (builder.TryAddSpriteThumbnail(out sprite, out var thumb)) + return user.SendFileAsync(thumb.Value, text: message, embed: builder.Build()); + + // No sprite, just return a regular message. + return user.SendMessageAsync(text: message, embed: builder.Build()); + } + + /// + /// Represents the result of attempting to join a trade queue, including whether the join was successful, the trade entry, and an associated message. + /// + /// Indicates whether the join was successful. + /// The trade entry associated with the join attempt. + /// A message providing additional information about the join attempt. + /// The position within the queue that the user joined at. + /// Estimated time (in minutes) that the user will need to wait before a bot picks up their request. + private sealed record QueueJoinResult(bool Result, TradeEntry Join, string Message, int Position = 0, float Estimate = 0); private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, int code, PokeRoutineType routine, PokeTradeType type) { @@ -123,13 +125,14 @@ private static QueueJoinResult AddToTradeQueue(IInteractionContext trader, T pk, var message = $"Added to the {routine} queue{ticketId}. Current Position: {position.Position}.{pokeName}"; var botct = info.Hub.Bots.Count; + float estimate = 0; if (position.Position > botct) { - var eta = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); - message += $" Estimated: {eta:F1} minutes."; + estimate = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); + message += $" Estimated: {estimate:F1} minutes."; } // Don't mark as ready yet; notifying the user may fail (DMs disabled). If so, we'll remove from the queue and not mark as ready. - return new(true, trade, message); + return new(true, trade, message, position.Position, estimate); } private static async Task HandleDiscordExceptionAsync(IInteractionContext context, HttpException ex) diff --git a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index 977462aa0..fabf99bd2 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -101,7 +101,7 @@ public static string FormatSetCode(ShowdownSet set, LanguageID displayLanguage = return FormatSetCode(lines); } - public static string FormatSetCode(T trade, LanguageID language = Language) where T : PKM, new() + public static string FormatSetCode(T trade, LanguageID language = Language) where T : PKM { var localization = BattleTemplateLocalization.GetLocalization(Language); var set = new ShowdownSet(trade, localization); From 1fa481bbcd5e68f5624191280d441462158ad3e1 Mon Sep 17 00:00:00 2001 From: Kurt Date: Thu, 13 Aug 2026 21:19:21 -0500 Subject: [PATCH 21/23] Allow team to be treated as owner add Team name to bot info --- .../Attributes/RequireSudoAttribute.cs | 14 +++++------ .../Attributes/RequireTeamOrOwnerAttribute.cs | 24 +++++-------------- .../Commands/General/InfoModule.cs | 6 +++-- .../Helpers/DiscordManager.cs | 18 +++++++++++--- SysBot.Pokemon.Discord/Helpers/QueueHelper.cs | 3 +-- .../Helpers/QueueRestrictions.cs | 2 +- SysBot.Pokemon.Discord/SysCord.cs | 4 ++-- 7 files changed, 36 insertions(+), 35 deletions(-) diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs index 0bdc1e438..419c4851a 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs @@ -10,24 +10,24 @@ namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireSudoAttribute : PreconditionAttribute { - public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) - return PreconditionResult.FromSuccess(); + return Task.FromResult(PreconditionResult.FromSuccess()); // Check if this user is a Guild User, which is the only context where roles exist if (context.User is not SocketGuildUser gUser) - return PreconditionResult.FromError("You must be in a guild to run this command."); + return Task.FromResult(PreconditionResult.FromError("You must be in a guild to run this command.")); if (mgr.CanUseSudo(gUser.Roles.Select(z => z.Name))) - return PreconditionResult.FromSuccess(); + return Task.FromResult(PreconditionResult.FromSuccess()); // Fallback: check if it is the owner or a team member. - if (await RequireTeamOrOwnerAttribute.IsTeamOrOwner(context).ConfigureAwait(false)) - PreconditionResult.FromSuccess(); + if (RequireTeamOrOwnerAttribute.IsTeamOrOwner(context)) + return Task.FromResult(PreconditionResult.FromSuccess()); // Since it wasn't, fail - return PreconditionResult.FromError("You are not permitted to run this command."); + return Task.FromResult(PreconditionResult.FromError("You are not permitted to run this command.")); } } diff --git a/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs index 2cd602cbf..9e4628e2a 100644 --- a/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Threading.Tasks; using Discord; using Discord.Interactions; @@ -9,28 +8,17 @@ namespace SysBot.Pokemon.Discord; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireTeamOrOwnerAttribute : PreconditionAttribute { - public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { - if (await IsTeamOrOwner(context).ConfigureAwait(false)) - PreconditionResult.FromSuccess(); + if (IsTeamOrOwner(context)) + return Task.FromResult(PreconditionResult.FromSuccess()); - return PreconditionResult.FromError("You are not permitted to run this command."); + return Task.FromResult(PreconditionResult.FromError("You are not permitted to run this command.")); } - public static async Task IsTeamOrOwner(IInteractionContext context) + public static bool IsTeamOrOwner(IInteractionContext context) { - // Get application info from the client - var appInfo = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); - - // Owner always gets permission. - if (context.User.Id == appInfo.Owner.Id) - return true; - // Check if the bot is owned by a team; if so, any marked as Owner are permitted. - if (appInfo.Team is { } team && team.TeamMembers.Any(m => m.Role == TeamRole.Owner)) - return true; - - // Return a silent failure if unauthorized (keeps help menus clean). - return false; + return SysCordSettings.Manager.IsTeamOrOwner(context.User.Id); } } diff --git a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs index 82836fb03..ba4eaa0d3 100644 --- a/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/InfoModule.cs @@ -24,7 +24,9 @@ public class InfoModule : SlashModuleBase [SlashCommand("info", "Displays information about the bot.")] public async Task InfoAsync() { - var app = await Context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + var manager = SysCordSettings.Manager; + var owner = manager.Owner; + var teamLine = manager.Team is { } team ? $"\n- {Format.Bold("Team")}: {team.Name}" : ""; var builder = new EmbedBuilder { @@ -35,7 +37,7 @@ public async Task InfoAsync() builder.AddField("Info", $""" - [Source Code]({Repo}) -- {Format.Bold("Owner")}: {app.Owner} ({app.Owner.Id}) +- {Format.Bold("Owner")}: {owner.GlobalName} ({owner.Id}){teamLine} - {Format.Bold("Library")}: Discord.Net ({DiscordConfig.Version}) - {Format.Bold("Started")}: {GetStartTimeRelative()} - {Format.Bold("Runtime")}: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.OSDescription} {RuntimeInformation.OSArchitecture}) diff --git a/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs b/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs index da565f516..f3ddee047 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs @@ -1,12 +1,23 @@ using System; using System.Collections.Generic; using System.Linq; +using Discord; namespace SysBot.Pokemon.Discord; public sealed record DiscordManager(DiscordSettings Config) { - public ulong Owner { get; internal set; } + public IUser Owner { get; internal set; } = null!; // late-bind + public ITeam? Team { get; set; } + + /// + /// Cache ownership at program startup. + /// + internal void SetOwnership(IApplication app) => Owner = (Team = app.Team)? + .TeamMembers.First(m => m.Role == TeamRole.Owner).User ?? app.Owner; + + public bool IsTeamOrOwner(ulong userId) => userId == Owner.Id + || Team is { } team && team.TeamMembers.Any(m => m.User.Id == userId); public RemoteControlAccessList BlacklistedUsers => Config.UserBlacklist; public RemoteControlAccessList WhitelistedChannels => Config.ChannelWhitelist; @@ -21,11 +32,12 @@ public sealed record DiscordManager(DiscordSettings Config) public RemoteControlAccessList RolesDump => Config.RoleCanDump; public RemoteControlAccessList RolesRemoteControl => Config.RoleRemoteControl; - public bool CanUseSudo(ulong uid) => uid == Owner || SudoDiscord.Contains(uid); + public bool IsAnyTeamMember(ulong uid) => Team?.TeamMembers.Any(z => z.User.Id == uid) ?? false; + public bool CanUseSudo(ulong uid) => uid == Owner.Id || IsAnyTeamMember(uid) || SudoDiscord.Contains(uid); public bool CanUseSudo(IEnumerable roles) => roles.Any(SudoRoles.Contains); public bool CanUseCommandChannel(ulong channel) => (WhitelistedChannels.List.Count == 0 && WhitelistedChannels.AllowIfEmpty) || WhitelistedChannels.Contains(channel); - public bool CanUseCommandUser(ulong uid) => uid == Owner || !BlacklistedUsers.Contains(uid); + public bool CanUseCommandUser(ulong uid) => uid == Owner.Id || !BlacklistedUsers.Contains(uid) || IsAnyTeamMember(uid); public RequestSignificance GetSignificance(IEnumerable roles) { diff --git a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs index f11372fe8..853cb2f74 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -149,8 +149,7 @@ private static async Task HandleDiscordExceptionAsync(IInteractionContext contex var permissions = self.GetPermissions(guildChannel); if (!permissions.SendMessages) { - var app = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); - message = $"{app.Owner.Mention} You must grant me \"Send Messages\" permissions!"; + message = $"{SysCordSettings.Manager.Owner.Mention} - You must grant me \"Send Messages\" permissions!"; Base.LogUtil.LogError(message); return; } diff --git a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs index 5dee3dbf8..548f39daa 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs @@ -61,7 +61,7 @@ public RequestSignificance GetSignificance() { // Check user ID. var userId = user.Id; - if (userId == Manager.Owner) + if (Manager.IsTeamOrOwner(userId)) return RequestSignificance.Owner; // Don't check Team membership for special favor. diff --git a/SysBot.Pokemon.Discord/SysCord.cs b/SysBot.Pokemon.Discord/SysCord.cs index ab22b0648..79e50a6e6 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -94,7 +94,7 @@ public async Task MainAsync(string apiToken, CancellationToken token) await _client.LoginAsync(TokenType.Bot, apiToken).ConfigureAwait(false); var app = await _client.GetApplicationInfoAsync().ConfigureAwait(false); - _manager.Owner = app.Owner.Id; + _manager.SetOwnership(app); await _client.StartAsync().ConfigureAwait(false); await MonitorStatusAsync(token).ConfigureAwait(false); @@ -156,7 +156,7 @@ private async Task HandleInteractionAsync(SocketInteraction interaction) return; } - if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && context.User.Id != _manager.Owner)) + if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && _manager.IsTeamOrOwner(context.User.Id))) { // Visibly reply if settings require (so that others can see). var ephemeral = !Hub.Config.Discord.ReplyCannotUseCommandInChannel; From a7ad9ce170ed619ff844b449fed9516b4b0c764c Mon Sep 17 00:00:00 2001 From: Kurt Date: Thu, 13 Aug 2026 21:51:59 -0500 Subject: [PATCH 22/23] Remove now-unused bot-listen settings --- SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs b/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs index eeb1d76d6..52b5ab6e6 100644 --- a/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs +++ b/SysBot.Pokemon/Settings/Integrations/DiscordSettings.cs @@ -88,10 +88,4 @@ public class DiscordSettings [Category(Operation), Description("Replies to users if they are not allowed to use a given command in the channel. When false, the bot will silently ignore them instead.")] public bool ReplyCannotUseCommandInChannel { get; set; } = true; - - [Category(Operation), Description("Bot listens to channel messages to reply with a ShowdownSet whenever a PKM file is attached (not with a command).")] - public bool ConvertPKMToShowdownSet { get; set; } = true; - - [Category(Operation), Description("Bot can reply with a ShowdownSet in Any channel the bot can see, instead of only channels the bot has been whitelisted to run in. Only make this true if you want the bot to serve more utility in non-bot channels.")] - public bool ConvertPKMReplyAnyChannel { get; set; } } From 5dceb72f992b4ff3ea439516121955edf5079f29 Mon Sep 17 00:00:00 2001 From: Kurt Date: Thu, 13 Aug 2026 23:19:53 -0500 Subject: [PATCH 23/23] Add owner setup check command, trade item Move some files around; Trade module is getting pretty heavy so allow it to be split across multiple files. Have fun chaining modals. --- .../Commands/Management/OwnerModule.cs | 77 +++++++++++++++++++ .../Commands/{Trade => Queues}/CloneModule.cs | 7 +- .../Commands/{Trade => Queues}/DumpModule.cs | 5 +- .../Commands/{Trade => Queues}/QueueModule.cs | 0 .../{Trade => Queues}/SeedCheckModule.cs | 10 +-- .../Commands/Queues/Trade/TradeBase64Modal.cs | 18 +++++ .../Commands/Queues/Trade/TradeModule.Sudo.cs | 37 +++++++++ .../{ => Queues}/Trade/TradeModule.cs | 77 +++++-------------- .../Commands/Queues/Trade/TradeSetModal.cs | 18 +++++ .../Helpers/QueueRestrictions.cs | 8 +- 10 files changed, 183 insertions(+), 74 deletions(-) rename SysBot.Pokemon.Discord/Commands/{Trade => Queues}/CloneModule.cs (90%) rename SysBot.Pokemon.Discord/Commands/{Trade => Queues}/DumpModule.cs (90%) rename SysBot.Pokemon.Discord/Commands/{Trade => Queues}/QueueModule.cs (100%) rename SysBot.Pokemon.Discord/Commands/{Trade => Queues}/SeedCheckModule.cs (86%) create mode 100644 SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeBase64Modal.cs create mode 100644 SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.Sudo.cs rename SysBot.Pokemon.Discord/Commands/{ => Queues}/Trade/TradeModule.cs (79%) create mode 100644 SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeSetModal.cs diff --git a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs index 44d8f4746..74328c445 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Discord; using Discord.Interactions; +using Discord.WebSocket; namespace SysBot.Pokemon.Discord; @@ -87,6 +89,81 @@ public async Task ExitProgram() Environment.Exit(0); } + [SlashCommand("check", "Checks if the bot has the required permissions in whitelisted channels.")] + [CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)] + public async Task ChannelPermissionTest() + { + List results = []; + foreach (var guild in Context.Client.Guilds) + { + var result = new GuildPermissionScanResult { GuildName = guild.Name }; + foreach (var channel in guild.TextChannels) + { + if (!SysCordSettings.Settings.ChannelWhitelist.Contains(channel.Id)) + continue; + + var missingPerms = GetMissingPerms(guild, channel); + if (missingPerms.Count == 0) + continue; + + var c = new GuildChannelPermissionCheck { Channel = channel.Name }; + c.MissingPermissions.AddRange(missingPerms.Select(p => p.ToString())); + result.InvalidChannels.Add(c); + } + + if (result.InvalidChannels.Count != 0) + results.Add(result); + } + + if (results.Count == 0) + { + await RespondAsync("All permissions for whitelisted channels are correct.", ephemeral: true).ConfigureAwait(false); + return; + } + + var builder = new EmbedBuilder { Title = "Guilds with Missing Permissions", Color = Color.Red }; + foreach (var guild in results) + { + var fieldValue = string.Join("\n", guild.InvalidChannels.Select(c => + $"{c.Channel}: {string.Join(", ", c.MissingPermissions)}")); + builder.AddField(guild.GuildName, fieldValue); + } + await RespondAsync(ephemeral: true, embed: builder.Build()).ConfigureAwait(false); + } + + private static ReadOnlySpan RequiredPermissions => + [ + ChannelPermission.ViewChannel, + ChannelPermission.SendMessages, + ChannelPermission.EmbedLinks, + ChannelPermission.AttachFiles, + ChannelPermission.ReadMessageHistory, + ]; + + private static List GetMissingPerms(SocketGuild guild, SocketTextChannel channel) + { + List result = []; + var botPermissions = guild.CurrentUser.GetPermissions(channel); + foreach (var perm in RequiredPermissions) + { + if (!botPermissions.Has(perm)) + result.Add(perm); + } + return result; + } + + private sealed class GuildPermissionScanResult + { + public required string GuildName { get; init; } + public List InvalidChannels { get; } = []; + } + + private sealed class GuildChannelPermissionCheck + { + public required string Channel { get; init; } + public List MissingPermissions { get; } = []; + } + private RemoteControlAccess GetReference(ulong id, string name) => new() { ID = id, diff --git a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/CloneModule.cs similarity index 90% rename from SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs rename to SysBot.Pokemon.Discord/Commands/Queues/CloneModule.cs index 915bb5f42..584857421 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/CloneModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Queues/CloneModule.cs @@ -14,6 +14,7 @@ namespace SysBot.Pokemon.Discord; [RequireQueueRole(PokeRoutineType.Clone)] [RequireOpenDms] public Task CloneAsync(int? code = null) => JoinAsync(code); + private async Task JoinAsync(int? code) { if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) @@ -36,16 +37,14 @@ private async Task JoinAsync(int? code) [RequireSudo] public async Task GetListAsync() { - string msg = Info.GetTradeList(PokeRoutineType.Clone); - var embed = new EmbedBuilder { Color = Color.LightGrey }; + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.Clone) }; embed.AddField(x => { x.Name = "Pending Trades"; - x.Value = msg; + x.Value = Info.GetTradeList(PokeRoutineType.Clone); x.IsInline = false; }); await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); } - } diff --git a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/DumpModule.cs similarity index 90% rename from SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs rename to SysBot.Pokemon.Discord/Commands/Queues/DumpModule.cs index 6c78144b2..2d2a7dcf2 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/DumpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Queues/DumpModule.cs @@ -36,12 +36,11 @@ public async Task DumpAsync( [RequireSudo] public async Task GetListAsync() { - string msg = Info.GetTradeList(PokeRoutineType.Dump); - var embed = new EmbedBuilder { Color = Color.LightGrey }; + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.Dump) }; embed.AddField(x => { x.Name = "Pending Trades"; - x.Value = msg; + x.Value = Info.GetTradeList(PokeRoutineType.Dump); x.IsInline = false; }); diff --git a/SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/QueueModule.cs similarity index 100% rename from SysBot.Pokemon.Discord/Commands/Trade/QueueModule.cs rename to SysBot.Pokemon.Discord/Commands/Queues/QueueModule.cs diff --git a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/SeedCheckModule.cs similarity index 86% rename from SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs rename to SysBot.Pokemon.Discord/Commands/Queues/SeedCheckModule.cs index 127fb20f5..86506836c 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/SeedCheckModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Queues/SeedCheckModule.cs @@ -9,7 +9,7 @@ namespace SysBot.Pokemon.Discord; [RequireContext(ContextType.Guild)] public class SeedCheckModule : SlashModuleBase where T : PKM, new() { - private static TradeQueueInfo Info=>SysCord.Runner.Hub.Queues.Info; + private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; [SlashCommand("seed-check", "Checks the seed for a Pokémon.")] [RequireQueueRole(PokeRoutineType.SeedCheck)] @@ -39,7 +39,7 @@ public async Task FindFrameAsync( var hub = SysCord.Runner.Hub; var r = new SeedSearchResult(Z3SearchResult.Success, value, -1, hub.Config.SeedCheckSWSH.ResultDisplayMode); - var embed = new EmbedBuilder { Color = Color.LightGrey }; + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.SeedCheck) }; embed.AddField($"Seed: 0x{value:X16}", r.ToString()); await FollowupAsync($"Here are the details for `{r.Seed:X16}`:", embed: embed.Build()).ConfigureAwait(false); } @@ -55,16 +55,14 @@ public async Task FindFrameAsync( [RequireSudo] public async Task GetSeedListAsync() { - string msg = Info.GetTradeList(PokeRoutineType.SeedCheck); - var embed = new EmbedBuilder { Color = Color.LightGrey }; + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.SeedCheck) }; embed.AddField(x => { x.Name = "Pending Trades"; - x.Value = msg; + x.Value = Info.GetTradeList(PokeRoutineType.SeedCheck); x.IsInline = false; }); await RespondAsync("These are the users who are currently waiting:", embed: embed.Build()).ConfigureAwait(false); } - } diff --git a/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeBase64Modal.cs b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeBase64Modal.cs new file mode 100644 index 000000000..ef9a3938e --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeBase64Modal.cs @@ -0,0 +1,18 @@ +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +public class TradeBase64Modal : IModal +{ + public string Title => "Trade Base64 File"; + + [InputLabel("Base64 Text")] + [ModalTextInput("base64", TextInputStyle.Paragraph, placeholder: "Paste the Base64 text here...")] + public string Base64 { get; set; } = string.Empty; + + [RequiredInput(false)] + [InputLabel("Trade Code (optional)")] + [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] + public string? Code { get; set; } +} diff --git a/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.Sudo.cs b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.Sudo.cs new file mode 100644 index 000000000..350942c24 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.Sudo.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +public partial class TradeModule +{ + [SlashCommand("list", "Prints the users in the trade queues.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task GetTradeListAsync() + { + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.LinkTrade) }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = Info.GetTradeList(PokeRoutineType.LinkTrade); + x.IsInline = false; + }); + + await RespondAsync("These are the users who are currently waiting:", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); + } + + [SlashCommand("ban", "Ban an Online ID from trading.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task BanTradeAsync( + [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, + [Summary(nameof(reason), "The reason for banning the user.")] string reason) + { + // Display not-ephemeral message to the sudo user, since this is a sudo command and they should be aware of the action being taken. + await DeferAsync().ConfigureAwait(false); + SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew(GetReference(nnid, reason)); + await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); + } +} diff --git a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.cs similarity index 79% rename from SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs rename to SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.cs index d0d787436..d2f111fa4 100644 --- a/SysBot.Pokemon.Discord/Commands/Trade/TradeModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.cs @@ -12,7 +12,7 @@ namespace SysBot.Pokemon.Discord; [Group("trade", "Commands for starting a trade session with the bot.")] [RequireContext(ContextType.Guild)] [RequireOpenDms] -public class TradeModule : SlashModuleBase where T : PKM, new() +public partial class TradeModule : SlashModuleBase where T : PKM, new() // partial, allow other files to have commands in this group { private static TradeQueueInfo Info => SysCord.Runner.Hub.Queues.Info; @@ -97,39 +97,26 @@ public async Task TradeFileAsync( await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); } - /* - * - * SUDO COMMANDS BELOW - * - */ - - [SlashCommand("list", "Prints the users in the trade queues.")] - [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check - [RequireSudo] - public async Task GetTradeListAsync() + [SlashCommand("item", "Trade a specific item.")] + [RequireQueueRole(PokeRoutineType.LinkTrade)] + public async Task TradeItemAsync( + [Summary(nameof(itemName), "Item name to be traded.")] string itemName, + [Summary(nameof(code), "Optional; leave blank for a random code")] int? code = null) { - var embed = new EmbedBuilder { Color = Color.LightGrey }; - embed.AddField(x => - { - x.Name = "Pending Trades"; - x.Value = Info.GetTradeList(PokeRoutineType.LinkTrade); - x.IsInline = false; - }); + // Re-check if the queue closed in the time between opening the modal and entering the info. + if (!await CheckQueue().ConfigureAwait(false)) + return; - await RespondAsync("These are the users who are currently waiting:", ephemeral: true, embed: embed.Build()).ConfigureAwait(false); - } + // Sanity check their inputs. + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; - [SlashCommand("ban", "Ban an Online ID from trading.")] - [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check - [RequireSudo] - public async Task BanTradeAsync( - [Summary(nameof(nnid), "The in-game/online ID of the user to ban from trading.")] ulong nnid, - [Summary(nameof(reason), "The reason for banning the user.")] string reason) - { - // Display not-ephemeral message to the sudo user, since this is a sudo command and they should be aware of the action being taken. - await DeferAsync().ConfigureAwait(false); - SysCordSettings.HubConfig.TradeAbuse.BannedIDs.AddIfNew(GetReference(nnid, reason)); - await FollowupAsync($"Done. Online ID {nnid} has been banned for reason: {reason}").ConfigureAwait(false); + // Convert, then join the queue. + await DeferAsync(ephemeral: true).ConfigureAwait(false); + var tradeCode = code ?? Info.GetRandomTradeCode(); // already validated above, so safe to take if provided + + var set = $"Pikachu @ {itemName}"; // Available in *every* game! + await TradeTextAsync(tradeCode, set, Context).ConfigureAwait(false); } private async Task TradeTextAsync(int code, string content, IInteractionContext user) @@ -304,31 +291,3 @@ private async Task AddTradeToQueueAsync(int code, T pk) await QueueHelper.AddToQueueAsync(Context, code, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific).ConfigureAwait(false); } } - -public class TradeSetModal : IModal -{ - public string Title => "Trade Showdown Set"; - - [InputLabel("Showdown Set")] - [ModalTextInput("showdown", TextInputStyle.Paragraph, placeholder: "Paste your Showdown set here...")] - public string Showdown { get; set; } = string.Empty; - - [RequiredInput(false)] - [InputLabel("Trade Code (optional)")] - [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] - public string? Code { get; set; } -} - -public class TradeBase64Modal : IModal -{ - public string Title => "Trade Base64 File"; - - [InputLabel("Base64 Text")] - [ModalTextInput("base64", TextInputStyle.Paragraph, placeholder: "Paste the Base64 text here...")] - public string Base64 { get; set; } = string.Empty; - - [RequiredInput(false)] - [InputLabel("Trade Code (optional)")] - [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] - public string? Code { get; set; } -} diff --git a/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeSetModal.cs b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeSetModal.cs new file mode 100644 index 000000000..800bf94ec --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeSetModal.cs @@ -0,0 +1,18 @@ +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +public class TradeSetModal : IModal +{ + public string Title => "Trade Showdown Set"; + + [InputLabel("Showdown Set")] + [ModalTextInput("showdown", TextInputStyle.Paragraph, placeholder: "Paste your Showdown set here...")] + public string Showdown { get; set; } = string.Empty; + + [RequiredInput(false)] + [InputLabel("Trade Code (optional)")] + [ModalTextInput("code", TextInputStyle.Short, placeholder: "Leave blank for a random code", maxLength: 8)] + public string? Code { get; set; } +} diff --git a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs index 548f39daa..9ff4b360f 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs @@ -27,8 +27,7 @@ public async Task IsTradeCodeValidOrEmpty(string? code) if (uint.TryParse(code, out var parsed) && parsed <= MaxTradeCode) return true; - await context.Interaction.RespondAsync($"The trade code must be between 0 and {MaxTradeCode}.", ephemeral: true).ConfigureAwait(false); - return false; + return await context.ReplyBadCodeAsync().ConfigureAwait(false); } /// @@ -45,6 +44,11 @@ public async Task IsTradeCodeValidOrEmpty(int? code) if ((uint)code.Value <= MaxTradeCode) return true; + return await context.ReplyBadCodeAsync().ConfigureAwait(false); + } + + private async Task ReplyBadCodeAsync() + { await context.Interaction.RespondAsync($"The trade code must be between 0 and {MaxTradeCode}.", ephemeral: true).ConfigureAwait(false); return false; }