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..d2fb58f0e 100644 --- a/SysBot.Base/Util/Logging/LogUtil.cs +++ b/SysBot.Base/Util/Logging/LogUtil.cs @@ -1,10 +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; @@ -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/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/Helpers/RequireQueueRoleAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs similarity index 74% rename from SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs rename to SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs index bf5215d6f..27fbe7b6c 100644 --- a/SysBot.Pokemon.Discord/Helpers/RequireQueueRoleAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireQueueRoleAttribute.cs @@ -1,21 +1,19 @@ -using Discord.Commands; -using Discord.WebSocket; using System; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; namespace SysBot.Pokemon.Discord; /// /// Same as with extra consideration for bots accepting Queue requests. /// -public sealed class RequireQueueRoleAttribute(string RoleName) : PreconditionAttribute +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireQueueRoleAttribute(PokeRoutineType type) : 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) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) @@ -29,13 +27,15 @@ public override Task CheckPermissionsAsync(ICommandContext c 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!")); - 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/Commands/Attributes/RequireRoleAccessAttribute.cs similarity index 70% rename from SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs rename to SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs index 4fe9bdee5..b8941de0c 100644 --- a/SysBot.Pokemon.Discord/Helpers/RequireRoleAccessAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireRoleAccessAttribute.cs @@ -1,21 +1,19 @@ -using Discord.Commands; -using Discord.WebSocket; using System; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; 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 +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireRoleAccessAttribute(PokeRoutineType type) : 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) + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) { var mgr = SysCordSettings.Manager; if (mgr.Config.AllowGlobalSudo && mgr.CanUseSudo(context.User.Id)) @@ -29,7 +27,9 @@ public override Task CheckPermissionsAsync(ICommandContext c if (mgr.CanUseSudo(roles.Select(z => z.Name))) return Task.FromResult(PreconditionResult.FromSuccess()); - if (!mgr.GetHasRoleAccess(RoleName, roles.Select(z => z.Name))) + // 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/Helpers/RequireSudoAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs similarity index 67% rename from SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs rename to SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs index faeb62643..419c4851a 100644 --- a/SysBot.Pokemon.Discord/Helpers/RequireSudoAttribute.cs +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireSudoAttribute.cs @@ -1,15 +1,16 @@ -using Discord.Commands; -using Discord.WebSocket; using System; using System.Linq; using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; namespace SysBot.Pokemon.Discord; +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public sealed class RequireSudoAttribute : PreconditionAttribute { - // Override the CheckPermissions method - 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)) @@ -22,6 +23,10 @@ public override Task CheckPermissionsAsync(ICommandContext c if (mgr.CanUseSudo(gUser.Roles.Select(z => z.Name))) return Task.FromResult(PreconditionResult.FromSuccess()); + // Fallback: check if it is the owner or a team member. + if (RequireTeamOrOwnerAttribute.IsTeamOrOwner(context)) + 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/Commands/Attributes/RequireTeamOrOwnerAttribute.cs b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs new file mode 100644 index 000000000..9e4628e2a --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Attributes/RequireTeamOrOwnerAttribute.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class RequireTeamOrOwnerAttribute : PreconditionAttribute +{ + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + if (IsTeamOrOwner(context)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + return Task.FromResult(PreconditionResult.FromError("You are not permitted to run this command.")); + } + + public static bool IsTeamOrOwner(IInteractionContext context) + { + // Check if the bot is owned by a team; if so, any marked as Owner are permitted. + return SysCordSettings.Manager.IsTeamOrOwner(context.User.Id); + } +} 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..a84287bca 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/BatchEditingModule.cs @@ -1,42 +1,77 @@ -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}", ephemeral: true).ConfigureAwait(false); else - await ReplyAsync($"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.")] + 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.SendFileAsync(pk, "Modified result attached:").ConfigureAwait(false); } private static bool IsValidInstructionSet(ReadOnlySpan split, out List invalid) @@ -48,7 +83,6 @@ private static bool IsValidInstructionSet(ReadOnlySpan split, out List +[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..ebe2635ff 100644 --- a/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Extra/LegalizerModule.cs @@ -1,33 +1,51 @@ -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() + [SlashCommand("legalize", "Tries to legalize an attached PKM file.")] + public async Task LegalizeAsync( + [Summary(nameof(file), "The file to legalize.")] IAttachment file) { - var attachments = Context.Message.Attachments; - foreach (var att in attachments) - await Context.Channel.ReplyWithLegalizedSetAsync(att).ConfigureAwait(false); + await DeferAsync(ephemeral: true).ConfigureAwait(false); + await Context.ReplyWithLegalizedSetAsync(file).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("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) { - return Context.Channel.ReplyWithLegalizedSetAsync(content, gen); + 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); } - [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 ConvertAsync( + [Summary(nameof(content), "The Showdown set to convert.")] string content, + [Summary(nameof(version), "Optional: Original Trainer version to obtain the encounter with.")] GameVersion? version = null) { - return Context.Channel.ReplyWithLegalizedSetAsync(content); + await DeferAsync(ephemeral: true).ConfigureAwait(false); + if (version is null) // assume current format if no version is specified + await Context.ReplyWithLegalizedSetAsync(content).ConfigureAwait(false); + else + await Context.ReplyWithLegalizedSetAsync(content, version.Value).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..0316fbe10 100644 --- a/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs +++ b/SysBot.Pokemon.Discord/Commands/General/HelpModule.cs @@ -1,116 +1,94 @@ -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( + [Summary(nameof(commandName), "Command name to get help for. Leave blank to fetch all available.")] string? commandName = null) { - var builder = new EmbedBuilder + var builder = new EmbedBuilder { Color = Color.Blue }; + var provider = SysCordSettings.ServiceProvider; + if (string.IsNullOrWhiteSpace(commandName)) { - 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) + // There will be at least one command always (`help` was just used!). + await BuildCommandsAll(builder, provider).ConfigureAwait(false); + } + else { - string? description = null; - HashSet mentioned = []; - foreach (var cmd in module.Commands) + var added = await BuildCommandsMatching(builder, provider, commandName).ConfigureAwait(false); + if (added == 0) { - 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 message = $"Sorry, I couldn't find a command like {Format.Bold(commandName)}."; + await RespondAsync(message, ephemeral: true).ConfigureAwait(false); + return; } - 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; - }); + // Use a different description. + builder.Description = $"Here are some commands like {Format.Bold(commandName)}:"; } - - await ReplyAsync("Help has arrived!", false, builder.Build()).ConfigureAwait(false); + await RespondAsync("Help has arrived!", ephemeral: true, embed: 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) + private async Task BuildCommandsMatching(EmbedBuilder builder, IServiceProvider provider, string commandName) { - var result = Service.Search(Context, command); - - if (!result.IsSuccess) - { - await ReplyAsync($"Sorry, I couldn't find a command like **{command}**.").ConfigureAwait(false); - return; - } + var matches = service.SlashCommands.Where(x => + x.Name.Equals(commandName, StringComparison.OrdinalIgnoreCase) || + x.Name.Contains(commandName, StringComparison.OrdinalIgnoreCase)).ToList(); - var builder = new EmbedBuilder + int added = 0; + foreach (var cmd in matches) { - Color = new Color(114, 137, 218), - Description = $"Here are some commands like **{command}**:", - }; - - foreach (var match in result.Commands) - { - var cmd = match.Command; + var check = await cmd.CheckPreconditionsAsync(Context, provider).ConfigureAwait(false); + if (!check.IsSuccess) + continue; - builder.AddField(x => - { - 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}"); + added++; } - await ReplyAsync("Help has arrived!", false, builder.Build()).ConfigureAwait(false); + return added; } - private static string GetCommandSummary(CommandInfo cmd) + private async Task BuildCommandsAll(EmbedBuilder builder, IServiceProvider provider) { - return $"Summary: {cmd.Summary}\nParameters: {GetParameterSummary(cmd.Parameters)}"; + 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 string GetParameterSummary(IReadOnlyList p) + private static async IAsyncEnumerable GetAvailableCommands(IEnumerable possible, + IInteractionContext context, IServiceProvider provider) { - if (p.Count == 0) - return "None"; - return $"{p.Count}\n- " + string.Join("\n- ", p.Select(GetParameterSummary)); + foreach (var cmd in possible) + { + var result = await cmd.CheckPreconditionsAsync(context, provider).ConfigureAwait(false); + if (result.IsSuccess) + yield return cmd; + } } - private static string GetParameterSummary(ParameterInfo z) + private static string GetParameters(IReadOnlyList para) { - var result = z.Name; - if (!string.IsNullOrWhiteSpace(z.Summary)) - result += $" ({z.Summary})"; - return result; + if (para.Count == 0) + return "None"; + 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..ba4eaa0d3 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,73 +5,83 @@ 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 manager = SysCordSettings.Manager; + var owner = manager.Owner; + var teamLine = manager.Team is { } team ? $"\n- {Format.Bold("Team")}: {team.Name}" : ""; 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")}: {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}) +- {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} @ {TimestampTag.FromDateTime(SysCordSettings.RegisteredTime, TimestampTagStyles.ShortDateTime)} +- {Format.Bold("Modal Count")}: {SysCordSettings.RegisteredModals} +""" + ); 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"); + 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) { - 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]; + 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} " : "") + $@"{buildTime:yy-MM-dd\.hh\:mm}"; - return !inclVersion ? _default : version; + return (inclVersion ? $"{version} " : "") + $"{TimestampTag.FromDateTime(buildTime, TimestampTagStyles.ShortDateTime)}"; + 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..72b880282 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/HubModule.cs @@ -1,72 +1,56 @@ -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; var hub = me.Hub; - var builder = new EmbedBuilder - { - Color = Color.Gold, - }; - - var runner = SysCord.Runner; - var allBots = runner.Bots.ConvertAll(z => z.Bot); - var botCount = allBots.Count; + var builder = new EmbedBuilder { Color = Color.Gold }; + 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 +63,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 _, checkReady: false)) // can be soon-ready 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..74328c445 100644 --- a/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs +++ b/SysBot.Pokemon.Discord/Commands/Management/OwnerModule.cs @@ -1,128 +1,173 @@ -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 Discord.WebSocket; 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. +[RequireTeamOrOwner] +public class OwnerModule : InteractionModuleBase { - [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.Id, user.GlobalName)); + 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.Id, c.Name)); + 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); + var emphasis = Format.Bold("Bot services are going offline."); + await RespondAsync($"Shutting down... goodbye! {emphasis}").ConfigureAwait(false); Environment.Exit(0); } - private RemoteControlAccess GetReference(IUser channel) => new() + [SlashCommand("check", "Checks if the bot has the required permissions in whitelisted channels.")] + [CommandContextType(InteractionContextType.Guild, InteractionContextType.PrivateChannel)] + public async Task ChannelPermissionTest() { - ID = channel.Id, - Name = channel.Username, - Comment = $"Added by {Context.User.Username} on {DateTime.Now:yyyy.MM.dd-hh:mm:ss}", - }; + 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(IChannel channel) => new() + private RemoteControlAccess GetReference(ulong id, string name) => new() { - ID = channel.Id, - Name = channel.Name, + 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/Management/PoolModule.cs b/SysBot.Pokemon.Discord/Commands/Management/PoolModule.cs index 3f4ef0924..d23dc858f 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 { 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/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/Queues/CloneModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/CloneModule.cs new file mode 100644 index 000000000..584857421 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/CloneModule.cs @@ -0,0 +1,50 @@ +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.")] + [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)) + return; + + await DeferAsync(ephemeral: true).ConfigureAwait(false); + + code ??= Info.GetRandomTradeCode(); + await QueueHelper.AddToQueueAsync(Context, (int)code, new T(), PokeRoutineType.Clone, PokeTradeType.Clone).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("clone-list", "Prints the users in the Clone queue.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task GetListAsync() + { + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.Clone) }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + 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/Queues/DumpModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/DumpModule.cs new file mode 100644 index 000000000..2d2a7dcf2 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/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.")] + [RequireQueueRole(PokeRoutineType.Dump)] + [RequireOpenDms] + 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); + + code ??= Info.GetRandomTradeCode(); + await QueueHelper.AddToQueueAsync(Context, (int)code, new T(), PokeRoutineType.Dump, PokeTradeType.Dump).ConfigureAwait(false); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("dump-list", "Prints the users in the Dump queue.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task GetListAsync() + { + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.Dump) }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + x.Value = Info.GetTradeList(PokeRoutineType.Dump); + 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/QueueModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/QueueModule.cs new file mode 100644 index 000000000..077400689 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/QueueModule.cs @@ -0,0 +1,89 @@ +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)}", ephemeral: true); + + [SlashCommand("clear", "Clears yourself from the trade queues.")] + public Task ClearTradeAsync() + => RespondAsync(GetClearTradeMessage(Info.ClearTrade(Context.User.Id)), ephemeral: true); + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("clear-user", "Clears a user 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 ClearTradeUserAsync( + [Summary(nameof(user), "The user to clear from the trade queues.")] IUser user) + { + var message = GetClearTradeMessage(Info.ClearTrade(user.Id)); + await RespondAsync(message).ConfigureAwait(false); + } + + [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() + { + Info.ClearAllQueues(); + await RespondAsync("Cleared all in the queue.").ConfigureAwait(false); + } + + [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() + { + var state = Info.ToggleQueue(); + var message = state + ? "Users are now able to join the trade queue." + : $"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.")] + [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) + { + 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.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task ListUserQueue() + { + 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/Queues/SeedCheckModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/SeedCheckModule.cs new file mode 100644 index 000000000..86506836c --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/SeedCheckModule.cs @@ -0,0 +1,68 @@ +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.")] + [RequireQueueRole(PokeRoutineType.SeedCheck)] + [RequireOpenDms] + 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); + + code ??= Info.GetRandomTradeCode(); + 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.")] + 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..]; + 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.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); + } + + /* + * + * SUDO COMMANDS BELOW + * + */ + + [SlashCommand("seed-list", "Prints the users in the Seed Check queue.")] + [DefaultMemberPermissions(GuildPermission.PrioritySpeaker)] // basic gate to hide the commands from untrusted users, but not a full sudo check + [RequireSudo] + public async Task GetSeedListAsync() + { + var embed = new EmbedBuilder { Color = Color.LightGrey, Title = nameof(PokeRoutineType.SeedCheck) }; + embed.AddField(x => + { + x.Name = "Pending Trades"; + 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/Queues/Trade/TradeModule.cs b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.cs new file mode 100644 index 000000000..d2f111fa4 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Queues/Trade/TradeModule.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("trade", "Commands for starting a trade session with the bot.")] +[RequireContext(ContextType.Guild)] +[RequireOpenDms] +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; + + private const string TradeModalId = "trade-set"; + + [SlashCommand("set", "Trade a Showdown Set.")] + [RequireQueueRole(PokeRoutineType.LinkTrade)] + 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); + + /// + /// Handles the submission of the , validating the input and processing the trade. + /// + [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 (!await CheckQueue().ConfigureAwait(false)) + return; + + // 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 + + await TradeTextAsync(tradeCode, modal.Showdown, Context).ConfigureAwait(false); + } + + /// + /// 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 (!await CheckQueue().ConfigureAwait(false)) + return; + + // 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 + + 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( + [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) + { + // 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 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 + + await TradeAttachmentAsync(tradeCode, file, Context).ConfigureAwait(false); + } + + [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) + { + // Re-check if the queue closed in the time between opening the modal and entering the info. + if (!await CheckQueue().ConfigureAwait(false)) + return; + + // Sanity check their inputs. + if (!await Context.IsTradeCodeValidOrEmpty(code).ConfigureAwait(false)) + return; + + // 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) + { + content = ReusableActions.StripCodeBlock(content); + + // Try parsing as any language. + if (!ShowdownParsing.TryParseAnyLanguage(content, out var set)) + { + // Try as base64 byte[] input. + try + { + 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. + } + + await FollowupAsync("Unable to detect valid data. Please check your inputs.").ConfigureAwait(false); + return; + } + + // 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 msg = GetInvalidSetMessage(set); + await FollowupAsync(msg).ConfigureAwait(false); + return; + } + + try + { + await TradeShowdownAsync(code, template, user).ConfigureAwait(false); + } + catch (Exception ex) + { + LogUtil.LogSafe(ex); + var msg = $""" + Oops! An unexpected problem happened with this Showdown Set: + {ReusableActions.FormatSetCode(set)} + """; + await FollowupAsync(msg).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 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 + // 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 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); + var pk = GetRequest(att); + if (pk == null) + { + await FollowupAsync("Attachment provided is not compatible with this module!").ConfigureAwait(false); + return; + } + + await AddTradeToQueueAsync(code, pk).ConfigureAwait(false); + } + + private async Task TradeShowdownAsync(int code, IBattleTemplate template, IInteractionContext user) + { + 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; + } + + pk.ResetPartyStats(); + await AddTradeToQueueAsync(code, pk).ConfigureAwait(false); + } + + private async Task AddTradeToQueueAsync(int code, T pk) + { + var la = new LegalityAnalysis(pk); + if (!la.Valid) + { + 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 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 FollowupAsync($"{typeof(T).Name} attachment is not native, and cannot be traded!").ConfigureAwait(false); + return; + } + + if (cfg.DisallowTracked && pk is IHomeTrack { HasTracker: true }) + { + await FollowupAsync($"{typeof(T).Name} attachment is tracked by HOME, and cannot be traded!").ConfigureAwait(false); + return; + } + + await QueueHelper.AddToQueueAsync(Context, code, pk, PokeRoutineType.LinkTrade, PokeTradeType.Specific).ConfigureAwait(false); + } +} 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/Commands/SlashModuleBase.cs b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs new file mode 100644 index 000000000..1716e04b6 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/SlashModuleBase.cs @@ -0,0 +1,23 @@ +using System; +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +/// +/// Base implementation providing +/// +[DefaultMemberPermissions(GuildPermission.SendMessages)] +[RequireBotPermission(GuildPermission.SendMessages)] +public abstract class SlashModuleBase : InteractionModuleBase +{ + // 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() + { + 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..b6d6d9167 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/BotModule.cs @@ -0,0 +1,105 @@ +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() + { + 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) + { + 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) + { + 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) + { + 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) + { + 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) + { + 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..25325a19b --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EchoModule.cs @@ -0,0 +1,101 @@ +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); + + private static readonly Dictionary Channels = []; + + public static void RestoreChannels(DiscordSocketClient discord, DiscordSettings cfg) + { + int count = 0; + foreach (var channelAccess in cfg.EchoChannels) + { + 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 {count} Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot echo special messages to this channel.")] + public async Task AddEchoAsync() + { + 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() + { + 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() + { + 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() + { + 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..0f19b796d --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/EncounterModule.cs @@ -0,0 +1,25 @@ +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( + [Summary(nameof(name), "Bot label to match. Leave blank to toss for all.")] string name = "") + { + 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..10080e9ae --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/LogModule.cs @@ -0,0 +1,91 @@ +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) + { + int count = 0; + foreach (var channelAccess in settings.LoggingChannels) + { + 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 {count} Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot log to this channel.")] + public async Task AddLogAsync() + { + 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() + { + 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() + { + 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() + { + 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..452116fae --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/RemoteControlModule.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading.Tasks; +using Discord; +using Discord.Interactions; +using PKHeX.Core; +using SysBot.Base; + +namespace SysBot.Pokemon.Discord; + +[Group("control", "Commands related to controlling the console itself.")] +[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() +{ + [SlashCommand("click", "Clicks the specified button.")] + public async Task ClickAsync( + [Summary(nameof(button), "The button to press.")] SwitchButton button) + { + 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) + { + 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) + { + 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) + { + 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) + { + 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 DeferAsync().ConfigureAwait(false); + await Task.Delay(value).ConfigureAwait(false); + await b.Connection.SendAsync(SwitchCommand.ResetStick(s, crlf)).ConfigureAwait(false); + await FollowupAsync($"{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..26690e4b2 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModule.cs @@ -0,0 +1,140 @@ +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) + { + 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) + { + 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) + { + 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) + { + 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) + { + 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() + { + 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) + { + 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) + { + 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) + { + 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() + { + 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) + { + 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() + { + 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() + { + 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..0cface8df --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/SudoModuleBase.cs @@ -0,0 +1,10 @@ +using Discord; +using Discord.Interactions; + +namespace SysBot.Pokemon.Discord; + +[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 new file mode 100644 index 000000000..cb4d9c4f4 --- /dev/null +++ b/SysBot.Pokemon.Discord/Commands/Sudo/TradeStartModule.cs @@ -0,0 +1,106 @@ +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, DiscordSettings settings) + { + int count = 0; + foreach (var channelAccess in settings.TradeStartingChannels) + { + 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 {count} Discord channel(s) on Bot startup."); + } + + [SlashCommand("here", "Makes the bot log trade starts to this channel.")] + public async Task AddLogAsync() + { + 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() + { + 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() + { + 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() + { + 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/Helpers/AutoLegalityExtensionsDiscord.cs b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs index 81c4e7848..54e9ecd23 100644 --- a/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs +++ b/SysBot.Pokemon.Discord/Helpers/AutoLegalityExtensionsDiscord.cs @@ -1,96 +1,105 @@ +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, LanguageID displayLanguage) { - 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.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; } - 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.FollowupAsync(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, displayLanguage); + await context.SendFileAsync(pk, $"{message}\n{formatted}").ConfigureAwait(false); + } + catch (Exception ex) + { + LogUtil.LogSafe(ex); + 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 static async Task ReplyWithLegalizedSetAsync(this ISocketMessageChannel channel, IAttachment att) - { - var download = await NetUtil.DownloadPKMAsync(att).ConfigureAwait(false); - if (!download.Success) + public async Task ReplyWithLegalizedSetAsync(string content, GameVersion version, LanguageID displayLanguage = LanguageID.English) { - await channel.SendMessageAsync(download.ErrorMessage).ConfigureAwait(false); - return; + content = ReusableActions.StripCodeBlock(content); + var set = ShowdownParsing.GetShowdownSet(content, out _); + var tr = AutoLegalityWrapper.GetTrainerInfo(version); + await context.ReplyWithLegalizedSetAsync(tr, set, displayLanguage).ConfigureAwait(false); } - var pkm = download.Data!; - if (new LegalityAnalysis(pkm).Valid) + public async Task ReplyWithLegalizedSetAsync(string content, LanguageID displayLanguage = LanguageID.English) where T : PKM, new() { - await channel.SendMessageAsync($"{download.SanitizedFileName}: Already legal.").ConfigureAwait(false); - return; + content = ReusableActions.StripCodeBlock(content); + var set = ShowdownParsing.GetShowdownSet(content, out _); + var tr = AutoLegalityWrapper.GetTrainerInfo(); + await context.ReplyWithLegalizedSetAsync(tr, set, displayLanguage).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.FollowupAsync(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.FollowupAsync($"{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.FollowupAsync($"{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..4e2c5ead4 100644 --- a/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs +++ b/SysBot.Pokemon.Discord/Helpers/ChannelLogger.cs @@ -1,15 +1,13 @@ -using System; +using System; +using System.Runtime.CompilerServices; using Discord.WebSocket; using SysBot.Base; 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..f3ddee047 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordManager.cs @@ -1,13 +1,23 @@ using System; using System.Collections.Generic; using System.Linq; +using Discord; 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 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; @@ -22,11 +32,12 @@ 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 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) => !BlacklistedUsers.Contains(uid); + public bool CanUseCommandUser(ulong uid) => uid == Owner.Id || !BlacklistedUsers.Contains(uid) || IsAnyTeamMember(uid); public RequestSignificance GetSignificance(IEnumerable roles) { @@ -41,19 +52,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..9209aacfb 100644 --- a/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs +++ b/SysBot.Pokemon.Discord/Helpers/DiscordTradeNotifier.cs @@ -1,88 +1,108 @@ -using Discord; -using Discord.WebSocket; -using PKHeX.Core; using System; using System.Linq; +using System.Threading.Tasks; +using Discord; +using PKHeX.Core; +using SysBot.Base; 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, IInteractionContext 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 IInteractionContext 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 code = Format.Bold($"{Code:0000 0000}"); + var message = $"Initializing trade{receive}. Please be ready. Your code is {code}."; + + 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 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); } - 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); + + LogUtil.LogInfo($"Total time since queueing: {info.Age:g}"); } - 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/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/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..853cb2f74 100644 --- a/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs +++ b/SysBot.Pokemon.Discord/Helpers/QueueHelper.cs @@ -1,142 +1,176 @@ +using System.IO; +using System.Threading.Tasks; using Discord; -using Discord.Commands; using Discord.Net; -using Discord.WebSocket; using PKHeX.Core; -using System.Threading.Tasks; namespace SysBot.Pokemon.Discord; public static class QueueHelper where T : PKM, new() { - 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(IInteractionContext context, int code, T pk, PokeRoutineType routine, PokeTradeType type) { - if ((uint)code > MaxTradeCode) - { - await context.Channel.SendMessageAsync("Trade code should be 00000000-99999999!").ConfigureAwait(false); - return; - } - + QueueJoinResult? check = null; 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) + check = AddToTradeQueue(context, pk, code, routine, type); + var result = check.Result; + if (!result) { - // Delete the user's join message for privacy - if (!context.IsPrivate) - await context.Message.DeleteAsync(RequestOptions.Default).ConfigureAwait(false); - } - else - { - // Delete our "I'm adding you!", and send the same message that we sent to the general channel. - await test.DeleteAsync().ConfigureAwait(false); + await context.Interaction.FollowupAsync(check.Message).ConfigureAwait(false); + return; } + + // Message the user in their DMs. If this fails, the event handler will abort and let them know to enable DMs. + var task = GetPrivateMessageTradeJoin(context, pk, check, out var sprite); + var message = await task.ConfigureAwait(false); + if (sprite != null) + await sprite.DisposeAsync().ConfigureAwait(false); + + // Keep a public log of them joining the queue. + 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: {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 instead remove via the exception handling below. } catch (HttpException ex) { - await HandleDiscordExceptionAsync(context, trader, ex).ConfigureAwait(false); + // 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; + var hub = SysCord.Runner.Hub; + var info = hub.Queues.Info; + info.Remove(detail); + } + + await HandleDiscordExceptionAsync(context, ex).ConfigureAwait(false); } } - public static Task AddToQueueAsync(SocketCommandContext context, int code, string trainer, RequestSignificance sig, T trade, PokeRoutineType routine, PokeTradeType type) + private static Task GetPrivateMessageTradeJoin(IInteractionContext context, T pk, QueueJoinResult check, + out MemoryStream? sprite) { - return AddToQueueAsync(context, code, trainer, sig, trade, routine, type, context.User); + // 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 bool AddToTradeQueue(SocketCommandContext context, T pk, int code, string trainerName, RequestSignificance sig, PokeRoutineType type, PokeTradeType t, SocketUser trader, out string msg) + private static Task GetPrivateMessageTradeJoin(IInteractionContext context, T pk, QueueJoinResult check, string message, + out MemoryStream? sprite) { - var user = trader; - var userID = user.Id; - var name = user.Username; + 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()); + } - 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); + /// + /// 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) + { + var channel = trader.Channel; + var user = trader.User; + var userId = user.Id; + 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, + 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."; - return false; - } + return new(false, trade, "Sorry, you are already in the queue."); - var position = Info.CheckPosition(userID, type); + 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 ticketID = ""; - if (TradeStartModule.IsStartChannel(context.Channel.Id)) - ticketID = $", unique ID: {detail.ID}"; - - 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; + 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); - msg += $" Estimated: {eta:F1} minutes."; + estimate = info.Hub.Config.Queues.EstimateDelay(position.Position, botct); + message += $" Estimated: {estimate:F1} minutes."; } - return true; + // 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, position.Position, estimate); } - private static async Task HandleDiscordExceptionAsync(SocketCommandContext context, SocketUser trader, HttpException ex) + private static async Task HandleDiscordExceptionAsync(IInteractionContext 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.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 = $"{SysCordSettings.Manager.Owner.Mention} - You must grant me \"Send Messages\" permissions!"; + Base.LogUtil.LogError(message); + return; + } } - } break; + case DiscordErrorCode.CannotSendMessagesToThisUserDueToHavingNoMutualGuilds: 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; + // Can still respond to their command. + await interaction.FollowupAsync(message, ephemeral: true).ConfigureAwait(false); } } diff --git a/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs new file mode 100644 index 000000000..9ff4b360f --- /dev/null +++ b/SysBot.Pokemon.Discord/Helpers/QueueRestrictions.cs @@ -0,0 +1,83 @@ +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; + + return await context.ReplyBadCodeAsync().ConfigureAwait(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; + + 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; + } + + 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 (Manager.IsTeamOrOwner(userId)) + 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/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/ReusableActions.cs b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs index e7e726128..fabf99bd2 100644 --- a/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs +++ b/SysBot.Pokemon.Discord/Helpers/ReusableActions.cs @@ -1,92 +1,148 @@ -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 PKHeX.Core; namespace SysBot.Pokemon.Discord; public static class ReusableActions { - public static async Task SendPKMAsync(this IMessageChannel channel, PKM pkm, string msg = "") - { - 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); - } + /// + /// 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 async Task SendPKMAsync(this IUser user, PKM pkm, string msg = "") + public static string GetModuleName(string name) { - 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); + 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; } - public static async Task RepostPKMAsShowdownAsync(this ISocketMessageChannel channel, IAttachment att) + + extension(IMessageChannel channel) { - if (!EntityDetection.IsSizePlausible(att.Size)) - return; - var result = await NetUtil.DownloadPKMAsync(att).ConfigureAwait(false); - if (!result.Success) - return; - - var pkm = result.Data!; - await channel.SendPKMAsShowdownSetAsync(pkm).ConfigureAwait(false); + 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 RequestSignificance GetFavor(this IUser user) + extension(IInteractionContext context) { - 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 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 async Task SendFilePrivatelyAsync(PKM pk, string message = "", Embed? embed = null) + => await context.SendFilePrivatelyAsync([pk], message, embed).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 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); + public FileAttachment ToFileAttachment() + { + Span data = stackalloc byte[pk.SIZE_PARTY]; + pk.WriteDecryptedDataParty(data); + var result = data.ToArray(); + + // 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 async Task SendPKMAsShowdownSetAsync(this ISocketMessageChannel channel, PKM pkm) + public static string GetFormattedShowdownText(PKM pk, LanguageID displayLanguage = LanguageID.English) { - var txt = GetFormattedShowdownText(pkm); - await channel.SendMessageAsync(txt).ConfigureAwait(false); + var config = BattleTemplateConfig.Showdown; + + var settings = new BattleTemplateExportSettings(config, displayLanguage); + var showdown = ShowdownParsing.GetShowdownText(pk, settings); + + return FormatSetCode(showdown); } - public static string GetFormattedShowdownText(PKM pkm) + // 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 displayLanguage = Language) { - var showdown = ShowdownParsing.GetShowdownText(pkm); - return Format.Code(showdown); + var config = BattleTemplateConfig.Showdown; + var settings = new BattleTemplateExportSettings(config, displayLanguage); + var lines = set.GetSetLines(settings); + return FormatSetCode(lines); } - private static readonly string[] separator = [ ",", ", ", " " ]; - - public static IReadOnlyList GetListFromString(string str) + public static string FormatSetCode(T trade, LanguageID language = Language) where T : PKM { - // Extract comma separated list - return str.Split(separator, StringSplitOptions.RemoveEmptyEntries); + var localization = BattleTemplateLocalization.GetLocalization(Language); + var set = new ShowdownSet(trade, localization); + return FormatSetCode(set, language); } - public static string StripCodeBlock(string str) => str + /// + /// Removes the Discord code formatting. + /// + public static string StripCodeBlock(string message) => message .Replace("`\n", "") .Replace("\n`", "") .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/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..79e50a6e6 100644 --- a/SysBot.Pokemon.Discord/SysCord.cs +++ b/SysBot.Pokemon.Discord/SysCord.cs @@ -1,108 +1,79 @@ -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(); + SysCordSettings.ServiceProvider = _services; } - // 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 +81,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 +90,116 @@ 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.SetOwnership(app); - // Wait infinitely so your bot actually stays connected. + await _client.StartAsync().ConfigureAwait(false); await MonitorStatusAsync(token).ConfigureAwait(false); } public async Task InitCommands() { + // 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(); + var assembly = Assembly.GetExecutingAssembly(); + await LoadModulesFromAssembly(assembly, blacklist).ConfigureAwait(false); - 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(); + _client.Ready += LoadCommandsAndChannels; + _client.InteractionCreated += HandleInteractionAsync; + } - var blacklist = Hub.Config.Discord.ModuleBlacklist - .Replace("Module", "").Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(z => z.Trim()).ToList(); + 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())) + .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))); + + var types = moduleTypes.Concat(genericTypes); - foreach (var module in modules) + foreach (var module in types) { - 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 name = ReusableActions.GetModuleName(module.Name); + if (IsBlacklisted(name, blacklist)) + continue; - // Subscribe a handler to see if a message invokes a command. - _client.Ready += LoadLoggingAndEcho; - _client.MessageReceived += HandleMessageAsync; + 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 async Task HandleMessageAsync(SocketMessage arg) + private static bool IsBlacklisted(string name, IReadOnlyList blacklist) + => blacklist.Any(z => z.Equals(name, StringComparison.OrdinalIgnoreCase)); + + private async Task HandleInteractionAsync(SocketInteraction interaction) { - // Bail out if it's a System Message. - if (arg is not SocketUserMessage msg) - return; + var context = new SocketInteractionContext(_client, interaction); - // We don't want the bot to respond to itself or other bots. - if (msg.Author.Id == _client.CurrentUser.Id || msg.Author.IsBot) + if (!_manager.CanUseCommandUser(context.User.Id)) + { + await RespondErrorAsync(interaction, "You are not permitted to use this command.", ephemeral: true).ConfigureAwait(false); return; + } - // 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)) + if ((context.Interaction.ChannelId is not { } channel) || (!_manager.CanUseCommandChannel(channel) && _manager.IsTeamOrOwner(context.User.Id))) { - bool handled = await TryHandleCommandAsync(msg, pos).ConfigureAwait(false); - if (handled) - return; + // Visibly reply if settings require (so that others can see). + var ephemeral = !Hub.Config.Discord.ReplyCannotUseCommandInChannel; + await RespondErrorAsync(interaction, "You can't use that here.", ephemeral: ephemeral).ConfigureAwait(false); + return; } - await TryHandleMessageAsync(msg).ConfigureAwait(false); + await LogInteractionStart(context, channel).ConfigureAwait(false); + + var result = await _interactions.ExecuteCommandAsync(context, _services).ConfigureAwait(false); + if (!result.IsSuccess && !interaction.HasResponded) + await RespondErrorAsync(interaction, result.ErrorReason).ConfigureAwait(false); } - private async Task TryHandleMessageAsync(SocketMessage msg) + private static async Task LogInteractionStart(SocketInteractionContext context, ulong channelId) { - // should this be a service? - if (msg.Attachments.Count > 0) + var interaction = context.Interaction; + var (type, identity) = interaction switch { - 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); - } - } + 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 async Task TryHandleCommandAsync(SocketUserMessage msg, int pos) - { - // Create a Command Context. - var context = new SocketCommandContext(_client, msg); - - // Check Permission - var mgr = Manager; - if (!mgr.CanUseCommandUser(msg.Author.Id)) - { - await msg.Channel.SendMessageAsync("You are not permitted to use this command.").ConfigureAwait(false); - return true; - } - if (!mgr.CanUseCommandChannel(msg.Channel.Id) && msg.Author.Id != mgr.Owner) - { - if (Hub.Config.Discord.ReplyCannotUseCommandInChannel) - await msg.Channel.SendMessageAsync("You can't use that command here.").ConfigureAwait(false); - return true; - } + private static Task RespondErrorAsync(SocketInteraction interaction, string message, bool ephemeral = true) => interaction.HasResponded + ? interaction.FollowupAsync(message, ephemeral: ephemeral) + : interaction.RespondAsync(message, ephemeral: ephemeral); - // 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; - } + 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 +212,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,24 +237,79 @@ 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() { + var cfg = Hub.Config.Discord; + if (!CommandsRegistered) + 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. - 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; + var game = cfg.BotGameStatus; if (!string.IsNullOrWhiteSpace(game)) await _client.SetGameAsync(game).ConfigureAwait(false); } + + private async Task RegisterSlashCommands(DiscordSettings cfg) + { + var hash = ComputeSlashCommandHash(); + if (hash == cfg.SlashCommandHash) + { + LogUtil.LogInfo("Skipped registering interactions; no signature changes detected."); + UpdateRegisteredCounts(); + 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; + + UpdateRegisteredCounts(); + cfg.SlashCommandHash = hash; + + var message = $"Registered interactions. Hash: {hash[..6]}"; + LogUtil.LogInfo(message); + } + catch (Exception ex) + { + 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 new file mode 100644 index 000000000..dc1949ba7 --- /dev/null +++ b/SysBot.Pokemon.Discord/SysCordSettings.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using PKHeX.Core; + +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 IServiceProvider ServiceProvider { get; internal set; } = null!; + + 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 countCommand, int countModal) + { + RegisteredCommands = countCommand; + RegisteredModals = countModal; + RegisteredTime = DateTime.UtcNow; + } +} diff --git a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs index ed1903681..508f67050 100644 --- a/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs +++ b/SysBot.Pokemon.Twitch/Helpers/TwitchCommandsHelper.cs @@ -1,49 +1,57 @@ +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); - if (set == null) + if (!ShowdownUtil.TryConvertSingleLine(showdownSet, out var set)) { - msg = $"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); 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 +60,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 +87,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..0869cd343 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,78 @@ 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, + }; - if (added == QueueResultAdd.AlreadyInQueue) + var trade = new TradeEntry(detail, userId, type, name); + var canAdd = Info.IsAbleToJoinQueue(trade, userId, sig.IsOwner); + if (canAdd != QueueResultAdd.CanAdd) { - msg = $"@{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; } - var position = Info.CheckPosition(userID, type); - msg = $"@{name}: Added to the {type} queue, unique ID: {detail.ID}. Current Position: {position.Position}"; + 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}"; 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."; } + + detail.IsReady = true; // Now that we've messaged the user, the trade is ready for a bot to pick up. 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 +163,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 +192,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 +207,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 +230,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 +242,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 +261,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..012e7c9f4 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(); @@ -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) @@ -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..c9fdd5d35 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,12 +33,13 @@ 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) + 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 }) @@ -57,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."), }; @@ -145,13 +146,13 @@ 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(); 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) @@ -167,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; } @@ -177,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; } } @@ -194,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(); @@ -225,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 f5c7aabea..5f95e368d 100644 --- a/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs +++ b/SysBot.Pokemon.WinForms/PokeBotRunnerImpl.cs @@ -1,12 +1,14 @@ -using PKHeX.Core; +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.WinForms; using SysBot.Pokemon.YouTube; -using System.Threading; -using System.Threading.Tasks; -namespace SysBot.Pokemon; +namespace SysBot.Pokemon.WinForms; /// /// Bot Environment implementation with Integrations added. @@ -18,6 +20,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 +55,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)) @@ -70,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; + }; } } 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.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..4c0d872f4 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) @@ -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. @@ -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..f4607ea1a 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); @@ -628,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)) @@ -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..ceaae1ab5 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; } @@ -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) { diff --git a/SysBot.Pokemon/Helpers/RemoteControlAccess.cs b/SysBot.Pokemon/Helpers/RemoteControlAccess.cs index 780300133..61efb07fe 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; } = []; @@ -49,13 +49,36 @@ public class RemoteControlAccessList /// Adds new items if not already present by . /// /// List of items to add - public void AddIfNew(IEnumerable 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 bool AddIfNew(IEnumerable list) + { + bool result = false; + foreach (var item in list) + { + 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/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..c6764a3e5 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; @@ -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; @@ -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..b4ebfa7e3 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); @@ -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; @@ -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/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 dc9286b72..2b54fd922 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,14 @@ public sealed record TradeQueueInfo(PokeTradeHub Hub) where T : PKM, new() { private readonly Lock _sync = new(); - private readonly List> UsersInQueue = []; + + /// + /// 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; public int Count @@ -23,7 +30,7 @@ public int Count get { lock (_sync) - return UsersInQueue.Count; + return _queue.Count; } } @@ -34,32 +41,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 +94,7 @@ public void ClearAllQueues() lock (_sync) { Hub.Queues.ClearAll(); - UsersInQueue.Clear(); + _queue.Clear(); } } @@ -97,13 +104,13 @@ 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); } - private QueueResultRemove ClearTrade(ICollection> details) + private QueueResultRemove ClearTrade(IReadOnlyCollection> details) { if (details.Count == 0) return QueueResultRemove.NotInQueue; @@ -140,7 +147,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,47 +156,59 @@ 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 IReadOnlyList> GetIsUserQueued(Func, bool> match) + { + lock (_sync) + return [.. _queue.Where(match)]; } - public IList> GetIsUserQueued(Func, bool> match) + public bool Remove(TradeEntry detail) { lock (_sync) { - return UsersInQueue.Where(match).ToArray(); + LogUtil.LogInfo($"Removing {detail.Trade.Trainer.TrainerName}", nameof(TradeQueueInfo<>)); + return _queue.Remove(detail); } } - public bool Remove(TradeEntry detail) + public QueueResultAdd IsAbleToJoinQueue(TradeEntry trade, ulong userId, bool sudo = false) { lock (_sync) { - LogUtil.LogInfo($"Removing {detail.Trade.Trainer.TrainerName}", nameof(TradeQueueInfo)); - return UsersInQueue.Remove(detail); + if (_queue.Any(z => z.UserID == userId) && !sudo) + return QueueResultAdd.AlreadyInQueue; + return QueueResultAdd.CanAdd; } } - 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) + // 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); - UsersInQueue.Add(trade); + _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; } @@ -200,6 +219,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..4b902bd17 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,22 +52,29 @@ 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; } - 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) @@ -78,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 @@ -113,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/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..dc38f1309 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); @@ -749,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; @@ -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..31ec88f6f 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); } @@ -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; @@ -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..52b5ab6e6 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}!"; @@ -85,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; } } diff --git a/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs b/SysBot.Pokemon/Settings/Integrations/StreamSettings.cs index b9d4d6705..55099720c 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; @@ -200,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); @@ -216,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); @@ -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/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/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..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; } } @@ -91,7 +100,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 +117,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/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/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..47993efda 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; @@ -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; } } 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/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/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..a29135d03 100644 --- a/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs +++ b/SysBot.Pokemon/TradeHub/PokeTradeDetail.cs @@ -1,77 +1,65 @@ -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, IReadyStatus 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; + 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; + 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..2d12caecb 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; @@ -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; @@ -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..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(); } @@ -54,17 +55,19 @@ 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 s = ShowdownUtil.ConvertToShowdown(twitch); - var template = s == null ? null : AutoLegalityWrapper.GetTemplate(s); - var pk = template == null ? null : sav.GetLegal(template, out _); - pk.Should().NotBeNull(); + var twitch = set.Replace("\r\n", " ").Replace('\n', ' '); + 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/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/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 e39a915b5..bd56df2ae 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; @@ -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(); @@ -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}"); } @@ -139,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); } @@ -147,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); } } 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