From dac6e283e8a47ba19060b1295dec7b1970c2a2e5 Mon Sep 17 00:00:00 2001 From: Toastie Date: Sun, 12 May 2024 21:22:46 +1200 Subject: [PATCH 001/340] Added EllieBot.Voice --- EllieBot.sln | 19 +- src/EllieBot.Voice/CloseCodes.cs | 35 ++ src/EllieBot.Voice/EllieBot.Voice.csproj | 15 + src/EllieBot.Voice/LibOpus.cs | 125 ++++++ src/EllieBot.Voice/LibSodium.cs | 32 ++ src/EllieBot.Voice/Models/SelectProtocol.cs | 23 ++ src/EllieBot.Voice/Models/VoiceHello.cs | 10 + src/EllieBot.Voice/Models/VoiceIdentify.cs | 20 + src/EllieBot.Voice/Models/VoicePayload.cs | 29 ++ src/EllieBot.Voice/Models/VoiceReady.cs | 22 + src/EllieBot.Voice/Models/VoiceResume.cs | 16 + .../Models/VoiceSessionDescription.cs | 13 + src/EllieBot.Voice/Models/VoiceSpeaking.cs | 26 ++ src/EllieBot.Voice/PoopyBufferImmortalized.cs | 136 +++++++ src/EllieBot.Voice/SocketClient.cs | 154 +++++++ src/EllieBot.Voice/SongBuffer.cs | 95 +++++ src/EllieBot.Voice/VoiceClient.cs | 207 ++++++++++ src/EllieBot.Voice/VoiceGateway.cs | 375 ++++++++++++++++++ 18 files changed, 1339 insertions(+), 13 deletions(-) create mode 100644 src/EllieBot.Voice/CloseCodes.cs create mode 100644 src/EllieBot.Voice/EllieBot.Voice.csproj create mode 100644 src/EllieBot.Voice/LibOpus.cs create mode 100644 src/EllieBot.Voice/LibSodium.cs create mode 100644 src/EllieBot.Voice/Models/SelectProtocol.cs create mode 100644 src/EllieBot.Voice/Models/VoiceHello.cs create mode 100644 src/EllieBot.Voice/Models/VoiceIdentify.cs create mode 100644 src/EllieBot.Voice/Models/VoicePayload.cs create mode 100644 src/EllieBot.Voice/Models/VoiceReady.cs create mode 100644 src/EllieBot.Voice/Models/VoiceResume.cs create mode 100644 src/EllieBot.Voice/Models/VoiceSessionDescription.cs create mode 100644 src/EllieBot.Voice/Models/VoiceSpeaking.cs create mode 100644 src/EllieBot.Voice/PoopyBufferImmortalized.cs create mode 100644 src/EllieBot.Voice/SocketClient.cs create mode 100644 src/EllieBot.Voice/SongBuffer.cs create mode 100644 src/EllieBot.Voice/VoiceClient.cs create mode 100644 src/EllieBot.Voice/VoiceGateway.cs diff --git a/EllieBot.sln b/EllieBot.sln index ecc63b7..5a74f26 100644 --- a/EllieBot.sln +++ b/EllieBot.sln @@ -26,9 +26,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\El EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Common", "src\Ellie.Common\Ellie.Common.csproj", "{5C1B88B0-B881-4E20-8382-4DDE275F8642}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Econ", "src\Ellie.Econ\Ellie.Econ.csproj", "{A73A6399-50E1-4362-BE29-86C2C88CF05A}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Voice", "src\EllieBot.Voice\EllieBot.Voice.csproj", "{1D93CE3C-80B4-49C7-A9A2-99988920AAEC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -60,14 +58,10 @@ Global {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU {76AC715D-12FF-4CBE-9585-A861139A2D0C}.Release|Any CPU.Build.0 = Release|Any CPU - {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5C1B88B0-B881-4E20-8382-4DDE275F8642}.Release|Any CPU.Build.0 = Release|Any CPU - {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A73A6399-50E1-4362-BE29-86C2C88CF05A}.Release|Any CPU.Build.0 = Release|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -79,8 +73,7 @@ Global {CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {F1A77F56-71B0-430E-AE46-94CDD7D43874} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {76AC715D-12FF-4CBE-9585-A861139A2D0C} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} - {5C1B88B0-B881-4E20-8382-4DDE275F8642} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} - {A73A6399-50E1-4362-BE29-86C2C88CF05A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {1D93CE3C-80B4-49C7-A9A2-99988920AAEC} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {79F61C2C-CDBB-4361-A234-91A0B334CFE4} diff --git a/src/EllieBot.Voice/CloseCodes.cs b/src/EllieBot.Voice/CloseCodes.cs new file mode 100644 index 0000000..6102a19 --- /dev/null +++ b/src/EllieBot.Voice/CloseCodes.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Ayu.Discord.Gateway +{ + public static class CloseCodes + { + private static IReadOnlyDictionary _closeCodes = new ReadOnlyDictionary( + new Dictionary() + { + { 4000, ("Unknown error", "We're not sure what went wrong. Try reconnecting?")}, + { 4001, ("Unknown opcode", "You sent an invalid Gateway opcode or an invalid payload for an opcode. Don't do that!")}, + { 4002, ("Decode error", "You sent an invalid payload to us. Don't do that!")}, + { 4003, ("Not authenticated", "You sent us a payload prior to identifying.")}, + { 4004, ("Authentication failed", "The account token sent with your identify payload is incorrect.")}, + { 4005, ("Already authenticated", "You sent more than one identify payload. Don't do that!")}, + { 4007, ("Invalid seq", "The sequence sent when resuming the session was invalid. Reconnect and start a new session.")}, + { 4008, ("Rate limited", "Woah nelly! You're sending payloads to us too quickly. Slow it down! You will be disconnected on receiving this.")}, + { 4009, ("Session timed out", "Your session timed out. Reconnect and start a new one.")}, + { 4010, ("Invalid shard", "You sent us an invalid shard when identifying.")}, + { 4011, ("Sharding required", "The session would have handled too many guilds - you are required to shard your connection in order to connect.")}, + { 4012, ("Invalid API version", "You sent an invalid version for the gateway.")}, + { 4013, ("Invalid intent(s)", "You sent an invalid intent for a Gateway Intent. You may have incorrectly calculated the bitwise value.")}, + { 4014, ("Disallowed intent(s)", "You sent a disallowed intent for a Gateway Intent. You may have tried to specify an intent that you have not enabled or are not whitelisted for.")} + }); + + public static (string Error, string Message) GetErrorCodeMessage(int closeCode) + { + if (_closeCodes.TryGetValue(closeCode, out var data)) + return data; + + return ("Unknown error", closeCode.ToString()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/EllieBot.Voice.csproj b/src/EllieBot.Voice/EllieBot.Voice.csproj new file mode 100644 index 0000000..b8d42d2 --- /dev/null +++ b/src/EllieBot.Voice/EllieBot.Voice.csproj @@ -0,0 +1,15 @@ + + + netstandard2.1 + 9.0 + true + CS8632 + 1.0.2 + EllieBot.Voice + + + + + + + diff --git a/src/EllieBot.Voice/LibOpus.cs b/src/EllieBot.Voice/LibOpus.cs new file mode 100644 index 0000000..a43a9d8 --- /dev/null +++ b/src/EllieBot.Voice/LibOpus.cs @@ -0,0 +1,125 @@ +using System; +using System.Runtime.InteropServices; + +namespace EllieBot.Voice +{ + internal static unsafe class LibOpus + { + public const string OPUS = "data/lib/opus"; + + [DllImport(OPUS, EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)] + internal static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error); + + [DllImport(OPUS, EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)] + internal static extern void DestroyEncoder(IntPtr encoder); + + [DllImport(OPUS, EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)] + internal static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes); + + [DllImport(OPUS, EntryPoint = "opus_encode_float", CallingConvention = CallingConvention.Cdecl)] + internal static extern int EncodeFloat(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes); + + [DllImport(OPUS, EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)] + internal static extern int EncoderCtl(IntPtr st, OpusCtl request, int value); + } + + public enum OpusApplication + { + VOIP = 2048, + Audio = 2049, + RestrictedLowdelay = 2051 + } + + public unsafe class LibOpusEncoder : IDisposable + { + private readonly IntPtr _encoderPtr; + + private readonly int _sampleRate; + + // private readonly int _channels; + // private readonly int _bitRate; + private readonly int _frameDelay; + + private readonly int _frameSizePerChannel; + public int FrameSizePerChannel => _frameSizePerChannel; + + public const int MaxData = 1276; + + public LibOpusEncoder(int sampleRate, int channels, int bitRate, int frameDelay) + { + _sampleRate = sampleRate; + // _channels = channels; + // _bitRate = bitRate; + _frameDelay = frameDelay; + _frameSizePerChannel = _sampleRate * _frameDelay / 1000; + + _encoderPtr = LibOpus.CreateEncoder(sampleRate, channels, (int)OpusApplication.Audio, out var error); + if (error != OpusError.OK) + throw new ExternalException(error.ToString()); + + LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetSignal, (int)OpusSignal.Music); + LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetInbandFEC, 1); + LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetBitrate, bitRate); + LibOpus.EncoderCtl(_encoderPtr, OpusCtl.SetPacketLossPerc, 2); + } + + public int SetControl(OpusCtl ctl, int value) + => LibOpus.EncoderCtl(_encoderPtr, ctl, value); + + public int Encode(Span input, byte[] output) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + return LibOpus.Encode(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); + } + + public int EncodeFloat(Span input, byte[] output) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + return LibOpus.EncodeFloat(_encoderPtr, inPtr, FrameSizePerChannel, outPtr, output.Length); + } + + + public void Dispose() + => LibOpus.DestroyEncoder(_encoderPtr); + } + + public enum OpusCtl + { + SetBitrate = 4002, + GetBitrate = 4003, + SetBandwidth = 4008, + GetBandwidth = 4009, + SetComplexity = 4010, + GetComplexity = 4011, + SetInbandFEC = 4012, + GetInbandFEC = 4013, + SetPacketLossPerc = 4014, + GetPacketLossPerc = 4015, + SetLsbDepth = 4036, + GetLsbDepth = 4037, + SetDtx = 4016, + GetDtx = 4017, + SetSignal = 4024 + } + + public enum OpusError + { + OK = 0, + BadArg = -1, + BufferToSmall = -2, + InternalError = -3, + InvalidPacket = -4, + Unimplemented = -5, + InvalidState = -6, + AllocFail = -7 + } + + public enum OpusSignal + { + Auto = -1000, + Voice = 3001, + Music = 3002, + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/LibSodium.cs b/src/EllieBot.Voice/LibSodium.cs new file mode 100644 index 0000000..bbbc77d --- /dev/null +++ b/src/EllieBot.Voice/LibSodium.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.InteropServices; + +namespace EllieBot.Voice +{ + internal static unsafe class Sodium + { + private const string SODIUM = "data/lib/libsodium"; + + [DllImport(SODIUM, EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte* nonce, byte* secret); + [DllImport(SODIUM, EntryPoint = "crypto_secretbox_open_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxOpenEasy(byte* output, byte* input, ulong inputLength, byte* nonce, byte* secret); + + public static int Encrypt(byte[] input, int inputOffset, long inputLength, byte[] output, int outputOffset, in ReadOnlySpan nonce, byte[] secret) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + fixed (byte* noncePtr = nonce) + fixed (byte* secretPtr = secret) + return SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength - inputOffset, noncePtr, secretPtr); + } + public static int Decrypt(byte[] input, ulong inputLength, byte[] output, in ReadOnlySpan nonce, byte[] secret) + { + fixed (byte* outPtr = output) + fixed (byte* inPtr = input) + fixed (byte* noncePtr = nonce) + fixed (byte* secretPtr = secret) + return SecretBoxOpenEasy(outPtr, inPtr, inputLength, noncePtr, secretPtr); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/SelectProtocol.cs b/src/EllieBot.Voice/Models/SelectProtocol.cs new file mode 100644 index 0000000..1a9dfa9 --- /dev/null +++ b/src/EllieBot.Voice/Models/SelectProtocol.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class SelectProtocol + { + [JsonProperty("protocol")] + public string Protocol { get; set; } + + [JsonProperty("data")] + public ProtocolData Data { get; set; } + + public sealed class ProtocolData + { + [JsonProperty("address")] + public string Address { get; set; } + [JsonProperty("port")] + public int Port { get; set; } + [JsonProperty("mode")] + public string Mode { get; set; } + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceHello.cs b/src/EllieBot.Voice/Models/VoiceHello.cs new file mode 100644 index 0000000..8fda1d1 --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceHello.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceHello + { + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceIdentify.cs b/src/EllieBot.Voice/Models/VoiceIdentify.cs new file mode 100644 index 0000000..9841869 --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceIdentify.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceIdentify + { + [JsonProperty("server_id")] + public string ServerId { get; set; } + + [JsonProperty("user_id")] + public string UserId { get; set; } + + [JsonProperty("session_id")] + public string SessionId { get; set; } + + [JsonProperty("token")] + public string Token { get; set; } + + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoicePayload.cs b/src/EllieBot.Voice/Models/VoicePayload.cs new file mode 100644 index 0000000..5ac642e --- /dev/null +++ b/src/EllieBot.Voice/Models/VoicePayload.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Discord.Models.Gateway +{ + public sealed class VoicePayload + { + [JsonProperty("op")] + public VoiceOpCode OpCode { get; set; } + + [JsonProperty("d")] + public JToken Data { get; set; } + } + + public enum VoiceOpCode + { + Identify = 0, + SelectProtocol = 1, + Ready = 2, + Heartbeat = 3, + SessionDescription = 4, + Speaking = 5, + HeartbeatAck = 6, + Resume = 7, + Hello = 8, + Resumed = 9, + ClientDisconnect = 13, + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceReady.cs b/src/EllieBot.Voice/Models/VoiceReady.cs new file mode 100644 index 0000000..99cc753 --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceReady.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceReady + { + [JsonProperty("ssrc")] + public uint Ssrc { get; set; } + + [JsonProperty("ip")] + public string Ip { get; set; } + + [JsonProperty("port")] + public int Port { get; set; } + + [JsonProperty("modes")] + public string[] Modes { get; set; } + + [JsonProperty("heartbeat_interval")] + public string HeartbeatInterval { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceResume.cs b/src/EllieBot.Voice/Models/VoiceResume.cs new file mode 100644 index 0000000..bad82b2 --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceResume.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceResume + { + [JsonProperty("server_id")] + public string ServerId { get; set; } + + [JsonProperty("session_id")] + public string SessionId { get; set; } + + [JsonProperty("token")] + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceSessionDescription.cs b/src/EllieBot.Voice/Models/VoiceSessionDescription.cs new file mode 100644 index 0000000..85bdc5e --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceSessionDescription.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceSessionDescription + { + [JsonProperty("mode")] + public string Mode { get; set; } + + [JsonProperty("secret_key")] + public byte[] SecretKey { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/Models/VoiceSpeaking.cs b/src/EllieBot.Voice/Models/VoiceSpeaking.cs new file mode 100644 index 0000000..a9a0610 --- /dev/null +++ b/src/EllieBot.Voice/Models/VoiceSpeaking.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using System; + +namespace EllieBot.Voice.Models +{ + public sealed class VoiceSpeaking + { + [JsonProperty("speaking")] + public int Speaking { get; set; } + + [JsonProperty("delay")] + public int Delay { get; set; } + + [JsonProperty("ssrc")] + public uint Ssrc { get; set; } + + [Flags] + public enum State + { + None = 0, + Microphone = 1 << 0, + Soundshare = 1 << 1, + Priority = 1 << 2 + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/PoopyBufferImmortalized.cs b/src/EllieBot.Voice/PoopyBufferImmortalized.cs new file mode 100644 index 0000000..4a80c86 --- /dev/null +++ b/src/EllieBot.Voice/PoopyBufferImmortalized.cs @@ -0,0 +1,136 @@ +#nullable enable +using System; +using System.Buffers; +using System.Threading; +using System.Threading.Tasks; + +namespace EllieBot.Voice +{ + public sealed class PoopyBufferImmortalized : ISongBuffer + { + private readonly byte[] _buffer; + private readonly byte[] _outputArray; + private CancellationToken _cancellationToken; + private bool _isStopped; + + public int ReadPosition { get; private set; } + public int WritePosition { get; private set; } + + public int ContentLength => WritePosition >= ReadPosition + ? WritePosition - ReadPosition + : (_buffer.Length - ReadPosition) + WritePosition; + + public int FreeSpace => _buffer.Length - ContentLength; + + public bool Stopped => _cancellationToken.IsCancellationRequested || _isStopped; + + public PoopyBufferImmortalized(int frameSize) + { + _buffer = ArrayPool.Shared.Rent(1_000_000); + _outputArray = new byte[frameSize]; + + ReadPosition = 0; + WritePosition = 0; + } + + public void Stop() + => _isStopped = true; + + // this method needs a rewrite + public Task BufferAsync(ITrackDataSource source, CancellationToken cancellationToken) + { + _cancellationToken = cancellationToken; + var bufferingCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task.Run(async () => + { + var output = ArrayPool.Shared.Rent(38400); + try + { + int read; + while (!Stopped && (read = source.Read(output)) > 0) + { + while (!Stopped && FreeSpace <= read) + { + bufferingCompleted.TrySetResult(true); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + if (Stopped) + break; + + Write(output, read); + } + } + finally + { + ArrayPool.Shared.Return(output); + bufferingCompleted.TrySetResult(true); + } + }, cancellationToken); + + return bufferingCompleted.Task; + } + + private void Write(byte[] input, int writeCount) + { + if (WritePosition + writeCount < _buffer.Length) + { + Buffer.BlockCopy(input, 0, _buffer, WritePosition, writeCount); + WritePosition += writeCount; + return; + } + + var wroteNormally = _buffer.Length - WritePosition; + Buffer.BlockCopy(input, 0, _buffer, WritePosition, wroteNormally); + var wroteFromStart = writeCount - wroteNormally; + Buffer.BlockCopy(input, wroteNormally, _buffer, 0, wroteFromStart); + WritePosition = wroteFromStart; + } + + public Span Read(int count, out int length) + { + var toRead = Math.Min(ContentLength, count); + var wp = WritePosition; + + if (ContentLength == 0) + { + length = 0; + return Span.Empty; + } + + if (wp > ReadPosition || ReadPosition + toRead <= _buffer.Length) + { + // thsi can be achieved without copying if + // writer never writes until the end, + // but leaves a single chunk free + Span toReturn = _outputArray; + ((Span)_buffer).Slice(ReadPosition, toRead).CopyTo(toReturn); + ReadPosition += toRead; + length = toRead; + return toReturn; + } + else + { + Span toReturn = _outputArray; + var toEnd = _buffer.Length - ReadPosition; + var bufferSpan = (Span)_buffer; + + bufferSpan.Slice(ReadPosition, toEnd).CopyTo(toReturn); + var fromStart = toRead - toEnd; + bufferSpan.Slice(0, fromStart).CopyTo(toReturn.Slice(toEnd)); + ReadPosition = fromStart; + length = toEnd + fromStart; + return toReturn; + } + } + + public void Dispose() + => ArrayPool.Shared.Return(_buffer); + + public void Reset() + { + ReadPosition = 0; + WritePosition = 0; + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/SocketClient.cs b/src/EllieBot.Voice/SocketClient.cs new file mode 100644 index 0000000..7c32af1 --- /dev/null +++ b/src/EllieBot.Voice/SocketClient.cs @@ -0,0 +1,154 @@ +using Serilog; +using System; +using System.Buffers; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Ayu.Discord.Gateway +{ + public class SocketClient : IDisposable + { + private ClientWebSocket? _ws; + + public event Func? PayloadReceived = delegate { return Task.CompletedTask; }; + public event Func? WebsocketClosed = delegate { return Task.CompletedTask; }; + + const int CHUNK_SIZE = 1024 * 16; + + public async Task RunAndBlockAsync(Uri url, CancellationToken cancel) + { + var error = "Error."; + var bufferWriter = new ArrayBufferWriter(CHUNK_SIZE); + try + { + using (_ws = new()) + { + await _ws.ConnectAsync(url, cancel).ConfigureAwait(false); + // WebsocketConnected!.Invoke(this); + + while (true) + { + var result = await _ws.ReceiveAsync(bufferWriter.GetMemory(CHUNK_SIZE), cancel); + bufferWriter.Advance(result.Count); + if (result.MessageType == WebSocketMessageType.Close) + { + var closeMessage = CloseCodes.GetErrorCodeMessage((int?)_ws.CloseStatus ?? 0).Message; + error = $"Websocket closed ({_ws.CloseStatus}): {_ws.CloseStatusDescription} {closeMessage}"; + break; + } + + if (result.EndOfMessage) + { + var pr = PayloadReceived; + var data = bufferWriter.WrittenMemory.ToArray(); + bufferWriter.Clear(); + + if (pr is not null) + { + await pr.Invoke(data); + } + } + } + } + } + catch (WebSocketException ex) + { + Log.Warning("Disconnected, check your internet connection..."); + Log.Debug(ex, "Websocket Exception in websocket client"); + } + catch (OperationCanceledException) + { + // ignored + } + catch (Exception ex) + { + Log.Error(ex, "Error in websocket client. {Message}", ex.Message); + } + finally + { + bufferWriter.Clear(); + _ws = null; + await ClosedAsync(error).ConfigureAwait(false); + } + } + + private async Task ClosedAsync(string msg = "Error") + { + try + { + await WebsocketClosed!.Invoke(msg).ConfigureAwait(false); + } + catch + { + } + } + + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + + public async Task SendAsync(byte[] data) + { + await _sendLock.WaitAsync().ConfigureAwait(false); + try + { + var ws = _ws; + if (ws is null) + throw new WebSocketException("Websocket is disconnected."); + for (var i = 0; i < data.Length; i += 4096) + { + var count = i + 4096 > data.Length ? data.Length - i : 4096; + await ws.SendAsync(new(data, i, count), + WebSocketMessageType.Text, + i + count >= data.Length, + CancellationToken.None).ConfigureAwait(false); + } + } + finally + { + _sendLock.Release(); + } + } + + public async Task SendBulkAsync(byte[] data) + { + var ws = _ws; + if (ws is null) + throw new WebSocketException("Websocket is disconnected."); + + await ws.SendAsync(new(data, 0, data.Length), + WebSocketMessageType.Binary, + true, + CancellationToken.None).ConfigureAwait(false); + } + + public async Task CloseAsync(string msg = "Stop") + { + if (_ws is not null && _ws.State != WebSocketState.Closed) + { + try + { + await _ws.CloseAsync(WebSocketCloseStatus.InternalServerError, msg, CancellationToken.None) + .ConfigureAwait(false); + + return true; + } + catch + { + } + } + + return false; + } + + public void Dispose() + { + PayloadReceived = null; + WebsocketClosed = null; + var ws = _ws; + if (ws is null) + return; + + ws.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/SongBuffer.cs b/src/EllieBot.Voice/SongBuffer.cs new file mode 100644 index 0000000..73f2bd1 --- /dev/null +++ b/src/EllieBot.Voice/SongBuffer.cs @@ -0,0 +1,95 @@ +using Serilog; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace EllieBot.Voice +{ + public interface ISongBuffer : IDisposable + { + Span Read(int toRead, out int read); + Task BufferAsync(ITrackDataSource source, CancellationToken cancellationToken); + void Reset(); + void Stop(); + } + + public interface ITrackDataSource + { + public int Read(byte[] output); + } + + public sealed class FfmpegTrackDataSource : ITrackDataSource, IDisposable + { + private Process _p; + + private readonly string _streamUrl; + private readonly bool _isLocal; + private readonly string _pcmType; + + private FfmpegTrackDataSource(int bitDepth, string streamUrl, bool isLocal) + { + this._pcmType = bitDepth == 16 ? "s16le" : "f32le"; + this._streamUrl = streamUrl; + this._isLocal = isLocal; + } + + public static FfmpegTrackDataSource CreateAsync(int bitDepth, string streamUrl, bool isLocal) + { + try + { + var source = new FfmpegTrackDataSource(bitDepth, streamUrl, isLocal); + source.StartFFmpegProcess(); + return source; + } + catch (System.ComponentModel.Win32Exception) + { + Log.Error(@"You have not properly installed or configured FFMPEG. +Please install and configure FFMPEG to play music. +Check the guides for your platform on how to setup ffmpeg correctly: + Windows Guide: https://goo.gl/OjKk8F + Linux Guide: https://goo.gl/ShjCUo"); + throw; + } + catch (OperationCanceledException) + { + } + catch (InvalidOperationException) + { + } + catch (Exception ex) + { + Log.Information(ex, "Error starting ffmpeg: {ErrorMessage}", ex.Message); + } + + return null; + } + + private Process StartFFmpegProcess() + { + var args = $"-err_detect ignore_err -i {_streamUrl} -f {_pcmType} -ar 48000 -vn -ac 2 pipe:1 -loglevel error"; + if (!_isLocal) + args = $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 {args}"; + + return _p = Process.Start(new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = args, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = false, + CreateNoWindow = true, + }); + } + + public int Read(byte[] output) + => _p.StandardOutput.BaseStream.Read(output); + + public void Dispose() + { + try { _p?.Kill(); } catch { } + + try { _p?.Dispose(); } catch { } + } + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/VoiceClient.cs b/src/EllieBot.Voice/VoiceClient.cs new file mode 100644 index 0000000..12d6e20 --- /dev/null +++ b/src/EllieBot.Voice/VoiceClient.cs @@ -0,0 +1,207 @@ +using System; +using System.Buffers; + +namespace EllieBot.Voice +{ + public sealed class VoiceClient : IDisposable + { + delegate int EncodeDelegate(Span input, byte[] output); + + private readonly int sampleRate; + private readonly int bitRate; + private readonly int channels; + private readonly int frameDelay; + private readonly int bitDepth; + + public LibOpusEncoder Encoder { get; } + private readonly ArrayPool _arrayPool; + + public int BitDepth => bitDepth * 8; + public int Delay => frameDelay; + + private int FrameSizePerChannel => Encoder.FrameSizePerChannel; + public int InputLength => FrameSizePerChannel * channels * bitDepth; + + EncodeDelegate Encode; + + // https://github.com/xiph/opus/issues/42 w + public VoiceClient(SampleRate sampleRate = SampleRate._48k, + Bitrate bitRate = Bitrate._192k, + Channels channels = Channels.Two, + FrameDelay frameDelay = FrameDelay.Delay20, + BitDepthEnum bitDepthEnum = BitDepthEnum.Float32) + { + this.frameDelay = (int)frameDelay; + this.sampleRate = (int)sampleRate; + this.bitRate = (int)bitRate; + this.channels = (int)channels; + this.bitDepth = (int)bitDepthEnum; + + this.Encoder = new(this.sampleRate, this.channels, this.bitRate, this.frameDelay); + + Encode = bitDepthEnum switch + { + BitDepthEnum.Float32 => Encoder.EncodeFloat, + BitDepthEnum.UInt16 => Encoder.Encode, + _ => throw new NotSupportedException(nameof(BitDepth)) + }; + + if (bitDepthEnum == BitDepthEnum.Float32) + { + Encode = Encoder.EncodeFloat; + } + else + { + Encode = Encoder.Encode; + } + + _arrayPool = ArrayPool.Shared; + } + + public int SendPcmFrame(VoiceGateway gw, Span data, int offset, int count) + { + var secretKey = gw.SecretKey; + if (secretKey.Length == 0) + { + return (int)SendPcmError.SecretKeyUnavailable; + } + + // encode using opus + var encodeOutput = _arrayPool.Rent(LibOpusEncoder.MaxData); + try + { + var encodeOutputLength = Encode(data, encodeOutput); + return SendOpusFrame(gw, encodeOutput, 0, encodeOutputLength); + } + finally + { + _arrayPool.Return(encodeOutput); + } + } + + public int SendOpusFrame(VoiceGateway gw, byte[] data, int offset, int count) + { + var secretKey = gw.SecretKey; + if (secretKey is null) + { + return (int)SendPcmError.SecretKeyUnavailable; + } + + // form RTP header + var headerLength = 1 // version + flags + + 1 // payload type + + 2 // sequence + + 4 // timestamp + + 4; // ssrc + + var header = new byte[headerLength]; + + header[0] = 0x80; // version + flags + header[1] = 0x78; // payload type + + // get byte values for header data + var seqBytes = BitConverter.GetBytes(gw.Sequence); // 2 + var nonceBytes = BitConverter.GetBytes(gw.NonceSequence); // 2 + var timestampBytes = BitConverter.GetBytes(gw.Timestamp); // 4 + var ssrcBytes = BitConverter.GetBytes(gw.Ssrc); // 4 + + gw.Timestamp += (uint)FrameSizePerChannel; + gw.Sequence++; + gw.NonceSequence++; + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(seqBytes); + Array.Reverse(nonceBytes); + Array.Reverse(timestampBytes); + Array.Reverse(ssrcBytes); + } + + // copy headers + Buffer.BlockCopy(seqBytes, 0, header, 2, 2); + Buffer.BlockCopy(timestampBytes, 0, header, 4, 4); + Buffer.BlockCopy(ssrcBytes, 0, header, 8, 4); + + //// encryption part + //// create a byte array where to store the encrypted data + //// it has to be inputLength + crypto_secretbox_MACBYTES (constant with value 16) + var encryptedBytes = new byte[count + 16]; + + //// form nonce with header + 12 empty bytes + //var nonce = new byte[24]; + //Buffer.BlockCopy(rtpHeader, 0, nonce, 0, rtpHeader.Length); + + var nonce = new byte[4]; + Buffer.BlockCopy(seqBytes, 0, nonce, 2, 2); + + Sodium.Encrypt(data, 0, count, encryptedBytes, 0, nonce, secretKey); + + var rtpDataLength = headerLength + encryptedBytes.Length + nonce.Length; + var rtpData = _arrayPool.Rent(rtpDataLength); + try + { + //copy headers + Buffer.BlockCopy(header, 0, rtpData, 0, header.Length); + //copy audio data + Buffer.BlockCopy(encryptedBytes, 0, rtpData, header.Length, encryptedBytes.Length); + Buffer.BlockCopy(nonce, 0, rtpData, rtpDataLength - 4, 4); + + gw.SendRtpData(rtpData, rtpDataLength); + // FUTURE When there's a break in the sent data, + // the packet transmission shouldn't simply stop. + // Instead, send five frames of silence (0xF8, 0xFF, 0xFE) + // before stopping to avoid unintended Opus interpolation + // with subsequent transmissions. + + return rtpDataLength; + } + finally + { + _arrayPool.Return(rtpData); + } + } + + public void Dispose() + => Encoder.Dispose(); + } + + public enum SendPcmError + { + SecretKeyUnavailable = -1, + } + + + public enum FrameDelay + { + Delay5 = 5, + Delay10 = 10, + Delay20 = 20, + Delay40 = 40, + Delay60 = 60, + } + + public enum BitDepthEnum + { + UInt16 = sizeof(UInt16), + Float32 = sizeof(float), + } + + public enum SampleRate + { + _48k = 48_000, + } + + public enum Bitrate + { + _64k = 64 * 1024, + _96k = 96 * 1024, + _128k = 128 * 1024, + _192k = 192 * 1024, + } + + public enum Channels + { + One = 1, + Two = 2, + } +} \ No newline at end of file diff --git a/src/EllieBot.Voice/VoiceGateway.cs b/src/EllieBot.Voice/VoiceGateway.cs new file mode 100644 index 0000000..af20aa3 --- /dev/null +++ b/src/EllieBot.Voice/VoiceGateway.cs @@ -0,0 +1,375 @@ +using EllieBot.Voice.Models; +using Discord.Models.Gateway; +using Newtonsoft.Json.Linq; +using Serilog; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Ayu.Discord.Gateway; +using Newtonsoft.Json; + +namespace EllieBot.Voice +{ + public class VoiceGateway + { + private class QueueItem + { + public VoicePayload Payload { get; } + public TaskCompletionSource Result { get; } + + public QueueItem(VoicePayload payload, TaskCompletionSource result) + { + Payload = payload; + Result = result; + } + } + + private readonly ulong _guildId; + private readonly ulong _userId; + private readonly string _sessionId; + private readonly string _token; + private readonly string _endpoint; + private readonly Uri _websocketUrl; + private readonly Channel _channel; + + public TaskCompletionSource ConnectingFinished { get; } + + private readonly Random _rng; + private readonly SocketClient _ws; + private readonly UdpClient _udpClient; + private Timer? _heartbeatTimer; + private bool _receivedAck; + private IPEndPoint? _udpEp; + + public uint Ssrc { get; private set; } + public string Ip { get; private set; } = string.Empty; + public int Port { get; private set; } = 0; + public byte[] SecretKey { get; private set; } = Array.Empty(); + public string Mode { get; private set; } = string.Empty; + public ushort Sequence { get; set; } + public uint NonceSequence { get; set; } + public uint Timestamp { get; set; } + public string MyIp { get; private set; } = string.Empty; + public ushort MyPort { get; private set; } + private bool _shouldResume; + + private readonly CancellationTokenSource _stopCancellationSource; + private readonly CancellationToken _stopCancellationToken; + public bool Stopped => _stopCancellationToken.IsCancellationRequested; + + public event Func OnClosed = delegate { return Task.CompletedTask; }; + + public VoiceGateway(ulong guildId, ulong userId, string session, string token, string endpoint) + { + this._guildId = guildId; + this._userId = userId; + this._sessionId = session; + this._token = token; + this._endpoint = endpoint; + + //Log.Information("g: {GuildId} u: {UserId} sess: {Session} tok: {Token} ep: {Endpoint}", + // guildId, userId, session, token, endpoint); + + this._websocketUrl = new($"wss://{_endpoint.Replace(":80", "")}?v=4"); + this._channel = Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false, + }); + + ConnectingFinished = new(); + + _rng = new(); + + _ws = new(); + _udpClient = new(); + _stopCancellationSource = new(); + _stopCancellationToken = _stopCancellationSource.Token; + + _ws.PayloadReceived += _ws_PayloadReceived; + _ws.WebsocketClosed += _ws_WebsocketClosed; + } + + public Task WaitForReadyAsync() + => ConnectingFinished.Task; + + private async Task SendLoop() + { + while (!_stopCancellationToken.IsCancellationRequested) + { + try + { + var qi = await _channel.Reader.ReadAsync(_stopCancellationToken); + //Log.Information("Sending payload with opcode {OpCode}", qi.Payload.OpCode); + + var json = JsonConvert.SerializeObject(qi.Payload); + + if (!_stopCancellationToken.IsCancellationRequested) + await _ws.SendAsync(Encoding.UTF8.GetBytes(json)); + _ = Task.Run(() => qi.Result.TrySetResult(true)); + } + catch (ChannelClosedException) + { + Log.Warning("Voice gateway send channel is closed"); + } + } + } + + private async Task _ws_PayloadReceived(byte[] arg) + { + var payload = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(arg)); + if (payload is null) + return; + try + { + //Log.Information("Received payload with opcode {OpCode}", payload.OpCode); + + switch (payload.OpCode) + { + case VoiceOpCode.Identify: + // sent, not received. + break; + case VoiceOpCode.SelectProtocol: + // sent, not received + break; + case VoiceOpCode.Ready: + var ready = payload.Data.ToObject(); + await HandleReadyAsync(ready!); + _shouldResume = true; + break; + case VoiceOpCode.Heartbeat: + // sent, not received + break; + case VoiceOpCode.SessionDescription: + var sd = payload.Data.ToObject(); + await HandleSessionDescription(sd!); + break; + case VoiceOpCode.Speaking: + // ignore for now + break; + case VoiceOpCode.HeartbeatAck: + _receivedAck = true; + break; + case VoiceOpCode.Resume: + // sent, not received + break; + case VoiceOpCode.Hello: + var hello = payload.Data.ToObject(); + await HandleHelloAsync(hello!); + break; + case VoiceOpCode.Resumed: + _shouldResume = true; + break; + case VoiceOpCode.ClientDisconnect: + break; + } + } + catch (Exception ex) + { + Log.Error(ex, "Error handling payload with opcode {OpCode}: {Message}", payload.OpCode, ex.Message); + } + } + private Task _ws_WebsocketClosed(string arg) + { + if (!string.IsNullOrWhiteSpace(arg)) + { + Log.Warning("Voice Websocket closed: {Arg}", arg); + } + + var hbt = _heartbeatTimer; + hbt?.Change(Timeout.Infinite, Timeout.Infinite); + _heartbeatTimer = null; + + if (!_stopCancellationToken.IsCancellationRequested && _shouldResume) + { + _ = _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken); + return Task.CompletedTask; + } + + _ws.WebsocketClosed -= _ws_WebsocketClosed; + _ws.PayloadReceived -= _ws_PayloadReceived; + + if (!_stopCancellationToken.IsCancellationRequested) + _stopCancellationSource.Cancel(); + + return this.OnClosed(this); + } + + public void SendRtpData(byte[] rtpData, int length) + => _udpClient.Send(rtpData, length, _udpEp); + + private Task HandleSessionDescription(VoiceSessionDescription sd) + { + SecretKey = sd.SecretKey; + Mode = sd.Mode; + + _ = Task.Run(() => ConnectingFinished.TrySetResult(true)); + + return Task.CompletedTask; + } + + private Task ResumeAsync() + { + _shouldResume = false; + return SendCommandPayloadAsync(new() + { + OpCode = VoiceOpCode.Resume, + Data = JToken.FromObject(new VoiceResume + { + ServerId = this._guildId.ToString(), + SessionId = this._sessionId, + Token = this._token, + }) + }); + } + + private async Task HandleReadyAsync(VoiceReady ready) + { + Ssrc = ready.Ssrc; + + //Log.Information("Received ready {GuildId}, {Session}, {Token}", guildId, session, token); + + _udpEp = new(IPAddress.Parse(ready.Ip), ready.Port); + + var ssrcBytes = BitConverter.GetBytes(Ssrc); + Array.Reverse(ssrcBytes); + var ipDiscoveryData = new byte[74]; + Buffer.BlockCopy(ssrcBytes, 0, ipDiscoveryData, 4, ssrcBytes.Length); + ipDiscoveryData[0] = 0x00; + ipDiscoveryData[1] = 0x01; + ipDiscoveryData[2] = 0x00; + ipDiscoveryData[3] = 0x46; + await _udpClient.SendAsync(ipDiscoveryData, ipDiscoveryData.Length, _udpEp); + while (true) + { + var buffer = _udpClient.Receive(ref _udpEp); + + if (buffer.Length == 74) + { + //Log.Information("Received IP discovery data."); + + var myIp = Encoding.UTF8.GetString(buffer, 8, buffer.Length - 10); + MyIp = myIp.TrimEnd('\0'); + MyPort = (ushort)((buffer[^2] << 8) | buffer[^1]); + + //Log.Information("{MyIp}:{MyPort}", MyIp, MyPort); + + await SelectProtocol(); + return; + } + + //Log.Information("Received voice data"); + } + } + + private Task HandleHelloAsync(VoiceHello data) + { + _receivedAck = true; + _heartbeatTimer = new(async _ => + { + await SendHeartbeatAsync(); + }, default, data.HeartbeatInterval, data.HeartbeatInterval); + + if (_shouldResume) + { + return ResumeAsync(); + } + + return IdentifyAsync(); + } + + private Task IdentifyAsync() + => SendCommandPayloadAsync(new() + { + OpCode = VoiceOpCode.Identify, + Data = JToken.FromObject(new VoiceIdentify + { + ServerId = _guildId.ToString(), + SessionId = _sessionId, + Token = _token, + UserId = _userId.ToString(), + }) + }); + + private Task SelectProtocol() + => SendCommandPayloadAsync(new() + { + OpCode = VoiceOpCode.SelectProtocol, + Data = JToken.FromObject(new SelectProtocol + { + Protocol = "udp", + Data = new() + { + Address = MyIp, + Port = MyPort, + Mode = "xsalsa20_poly1305_lite", + } + }) + }); + + private async Task SendHeartbeatAsync() + { + if (!_receivedAck) + { + Log.Warning("Voice gateway didn't receive HearbeatAck - closing"); + var success = await _ws.CloseAsync(); + if (!success) + await _ws_WebsocketClosed(null); + return; + } + + _receivedAck = false; + await SendCommandPayloadAsync(new() + { + OpCode = VoiceOpCode.Heartbeat, + Data = JToken.FromObject(_rng.Next()) + }); + } + + public Task SendSpeakingAsync(VoiceSpeaking.State speaking) + => SendCommandPayloadAsync(new() + { + OpCode = VoiceOpCode.Speaking, + Data = JToken.FromObject(new VoiceSpeaking + { + Delay = 0, + Ssrc = Ssrc, + Speaking = (int)speaking + }) + }); + + public Task StopAsync() + { + Started = false; + _shouldResume = false; + if (!_stopCancellationSource.IsCancellationRequested) + try { _stopCancellationSource.Cancel(); } catch { } + return _ws.CloseAsync("Stopped by the user."); + } + + public Task Start() + { + Started = true; + _ = SendLoop(); + return _ws.RunAndBlockAsync(_websocketUrl, _stopCancellationToken); + } + + public bool Started { get; set; } + + public async Task SendCommandPayloadAsync(VoicePayload payload) + { + var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queueItem = new QueueItem(payload, complete); + + if (!_channel.Writer.TryWrite(queueItem)) + await _channel.Writer.WriteAsync(queueItem); + + await complete.Task; + } + } +} \ No newline at end of file -- 2.43.0 From b1e6e93fe4e0bd09d4319de77dc27a4123593c24 Mon Sep 17 00:00:00 2001 From: Toastie Date: Sun, 12 May 2024 23:43:23 +1200 Subject: [PATCH 002/340] Updated Ellie.Marmalade --- .../Attributes/FilterAttribute.cs | 2 +- .../Attributes/MarmaladePermAttribute.cs | 2 +- .../Attributes/bot_owner_onlyAttribute.cs | 2 +- .../Attributes/bot_permAttribute.cs | 2 +- .../Attributes/cmdAttribute.cs | 2 +- .../Attributes/injectAttribute.cs | 2 +- .../Attributes/leftoverAttribute.cs | 2 +- .../Attributes/prioAttribute.cs | 2 +- .../Attributes/svcAttribute.cs | 2 +- .../Attributes/user_permAttribute.cs | 2 +- src/Ellie.Marmalade/Canary.cs | 2 +- src/Ellie.Marmalade/Context/AnyContext.cs | 11 +------ src/Ellie.Marmalade/Context/DmContext.cs | 2 +- src/Ellie.Marmalade/Context/GuildContext.cs | 2 +- src/Ellie.Marmalade/Ellie.Marmalade.csproj | 13 ++++----- .../Extensions/EmbedBuilderExtensions.cs | 14 --------- .../Extensions/MarmaladeExtensions.cs | 29 ++++++++----------- .../ParamParser/ParamParser.cs | 2 +- .../ParamParser/ParseResult.cs | 2 +- src/Ellie.Marmalade/Strings/CommandStrings.cs | 2 +- .../Strings/IMarmaladeStrings.cs | 2 +- .../Strings/IMarmaladeStringsProvider.cs | 2 +- .../Strings/LocalMarmaladeStringsProvider.cs | 2 +- .../Strings/MarmaladeStrings.cs | 2 +- src/Ellie.Marmalade/Strings/StringsLoader.cs | 2 +- 25 files changed, 39 insertions(+), 70 deletions(-) delete mode 100644 src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs diff --git a/src/Ellie.Marmalade/Attributes/FilterAttribute.cs b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs index de6ae24..d5a428b 100644 --- a/src/Ellie.Marmalade/Attributes/FilterAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/FilterAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Overridden to implement custom checks which commands have to pass in order to be executed. diff --git a/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs index 0001d76..0f04c23 100644 --- a/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/MarmaladePermAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Used as a marker class for bot_perm and user_perm Attributes diff --git a/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs index 8186614..31c3cfd 100644 --- a/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/bot_owner_onlyAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; [AttributeUsage(AttributeTargets.Method)] public sealed class bot_owner_onlyAttribute : MarmaladePermAttribute diff --git a/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs index 30fa320..6bd6af1 100644 --- a/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/bot_permAttribute.cs @@ -1,6 +1,6 @@ using Discord; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class bot_permAttribute : MarmaladePermAttribute diff --git a/src/Ellie.Marmalade/Attributes/cmdAttribute.cs b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs index 56ce03b..0dc068e 100644 --- a/src/Ellie.Marmalade/Attributes/cmdAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/cmdAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Marks a method as a snek command diff --git a/src/Ellie.Marmalade/Attributes/injectAttribute.cs b/src/Ellie.Marmalade/Attributes/injectAttribute.cs index 4865cff..be843ae 100644 --- a/src/Ellie.Marmalade/Attributes/injectAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/injectAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Marks services in command arguments for injection. diff --git a/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs index b16c225..71543e2 100644 --- a/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/leftoverAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Marks the parameter to take diff --git a/src/Ellie.Marmalade/Attributes/prioAttribute.cs b/src/Ellie.Marmalade/Attributes/prioAttribute.cs index 9b1cc81..2868b23 100644 --- a/src/Ellie.Marmalade/Attributes/prioAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/prioAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Sets the priority of a command in case there are multiple commands with the same name but different parameters. diff --git a/src/Ellie.Marmalade/Attributes/svcAttribute.cs b/src/Ellie.Marmalade/Attributes/svcAttribute.cs index dab065f..a453303 100644 --- a/src/Ellie.Marmalade/Attributes/svcAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/svcAttribute.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Marks the class as a service which can be used within the same Medusa diff --git a/src/Ellie.Marmalade/Attributes/user_permAttribute.cs b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs index 7d195eb..b0a3aa3 100644 --- a/src/Ellie.Marmalade/Attributes/user_permAttribute.cs +++ b/src/Ellie.Marmalade/Attributes/user_permAttribute.cs @@ -1,6 +1,6 @@ using Discord; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class user_permAttribute : MarmaladePermAttribute diff --git a/src/Ellie.Marmalade/Canary.cs b/src/Ellie.Marmalade/Canary.cs index 6fef82c..4b7bbbb 100644 --- a/src/Ellie.Marmalade/Canary.cs +++ b/src/Ellie.Marmalade/Canary.cs @@ -1,6 +1,6 @@ using Discord; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// The base class which will be loaded as a module into EllieBot diff --git a/src/Ellie.Marmalade/Context/AnyContext.cs b/src/Ellie.Marmalade/Context/AnyContext.cs index 816a95d..4f7207c 100644 --- a/src/Ellie.Marmalade/Context/AnyContext.cs +++ b/src/Ellie.Marmalade/Context/AnyContext.cs @@ -1,7 +1,7 @@ using Discord; using EllieBot; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Commands which take this class as a first parameter can be executed in both DMs and Servers @@ -40,13 +40,4 @@ public abstract class AnyContext /// Arguments (if any) to format in /// A formatted localized string public abstract string GetText(string key, object[]? args = null); - - /// - /// Creates a context-aware instance - /// (future feature for guild-based embed colors) - /// Any code dealing with embeds should use it for future-proofness - /// instead of manually creating embedbuilder instances - /// - /// A context-aware instance - public abstract IEmbedBuilder Embed(); } \ No newline at end of file diff --git a/src/Ellie.Marmalade/Context/DmContext.cs b/src/Ellie.Marmalade/Context/DmContext.cs index a703fa4..d971ee5 100644 --- a/src/Ellie.Marmalade/Context/DmContext.cs +++ b/src/Ellie.Marmalade/Context/DmContext.cs @@ -1,6 +1,6 @@ using Discord; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Commands which take this type as the first parameter can only be executed in DMs diff --git a/src/Ellie.Marmalade/Context/GuildContext.cs b/src/Ellie.Marmalade/Context/GuildContext.cs index 33cca4f..63ca873 100644 --- a/src/Ellie.Marmalade/Context/GuildContext.cs +++ b/src/Ellie.Marmalade/Context/GuildContext.cs @@ -1,6 +1,6 @@ using Discord; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Commands which take this type as a first parameter can only be executed in a server diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj index aa15438..b01a911 100644 --- a/src/Ellie.Marmalade/Ellie.Marmalade.csproj +++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj @@ -1,24 +1,21 @@ - net6.0 + net8.0 enable enable - preview - true - Ellie.Marmalade The EllieBot Devs - - - + + + - 5.0.0 + 9.0.0 diff --git a/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs b/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs deleted file mode 100644 index 6b6d50d..0000000 --- a/src/Ellie.Marmalade/Extensions/EmbedBuilderExtensions.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace EllieBot; - -public static class EmbedBuilderExtensions -{ - public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb) - => eb.WithColor(EmbedColor.Ok); - - public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb) - => eb.WithColor(EmbedColor.Pending); - - public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb) - => eb.WithColor(EmbedColor.Error); - -} diff --git a/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs index c02f973..1047966 100644 --- a/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs +++ b/src/Ellie.Marmalade/Extensions/MarmaladeExtensions.cs @@ -1,11 +1,10 @@ using Discord; -using Ellie.Marmalade; -namespace EllieBot; +namespace EllieBot.Marmalade; public static class MarmaladeExtensions { - public static Task EmbedAsync(this IMessageChannel ch, IEmbedBuilder embed, string msg = "") + public static Task EmbedAsync(this IMessageChannel ch, EmbedBuilder embed, string msg = "") => ch.SendMessageAsync(msg, embed: embed.Build(), options: new() @@ -13,25 +12,21 @@ public static class MarmaladeExtensions RetryMode = RetryMode.Retry502 }); - // unlocalized - public static Task SendConfirmAsync(this IMessageChannel ch, AnyContext ctx, string msg) - => ch.EmbedAsync(ctx.Embed().WithOkColor().WithDescription(msg)); - - public static Task SendPendingAsync(this IMessageChannel ch, AnyContext ctx, string msg) - => ch.EmbedAsync(ctx.Embed().WithPendingColor().WithDescription(msg)); - - public static Task SendErrorAsync(this IMessageChannel ch, AnyContext ctx, string msg) - => ch.EmbedAsync(ctx.Embed().WithErrorColor().WithDescription(msg)); - // unlocalized public static Task SendConfirmAsync(this AnyContext ctx, string msg) - => ctx.Channel.SendConfirmAsync(ctx, msg); + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(0, 200, 0) + .WithDescription(msg)); public static Task SendPendingAsync(this AnyContext ctx, string msg) - => ctx.Channel.SendPendingAsync(ctx, msg); + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(200, 200, 0) + .WithDescription(msg)); public static Task SendErrorAsync(this AnyContext ctx, string msg) - => ctx.Channel.SendErrorAsync(ctx, msg); + => ctx.Channel.EmbedAsync(new EmbedBuilder() + .WithColor(200, 0, 0) + .WithDescription(msg)); // localized public static Task ConfirmAsync(this AnyContext ctx) @@ -63,4 +58,4 @@ public static class MarmaladeExtensions public static Task ReplyConfirmLocalizedAsync(this AnyContext ctx, string key, params object[]? args) => ctx.SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {ctx.GetText(key, args)}"); -} +} \ No newline at end of file diff --git a/src/Ellie.Marmalade/ParamParser/ParamParser.cs b/src/Ellie.Marmalade/ParamParser/ParamParser.cs index a174a78..e4758d6 100644 --- a/src/Ellie.Marmalade/ParamParser/ParamParser.cs +++ b/src/Ellie.Marmalade/ParamParser/ParamParser.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Overridden to implement parsers for custom types diff --git a/src/Ellie.Marmalade/ParamParser/ParseResult.cs b/src/Ellie.Marmalade/ParamParser/ParseResult.cs index e12b77e..24c115b 100644 --- a/src/Ellie.Marmalade/ParamParser/ParseResult.cs +++ b/src/Ellie.Marmalade/ParamParser/ParseResult.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; public readonly struct ParseResult { diff --git a/src/Ellie.Marmalade/Strings/CommandStrings.cs b/src/Ellie.Marmalade/Strings/CommandStrings.cs index 5d06f3c..056d028 100644 --- a/src/Ellie.Marmalade/Strings/CommandStrings.cs +++ b/src/Ellie.Marmalade/Strings/CommandStrings.cs @@ -1,6 +1,6 @@ using YamlDotNet.Serialization; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; public readonly struct CommandStrings { diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs index 37c4299..f76cf82 100644 --- a/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStrings.cs @@ -1,6 +1,6 @@ using System.Globalization; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Defines methods to retrieve and reload marmalade strings diff --git a/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs index 6df86b0..2845f94 100644 --- a/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs +++ b/src/Ellie.Marmalade/Strings/IMarmaladeStringsProvider.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Implemented by classes which provide localized strings in their own ways diff --git a/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs index 0ea98d5..3b02ca6 100644 --- a/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs +++ b/src/Ellie.Marmalade/Strings/LocalMarmaladeStringsProvider.cs @@ -1,4 +1,4 @@ -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; public class LocalMarmaladeStringsProvider : IMarmaladeStringsProvider { diff --git a/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs index 286e9a1..c4f9f26 100644 --- a/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs +++ b/src/Ellie.Marmalade/Strings/MarmaladeStrings.cs @@ -1,7 +1,7 @@ using System.Globalization; using Serilog; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; public class MarmaladeStrings : IMarmaladeStrings { diff --git a/src/Ellie.Marmalade/Strings/StringsLoader.cs b/src/Ellie.Marmalade/Strings/StringsLoader.cs index 986fcad..e9b2493 100644 --- a/src/Ellie.Marmalade/Strings/StringsLoader.cs +++ b/src/Ellie.Marmalade/Strings/StringsLoader.cs @@ -2,7 +2,7 @@ using Serilog; using YamlDotNet.Serialization; -namespace Ellie.Marmalade; +namespace EllieBot.Marmalade; /// /// Loads strings from the shortcut or localizable path -- 2.43.0 From 9a92725f41f23eb3b234bb69d73d956fd9aa3f7b Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 00:37:12 +1200 Subject: [PATCH 003/340] Added pack-and-push.ps1 --- src/Ellie.Marmalade/pack-and-push.ps1 | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/Ellie.Marmalade/pack-and-push.ps1 diff --git a/src/Ellie.Marmalade/pack-and-push.ps1 b/src/Ellie.Marmalade/pack-and-push.ps1 new file mode 100644 index 0000000..5f5a2eb --- /dev/null +++ b/src/Ellie.Marmalade/pack-and-push.ps1 @@ -0,0 +1,2 @@ +dotnet pack -o bin/Release/packed +dotnet nuget push bin/Release/packed/ --source emotionlab \ No newline at end of file -- 2.43.0 From 83a8a34aeacaf83d108cd114af4dfabceaa0afe4 Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 00:56:07 +1200 Subject: [PATCH 004/340] Updated EllieBot.VotesApi --- src/EllieBot.VotesApi/Common/AuthHandler.cs | 3 +-- src/EllieBot.VotesApi/EllieBot.VotesApi.csproj | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/EllieBot.VotesApi/Common/AuthHandler.cs b/src/EllieBot.VotesApi/Common/AuthHandler.cs index 9294153..fbe7aa8 100644 --- a/src/EllieBot.VotesApi/Common/AuthHandler.cs +++ b/src/EllieBot.VotesApi/Common/AuthHandler.cs @@ -20,9 +20,8 @@ namespace EllieBot.VotesApi public AuthHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, - ISystemClock clock, IConfiguration conf) - : base(options, logger, encoder, clock) + : base(options, logger, encoder) => _conf = conf; protected override Task HandleAuthenticateAsync() diff --git a/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj b/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj index f42db3d..af0ed1f 100644 --- a/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj +++ b/src/EllieBot.VotesApi/EllieBot.VotesApi.csproj @@ -1,13 +1,13 @@  - net6.0 + net8.0 Linux - + -- 2.43.0 From f3d824c0fd9b77d7ba8cf1f8ab7cb9fe1fad7fbe Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 00:56:41 +1200 Subject: [PATCH 005/340] Updated TODO.md --- TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 15c0a05..dc3a441 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,9 @@ # List of things to do - - Finish the Ellie.Marmalade project + - ~~Finish the Ellie.Marmalade project~~ Done - Finish the EllieBot.Tests project - Finish the EllieBot project - Finish the EllieBot.Coordinator project - Finish the EllieBot.Generators project - - Finish the EllieBot.Voice project - - Finish the EllieBot.VotesApi project \ No newline at end of file + - ~~Finish the EllieBot.Voice project~~ Done + - ~~Finish the EllieBot.VotesApi project~~ \ No newline at end of file -- 2.43.0 From 453d823e5be3473ca58cb0d24f8f976e0b587eb5 Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 01:00:17 +1200 Subject: [PATCH 006/340] Updated EllieBot.Coordinator --- src/EllieBot.Coordinator/EllieBot.Coordinator.csproj | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj index 91942cc..af90db3 100644 --- a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj +++ b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 @@ -9,11 +9,11 @@ - - - + + + - + -- 2.43.0 From 4d7056dc61ba25bc0ce83c28be313ea847d532a7 Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 01:08:29 +1200 Subject: [PATCH 007/340] Updated EllieBot.Generators --- .../Cloneable/CloneableGenerator.cs | 96 ++--- .../Cloneable/SymbolExtensions.cs | 3 +- .../Cloneable/SyntaxReceiver.cs | 2 +- .../Command/CommandAttributesGenerator.cs | 336 ------------------ .../EllieBot.Generators.csproj | 1 + .../LocalizedStringsGenerator.cs | 36 +- 6 files changed, 69 insertions(+), 405 deletions(-) delete mode 100644 src/EllieBot.Generators/Command/CommandAttributesGenerator.cs diff --git a/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs index 6bd7da4..4b5f8cb 100644 --- a/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs +++ b/src/EllieBot.Generators/Cloneable/CloneableGenerator.cs @@ -6,8 +6,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; -using System.Collections.Generic; -using System.Linq; using System.Text; namespace Cloneable @@ -23,54 +21,60 @@ namespace Cloneable private const string CLONE_ATTRIBUTE_STRING = "CloneAttribute"; private const string IGNORE_CLONE_ATTRIBUTE_STRING = "IgnoreCloneAttribute"; - private const string CLONEABLE_ATTRIBUTE_TEXT = @"// -using System; + private const string CLONEABLE_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)] + internal sealed class {{CLONEABLE_ATTRIBUTE_STRING}} : Attribute + { + public {{CLONEABLE_ATTRIBUTE_STRING}}() + { + } + + public bool {{EXPLICIT_DECLARATION_KEY_STRING}} { get; set; } + } + } -namespace " + CLONEABLE_NAMESPACE + @" -{ - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = true, AllowMultiple = false)] - public sealed class " + CLONEABLE_ATTRIBUTE_STRING + @" : Attribute - { - public " + CLONEABLE_ATTRIBUTE_STRING + @"() - { - } + """; - public bool " + EXPLICIT_DECLARATION_KEY_STRING + @" { get; set; } - } -} -"; + private const string CLONE_PROPERTY_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + internal sealed class {{CLONE_ATTRIBUTE_STRING}} : Attribute + { + public {{CLONE_ATTRIBUTE_STRING}}() + { + } + + public bool {{PREVENT_DEEP_COPY_KEY_STRING}} { get; set; } + } + } - private const string CLONE_PROPERTY_ATTRIBUTE_TEXT = @"// -using System; + """; -namespace " + CLONEABLE_NAMESPACE + @" -{ - [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] - public sealed class " + CLONE_ATTRIBUTE_STRING + @" : Attribute - { - public " + CLONE_ATTRIBUTE_STRING + @"() - { - } + private const string IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT = $$""" + // + using System; + + namespace {{CLONEABLE_NAMESPACE}} + { + [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] + internal sealed class {{IGNORE_CLONE_ATTRIBUTE_STRING}} : Attribute + { + public {{IGNORE_CLONE_ATTRIBUTE_STRING}}() + { + } + } + } - public bool " + PREVENT_DEEP_COPY_KEY_STRING + @" { get; set; } - } -} -"; - - private const string IGNORE_CLONE_PROPERTY_ATTRIBUTE_TEXT = @"// -using System; - -namespace " + CLONEABLE_NAMESPACE + @" -{ - [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] - public sealed class " + IGNORE_CLONE_ATTRIBUTE_STRING + @" : Attribute - { - public " + IGNORE_CLONE_ATTRIBUTE_STRING + @"() - { - } - } -} -"; + """; private INamedTypeSymbol? _cloneableAttribute; private INamedTypeSymbol? _ignoreCloneAttribute; @@ -180,7 +184,7 @@ namespace {namespaceName} }}"; } - private IEnumerable<(string line, bool isCloneable)> GenerateFieldAssignmentsCode(INamedTypeSymbol classSymbol, bool isExplicit) + private IEnumerable<(string line, bool isCloneable)> GenerateFieldAssignmentsCode(INamedTypeSymbol classSymbol, bool isExplicit ) { var fieldNames = GetCloneableProperties(classSymbol, isExplicit); diff --git a/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs b/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs index 6c0c605..8d6de76 100644 --- a/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs +++ b/src/EllieBot.Generators/Cloneable/SymbolExtensions.cs @@ -1,8 +1,7 @@ // Code temporarily yeeted from // https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs // because of NRT issue -using System.Collections.Generic; -using System.Linq; + using Microsoft.CodeAnalysis; namespace Cloneable diff --git a/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs b/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs index 962c395..ae0d029 100644 --- a/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs +++ b/src/EllieBot.Generators/Cloneable/SyntaxReceiver.cs @@ -1,7 +1,7 @@ // Code temporarily yeeted from // https://github.com/mostmand/Cloneable/blob/master/Cloneable/CloneableGenerator.cs // because of NRT issue -using System.Collections.Generic; + using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; diff --git a/src/EllieBot.Generators/Command/CommandAttributesGenerator.cs b/src/EllieBot.Generators/Command/CommandAttributesGenerator.cs deleted file mode 100644 index 81a758c..0000000 --- a/src/EllieBot.Generators/Command/CommandAttributesGenerator.cs +++ /dev/null @@ -1,336 +0,0 @@ -// #nullable enable -// using System; -// using System.CodeDom.Compiler; -// using System.Collections.Generic; -// using System.Collections.Immutable; -// using System.Collections.ObjectModel; -// using System.Diagnostics; -// using System.IO; -// using System.Linq; -// using System.Text; -// using System.Threading; -// using Microsoft.CodeAnalysis; -// using Microsoft.CodeAnalysis.CSharp; -// using Microsoft.CodeAnalysis.CSharp.Syntax; -// using Microsoft.CodeAnalysis.Text; -// -// namespace EllieBot.Generators.Command; -// -// [Generator] -// public class CommandAttributesGenerator : IIncrementalGenerator -// { -// public const string ATTRIBUTE = @"// -// -// namespace EllieBot.Common; -// -// [System.AttributeUsage(System.AttributeTargets.Method)] -// public class CmdAttribute : System.Attribute -// { -// -// }"; -// -// public class MethodModel -// { -// public string? Namespace { get; } -// public IReadOnlyCollection Classes { get; } -// public string ReturnType { get; } -// public string MethodName { get; } -// public IEnumerable Params { get; } -// -// public MethodModel(string? ns, IReadOnlyCollection classes, string returnType, string methodName, IEnumerable @params) -// { -// Namespace = ns; -// Classes = classes; -// ReturnType = returnType; -// MethodName = methodName; -// Params = @params; -// } -// } -// -// public class FileModel -// { -// public string? Namespace { get; } -// public IReadOnlyCollection ClassHierarchy { get; } -// public IReadOnlyCollection Methods { get; } -// -// public FileModel(string? ns, IReadOnlyCollection classHierarchy, IReadOnlyCollection methods) -// { -// Namespace = ns; -// ClassHierarchy = classHierarchy; -// Methods = methods; -// } -// } -// -// public void Initialize(IncrementalGeneratorInitializationContext context) -// { -// // #if DEBUG -// // if (!Debugger.IsAttached) -// // Debugger.Launch(); -// // // SpinWait.SpinUntil(() => Debugger.IsAttached); -// // #endif -// context.RegisterPostInitializationOutput(static ctx => ctx.AddSource( -// "CmdAttribute.g.cs", -// SourceText.From(ATTRIBUTE, Encoding.UTF8))); -// -// var methods = context.SyntaxProvider -// .CreateSyntaxProvider( -// static (node, _) => node is MethodDeclarationSyntax { AttributeLists.Count: > 0 }, -// static (ctx, cancel) => Transform(ctx, cancel)) -// .Where(static m => m is not null) -// .Where(static m => m?.ChildTokens().Any(static x => x.IsKind(SyntaxKind.PublicKeyword)) ?? false); -// -// var compilationMethods = context.CompilationProvider.Combine(methods.Collect()); -// -// context.RegisterSourceOutput(compilationMethods, -// static (ctx, tuple) => RegisterAction(in ctx, tuple.Left, in tuple.Right)); -// } -// -// private static void RegisterAction(in SourceProductionContext ctx, -// Compilation comp, -// in ImmutableArray methods) -// { -// if (methods is { IsDefaultOrEmpty: true }) -// return; -// -// var models = GetModels(comp, methods, ctx.CancellationToken); -// -// foreach (var model in models) -// { -// var name = $"{model.Namespace}.{string.Join(".", model.ClassHierarchy)}.g.cs"; -// try -// { -// var source = GetSourceText(model); -// ctx.AddSource(name, SourceText.From(source, Encoding.UTF8)); -// } -// catch (Exception ex) -// { -// Console.WriteLine($"Error writing source file {name}\n" + ex); -// } -// } -// } -// -// private static string GetSourceText(FileModel model) -// { -// using var sw = new StringWriter(); -// using var tw = new IndentedTextWriter(sw); -// -// tw.WriteLine("// "); -// tw.WriteLine("#pragma warning disable CS1066"); -// -// if (model.Namespace is not null) -// { -// tw.WriteLine($"namespace {model.Namespace};"); -// tw.WriteLine(); -// } -// -// foreach (var className in model.ClassHierarchy) -// { -// tw.WriteLine($"public partial class {className}"); -// tw.WriteLine("{"); -// tw.Indent ++; -// } -// -// foreach (var method in model.Methods) -// { -// tw.WriteLine("[EllieCommand]"); -// tw.WriteLine("[EllieDescription]"); -// tw.WriteLine("[Aliases]"); -// tw.WriteLine($"public partial {method.ReturnType} {method.MethodName}({string.Join(", ", method.Params)});"); -// } -// -// foreach (var _ in model.ClassHierarchy) -// { -// tw.Indent --; -// tw.WriteLine("}"); -// } -// -// tw.Flush(); -// return sw.ToString(); -// } -// -// private static IReadOnlyCollection GetModels(Compilation compilation, -// in ImmutableArray inputMethods, -// CancellationToken cancel) -// { -// var models = new List(); -// -// var methods = inputMethods -// .Where(static x => x is not null) -// .Distinct(); -// -// var methodModels = methods -// .Select(x => MethodDeclarationToMethodModel(compilation, x!)) -// .Where(static x => x is not null) -// .Cast(); -// -// var groups = methodModels -// .GroupBy(static x => $"{x.Namespace}.{string.Join(".", x.Classes)}"); -// -// foreach (var group in groups) -// { -// if (cancel.IsCancellationRequested) -// return new Collection(); -// -// if (group is null) -// continue; -// -// var elems = group.ToList(); -// if (elems.Count is 0) -// continue; -// -// var model = new FileModel( -// methods: elems, -// ns: elems[0].Namespace, -// classHierarchy: elems![0].Classes -// ); -// -// models.Add(model); -// } -// -// -// return models; -// } -// -// private static MethodModel? MethodDeclarationToMethodModel(Compilation comp, MethodDeclarationSyntax decl) -// { -// // SpinWait.SpinUntil(static () => Debugger.IsAttached); -// -// SemanticModel semanticModel; -// try -// { -// semanticModel = comp.GetSemanticModel(decl.SyntaxTree); -// } -// catch -// { -// // for some reason this method can throw "Not part of this compilation" argument exception -// return null; -// } -// -// var methodModel = new MethodModel( -// @params: decl.ParameterList.Parameters -// .Where(p => p.Type is not null) -// .Select(p => -// { -// var prefix = p.Modifiers.Any(static x => x.IsKind(SyntaxKind.ParamsKeyword)) -// ? "params " -// : string.Empty; -// -// var type = semanticModel -// .GetTypeInfo(p.Type!) -// .Type -// ?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); -// -// -// var name = p.Identifier.Text; -// -// var suffix = string.Empty; -// if (p.Default is not null) -// { -// if (p.Default.Value is LiteralExpressionSyntax) -// { -// suffix = " = " + p.Default.Value; -// } -// else if (p.Default.Value is MemberAccessExpressionSyntax maes) -// { -// var maesSemModel = comp.GetSemanticModel(maes.SyntaxTree); -// var sym = maesSemModel.GetSymbolInfo(maes.Name); -// if (sym.Symbol is null) -// { -// suffix = " = " + p.Default.Value; -// } -// else -// { -// suffix = " = " + sym.Symbol.ToDisplayString(); -// } -// } -// } -// -// return $"{prefix}{type} {name}{suffix}"; -// }) -// .ToList(), -// methodName: decl.Identifier.Text, -// returnType: decl.ReturnType.ToString(), -// ns: GetNamespace(decl), -// classes: GetClasses(decl) -// ); -// -// return methodModel; -// } -// -// //https://github.com/andrewlock/NetEscapades.EnumGenerators/blob/main/src/NetEscapades.EnumGenerators/EnumGenerator.cs -// static string? GetNamespace(MethodDeclarationSyntax declarationSyntax) -// { -// // determine the namespace the class is declared in, if any -// string? nameSpace = null; -// var parentOfInterest = declarationSyntax.Parent; -// while (parentOfInterest is not null) -// { -// parentOfInterest = parentOfInterest.Parent; -// -// if (parentOfInterest is BaseNamespaceDeclarationSyntax ns) -// { -// nameSpace = ns.Name.ToString(); -// while (true) -// { -// if (ns.Parent is not NamespaceDeclarationSyntax parent) -// { -// break; -// } -// -// ns = parent; -// nameSpace = $"{ns.Name}.{nameSpace}"; -// } -// -// return nameSpace; -// } -// -// } -// -// return nameSpace; -// } -// -// static IReadOnlyCollection GetClasses(MethodDeclarationSyntax declarationSyntax) -// { -// // determine the namespace the class is declared in, if any -// var classes = new LinkedList(); -// var parentOfInterest = declarationSyntax.Parent; -// while (parentOfInterest is not null) -// { -// if (parentOfInterest is ClassDeclarationSyntax cds) -// { -// classes.AddFirst(cds.Identifier.ToString()); -// } -// -// parentOfInterest = parentOfInterest.Parent; -// } -// -// Debug.WriteLine($"Method {declarationSyntax.Identifier.Text} has {classes.Count} classes"); -// -// return classes; -// } -// -// private static MethodDeclarationSyntax? Transform(GeneratorSyntaxContext ctx, CancellationToken cancel) -// { -// var methodDecl = ctx.Node as MethodDeclarationSyntax; -// if (methodDecl is null) -// return default; -// -// foreach (var attListSyntax in methodDecl.AttributeLists) -// { -// foreach (var attSyntax in attListSyntax.Attributes) -// { -// if (cancel.IsCancellationRequested) -// return default; -// -// var symbol = ctx.SemanticModel.GetSymbolInfo(attSyntax).Symbol; -// if (symbol is not IMethodSymbol attSymbol) -// continue; -// -// if (attSymbol.ContainingType.ToDisplayString() == "EllieBot.Common.CmdAttribute") -// return methodDecl; -// } -// } -// -// return default; -// } -// } \ No newline at end of file diff --git a/src/EllieBot.Generators/EllieBot.Generators.csproj b/src/EllieBot.Generators/EllieBot.Generators.csproj index 742b2ea..1dbbc1d 100644 --- a/src/EllieBot.Generators/EllieBot.Generators.csproj +++ b/src/EllieBot.Generators/EllieBot.Generators.csproj @@ -5,6 +5,7 @@ latest false true + true diff --git a/src/EllieBot.Generators/LocalizedStringsGenerator.cs b/src/EllieBot.Generators/LocalizedStringsGenerator.cs index 95abda9..72d5040 100644 --- a/src/EllieBot.Generators/LocalizedStringsGenerator.cs +++ b/src/EllieBot.Generators/LocalizedStringsGenerator.cs @@ -1,10 +1,6 @@ #nullable enable -using System; using System.CodeDom.Compiler; -using System.Collections.Generic; using System.Diagnostics; -using System.IO; -using System.Linq; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Newtonsoft.Json; @@ -26,24 +22,23 @@ namespace EllieBot.Generators [Generator] public class LocalizedStringsGenerator : ISourceGenerator { - private const string LOC_STR_SOURCE = @"namespace EllieBot -{ - public readonly struct LocStr - { - public readonly string Key; - public readonly object[] Params; - - public LocStr(string key, params object[] data) - { - Key = key; - Params = data; - } - } -}"; + // private const string LOC_STR_SOURCE = @"namespace EllieBot + // { + // public readonly struct LocStr + // { + // public readonly string Key; + // public readonly object[] Params; + // + // public LocStr(string key, params object[] data) + // { + // Key = key; + // Params = data; + // } + // } + // }"; public void Initialize(GeneratorInitializationContext context) { - } public void Execute(GeneratorExecutionContext context) @@ -55,6 +50,7 @@ namespace EllieBot.Generators using (var stringWriter = new StringWriter()) using (var sw = new IndentedTextWriter(stringWriter)) { + sw.WriteLine("#pragma warning disable CS8981"); sw.WriteLine("namespace EllieBot;"); sw.WriteLine(); @@ -106,7 +102,7 @@ namespace EllieBot.Generators context.AddSource("strs.g.cs", stringWriter.ToString()); } - context.AddSource("LocStr.g.cs", LOC_STR_SOURCE); + // context.AddSource("LocStr.g.cs", LOC_STR_SOURCE); } private List GetFields(string? dataText) -- 2.43.0 From 545786e23ef6e97afebe9017a616c0fb3491bc3f Mon Sep 17 00:00:00 2001 From: Toastie Date: Mon, 13 May 2024 01:11:00 +1200 Subject: [PATCH 008/340] Updated TODO.md with finished projects --- TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index dc3a441..d7b33d4 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ - ~~Finish the Ellie.Marmalade project~~ Done - Finish the EllieBot.Tests project - Finish the EllieBot project - - Finish the EllieBot.Coordinator project - - Finish the EllieBot.Generators project + - ~~Finish the EllieBot.Coordinator project~~ Done + - ~~Finish the EllieBot.Generators project~~ Done - ~~Finish the EllieBot.Voice project~~ Done - - ~~Finish the EllieBot.VotesApi project~~ \ No newline at end of file + - ~~Finish the EllieBot.VotesApi project~~ Done \ No newline at end of file -- 2.43.0 From 9fff64f9512be7ffae38ecc29c4ab47c8e5db1e5 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 14 May 2024 23:08:36 +1200 Subject: [PATCH 009/340] Added Db stuff --- src/EllieBot/Db/EllieContext.cs | 535 ++++++++++++++++++ src/EllieBot/Db/EllieDbService.cs | 76 +++ src/EllieBot/Db/Extensions/ClubExtensions.cs | 34 ++ .../CurrencyTransactionExtensions.cs | 20 + src/EllieBot/Db/Extensions/DbExtensions.cs | 12 + .../Db/Extensions/DiscordUserExtensions.cs | 125 ++++ .../Extensions/EllieExpressionExtensions.cs | 15 + .../Db/Extensions/GuildConfigExtensions.cs | 227 ++++++++ src/EllieBot/Db/Extensions/QuoteExtensions.cs | 53 ++ .../Db/Extensions/ReminderExtensions.cs | 23 + .../SelfAssignableRolesExtensions.cs | 22 + .../Db/Extensions/UserXpExtensions.cs | 71 +++ .../Db/Extensions/WarningExtensions.cs | 60 ++ src/EllieBot/Db/Helpers/ActivityType.cs | 22 + src/EllieBot/Db/Helpers/GuildPerm.cs | 47 ++ src/EllieBot/Db/LevelStats.cs | 40 ++ src/EllieBot/Db/Models/AutoCommand.cs | 14 + src/EllieBot/Db/Models/AutoPublishChannel.cs | 7 + .../Db/Models/AutoTranslateChannel.cs | 10 + src/EllieBot/Db/Models/AutoTranslateUser.cs | 11 + src/EllieBot/Db/Models/BlacklistEntry.cs | 15 + src/EllieBot/Db/Models/CommandAlias.cs | 8 + src/EllieBot/Db/Models/CommandCooldown.cs | 8 + src/EllieBot/Db/Models/CurrencyTransaction.cs | 12 + src/EllieBot/Db/Models/DbEntity.cs | 12 + src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs | 14 + src/EllieBot/Db/Models/DiscordPemOverride.cs | 10 + src/EllieBot/Db/Models/DiscordUser.cs | 35 ++ src/EllieBot/Db/Models/Event.cs | 49 ++ src/EllieBot/Db/Models/FeedSub.cs | 19 + src/EllieBot/Db/Models/FollowedStream.cs | 33 ++ src/EllieBot/Db/Models/GCChannelId.cs | 14 + src/EllieBot/Db/Models/GamblingStats.cs | 9 + src/EllieBot/Db/Models/GroupName.cs | 11 + src/EllieBot/Db/Models/GuildColors.cs | 18 + src/EllieBot/Db/Models/GuildConfig.cs | 107 ++++ src/EllieBot/Db/Models/IgnoredLogItem.cs | 16 + .../Db/Models/IgnoredVoicePresenceChannel.cs | 8 + src/EllieBot/Db/Models/ImageOnlyChannel.cs | 15 + src/EllieBot/Db/Models/LogSetting.cs | 38 ++ src/EllieBot/Db/Models/Permission.cs | 55 ++ src/EllieBot/Db/Models/PlantedCurrency.cs | 12 + src/EllieBot/Db/Models/PlaylistSong.cs | 18 + src/EllieBot/Db/Models/Reminder.cs | 19 + src/EllieBot/Db/Models/Repeater.cs | 15 + .../Db/Models/RotatingPlayingStatus.cs | 8 + src/EllieBot/Db/Models/ShopEntry.cs | 46 ++ src/EllieBot/Db/Models/StreamOnlineMessage.cs | 11 + src/EllieBot/Db/Models/StreamRoleSettings.cs | 68 +++ src/EllieBot/Db/Models/VcRoleInfo.cs | 8 + src/EllieBot/Db/Models/Waifu.cs | 75 +++ src/EllieBot/Db/Models/anti/AntiAltSetting.cs | 11 + .../Db/Models/anti/AntiRaidSetting.cs | 19 + src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs | 12 + .../Db/Models/anti/AntiSpamSetting.cs | 14 + src/EllieBot/Db/Models/club/ClubInfo.cs | 41 ++ src/EllieBot/Db/Models/currency/BankUser.cs | 7 + .../Db/Models/expr/EllieExpression.cs | 27 + src/EllieBot/Db/Models/expr/Quote.cs | 26 + .../Db/Models/filter/FilterChannelId.cs | 30 + .../Db/Models/filter/FilterLinksChannelId.cs | 13 + src/EllieBot/Db/Models/filter/FilteredWord.cs | 7 + .../Db/Models/giveaway/GiveawayModel.cs | 14 + .../Db/Models/giveaway/GiveawayUser.cs | 10 + src/EllieBot/Db/Models/punish/BanTemplate.cs | 9 + src/EllieBot/Db/Models/punish/MutedUserId.cs | 13 + .../Db/Models/punish/PunishmentAction.cs | 15 + .../Db/Models/punish/WarnExpireAction.cs | 8 + src/EllieBot/Db/Models/punish/Warning.cs | 13 + .../Db/Models/punish/WarningPunishment.cs | 10 + src/EllieBot/Db/Models/roles/ReactionRole.cs | 18 + .../Db/Models/roles/SelfAssignableRole.cs | 11 + src/EllieBot/Db/Models/roles/StickyRoles.cs | 14 + .../Db/Models/slowmode/SlowmodeIgnoredRole.cs | 20 + .../Db/Models/slowmode/SlowmodeIgnoredUser.cs | 20 + src/EllieBot/Db/Models/support/PatronQuota.cs | 48 ++ .../Db/Models/support/RewardedUser.cs | 10 + .../Db/Models/todo/ArchivedTodoListModel.cs | 10 + src/EllieBot/Db/Models/todo/TodoModel.cs | 13 + src/EllieBot/Db/Models/untimer/UnbanTimer.cs | 14 + src/EllieBot/Db/Models/untimer/UnmuteTimer.cs | 14 + src/EllieBot/Db/Models/untimer/UnroleTimer.cs | 15 + src/EllieBot/Db/Models/xp/UserXpStats.cs | 13 + src/EllieBot/Db/Models/xp/XpSettings.cs | 62 ++ src/EllieBot/Db/MysqlContext.cs | 38 ++ src/EllieBot/Db/PostgreSqlContext.cs | 26 + src/EllieBot/Db/SqliteContext.cs | 26 + 87 files changed, 2881 insertions(+) create mode 100644 src/EllieBot/Db/EllieContext.cs create mode 100644 src/EllieBot/Db/EllieDbService.cs create mode 100644 src/EllieBot/Db/Extensions/ClubExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/DbExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/DiscordUserExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/GuildConfigExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/QuoteExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/ReminderExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/UserXpExtensions.cs create mode 100644 src/EllieBot/Db/Extensions/WarningExtensions.cs create mode 100644 src/EllieBot/Db/Helpers/ActivityType.cs create mode 100644 src/EllieBot/Db/Helpers/GuildPerm.cs create mode 100644 src/EllieBot/Db/LevelStats.cs create mode 100644 src/EllieBot/Db/Models/AutoCommand.cs create mode 100644 src/EllieBot/Db/Models/AutoPublishChannel.cs create mode 100644 src/EllieBot/Db/Models/AutoTranslateChannel.cs create mode 100644 src/EllieBot/Db/Models/AutoTranslateUser.cs create mode 100644 src/EllieBot/Db/Models/BlacklistEntry.cs create mode 100644 src/EllieBot/Db/Models/CommandAlias.cs create mode 100644 src/EllieBot/Db/Models/CommandCooldown.cs create mode 100644 src/EllieBot/Db/Models/CurrencyTransaction.cs create mode 100644 src/EllieBot/Db/Models/DbEntity.cs create mode 100644 src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs create mode 100644 src/EllieBot/Db/Models/DiscordPemOverride.cs create mode 100644 src/EllieBot/Db/Models/DiscordUser.cs create mode 100644 src/EllieBot/Db/Models/Event.cs create mode 100644 src/EllieBot/Db/Models/FeedSub.cs create mode 100644 src/EllieBot/Db/Models/FollowedStream.cs create mode 100644 src/EllieBot/Db/Models/GCChannelId.cs create mode 100644 src/EllieBot/Db/Models/GamblingStats.cs create mode 100644 src/EllieBot/Db/Models/GroupName.cs create mode 100644 src/EllieBot/Db/Models/GuildColors.cs create mode 100644 src/EllieBot/Db/Models/GuildConfig.cs create mode 100644 src/EllieBot/Db/Models/IgnoredLogItem.cs create mode 100644 src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs create mode 100644 src/EllieBot/Db/Models/ImageOnlyChannel.cs create mode 100644 src/EllieBot/Db/Models/LogSetting.cs create mode 100644 src/EllieBot/Db/Models/Permission.cs create mode 100644 src/EllieBot/Db/Models/PlantedCurrency.cs create mode 100644 src/EllieBot/Db/Models/PlaylistSong.cs create mode 100644 src/EllieBot/Db/Models/Reminder.cs create mode 100644 src/EllieBot/Db/Models/Repeater.cs create mode 100644 src/EllieBot/Db/Models/RotatingPlayingStatus.cs create mode 100644 src/EllieBot/Db/Models/ShopEntry.cs create mode 100644 src/EllieBot/Db/Models/StreamOnlineMessage.cs create mode 100644 src/EllieBot/Db/Models/StreamRoleSettings.cs create mode 100644 src/EllieBot/Db/Models/VcRoleInfo.cs create mode 100644 src/EllieBot/Db/Models/Waifu.cs create mode 100644 src/EllieBot/Db/Models/anti/AntiAltSetting.cs create mode 100644 src/EllieBot/Db/Models/anti/AntiRaidSetting.cs create mode 100644 src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs create mode 100644 src/EllieBot/Db/Models/anti/AntiSpamSetting.cs create mode 100644 src/EllieBot/Db/Models/club/ClubInfo.cs create mode 100644 src/EllieBot/Db/Models/currency/BankUser.cs create mode 100644 src/EllieBot/Db/Models/expr/EllieExpression.cs create mode 100644 src/EllieBot/Db/Models/expr/Quote.cs create mode 100644 src/EllieBot/Db/Models/filter/FilterChannelId.cs create mode 100644 src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs create mode 100644 src/EllieBot/Db/Models/filter/FilteredWord.cs create mode 100644 src/EllieBot/Db/Models/giveaway/GiveawayModel.cs create mode 100644 src/EllieBot/Db/Models/giveaway/GiveawayUser.cs create mode 100644 src/EllieBot/Db/Models/punish/BanTemplate.cs create mode 100644 src/EllieBot/Db/Models/punish/MutedUserId.cs create mode 100644 src/EllieBot/Db/Models/punish/PunishmentAction.cs create mode 100644 src/EllieBot/Db/Models/punish/WarnExpireAction.cs create mode 100644 src/EllieBot/Db/Models/punish/Warning.cs create mode 100644 src/EllieBot/Db/Models/punish/WarningPunishment.cs create mode 100644 src/EllieBot/Db/Models/roles/ReactionRole.cs create mode 100644 src/EllieBot/Db/Models/roles/SelfAssignableRole.cs create mode 100644 src/EllieBot/Db/Models/roles/StickyRoles.cs create mode 100644 src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs create mode 100644 src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs create mode 100644 src/EllieBot/Db/Models/support/PatronQuota.cs create mode 100644 src/EllieBot/Db/Models/support/RewardedUser.cs create mode 100644 src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs create mode 100644 src/EllieBot/Db/Models/todo/TodoModel.cs create mode 100644 src/EllieBot/Db/Models/untimer/UnbanTimer.cs create mode 100644 src/EllieBot/Db/Models/untimer/UnmuteTimer.cs create mode 100644 src/EllieBot/Db/Models/untimer/UnroleTimer.cs create mode 100644 src/EllieBot/Db/Models/xp/UserXpStats.cs create mode 100644 src/EllieBot/Db/Models/xp/XpSettings.cs create mode 100644 src/EllieBot/Db/MysqlContext.cs create mode 100644 src/EllieBot/Db/PostgreSqlContext.cs create mode 100644 src/EllieBot/Db/SqliteContext.cs diff --git a/src/EllieBot/Db/EllieContext.cs b/src/EllieBot/Db/EllieContext.cs new file mode 100644 index 0000000..d61d2f7 --- /dev/null +++ b/src/EllieBot/Db/EllieContext.cs @@ -0,0 +1,535 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using EllieBot.Db.Models; + +// ReSharper disable UnusedAutoPropertyAccessor.Global + +namespace EllieBot.Db; + +public abstract class EllieContext : DbContext +{ + public DbSet GuildConfigs { get; set; } + + public DbSet Quotes { get; set; } + public DbSet Reminders { get; set; } + public DbSet SelfAssignableRoles { get; set; } + public DbSet MusicPlaylists { get; set; } + public DbSet Expressions { get; set; } + public DbSet CurrencyTransactions { get; set; } + public DbSet WaifuUpdates { get; set; } + public DbSet WaifuItem { get; set; } + public DbSet Warnings { get; set; } + public DbSet UserXpStats { get; set; } + public DbSet Clubs { get; set; } + public DbSet ClubBans { get; set; } + public DbSet ClubApplicants { get; set; } + + + //logging + public DbSet LogSettings { get; set; } + public DbSet IgnoredVoicePresenceCHannels { get; set; } + public DbSet IgnoredLogChannels { get; set; } + + public DbSet RotatingStatus { get; set; } + public DbSet Blacklist { get; set; } + public DbSet AutoCommands { get; set; } + public DbSet RewardedUsers { get; set; } + public DbSet PlantedCurrency { get; set; } + public DbSet BanTemplates { get; set; } + public DbSet DiscordPermOverrides { get; set; } + public DbSet DiscordUser { get; set; } + public DbSet MusicPlayerSettings { get; set; } + public DbSet Repeaters { get; set; } + public DbSet WaifuInfo { get; set; } + public DbSet ImageOnlyChannels { get; set; } + public DbSet AutoTranslateChannels { get; set; } + public DbSet AutoTranslateUsers { get; set; } + + public DbSet Permissions { get; set; } + + public DbSet BankUsers { get; set; } + + public DbSet ReactionRoles { get; set; } + + public DbSet Patrons { get; set; } + + public DbSet PatronQuotas { get; set; } + + public DbSet StreamOnlineMessages { get; set; } + + public DbSet StickyRoles { get; set; } + + public DbSet Todos { get; set; } + public DbSet TodosArchive { get; set; } + + // todo add guild colors + // public DbSet GuildColors { get; set; } + + + #region Mandatory Provider-Specific Values + + protected abstract string CurrencyTransactionOtherIdDefaultValue { get; } + + #endregion + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + #region QUOTES + + var quoteEntity = modelBuilder.Entity(); + quoteEntity.HasIndex(x => x.GuildId); + quoteEntity.HasIndex(x => x.Keyword); + + #endregion + + #region GuildConfig + + var configEntity = modelBuilder.Entity(); + configEntity.HasIndex(c => c.GuildId) + .IsUnique(); + + configEntity.Property(x => x.VerboseErrors) + .HasDefaultValue(true); + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiSpamSetting); + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiRaidSetting); + + modelBuilder.Entity() + .HasOne(x => x.AntiAltSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasAlternateKey(x => new + { + x.GuildConfigId, + x.Url + }); + + modelBuilder.Entity().HasIndex(x => x.MessageId).IsUnique(); + + modelBuilder.Entity().HasIndex(x => x.ChannelId); + + configEntity.HasIndex(x => x.WarnExpireHours).IsUnique(false); + + #endregion + + #region streamrole + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.StreamRole); + + #endregion + + #region Self Assignable Roles + + var selfassignableRolesEntity = modelBuilder.Entity(); + + selfassignableRolesEntity.HasIndex(s => new + { + s.GuildId, + s.RoleId + }) + .IsUnique(); + + selfassignableRolesEntity.Property(x => x.Group).HasDefaultValue(0); + + #endregion + + #region MusicPlaylists + + var musicPlaylistEntity = modelBuilder.Entity(); + + musicPlaylistEntity.HasMany(p => p.Songs).WithOne().OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region Waifus + + var wi = modelBuilder.Entity(); + wi.HasOne(x => x.Waifu).WithOne(); + + wi.HasIndex(x => x.Price); + wi.HasIndex(x => x.ClaimerId); + // wi.HasMany(x => x.Items) + // .WithOne() + // .OnDelete(DeleteBehavior.Cascade); + + #endregion + + #region DiscordUser + + modelBuilder.Entity(du => + { + du.Property(x => x.IsClubAdmin) + .HasDefaultValue(false); + + du.Property(x => x.NotifyOnLevelUp) + .HasDefaultValue(XpNotificationLocation.None); + + du.Property(x => x.TotalXp) + .HasDefaultValue(0); + + du.Property(x => x.CurrencyAmount) + .HasDefaultValue(0); + + du.HasAlternateKey(w => w.UserId); + du.HasOne(x => x.Club) + .WithMany(x => x.Members) + .IsRequired(false) + .OnDelete(DeleteBehavior.NoAction); + + du.HasIndex(x => x.TotalXp); + du.HasIndex(x => x.CurrencyAmount); + du.HasIndex(x => x.UserId); + }); + + #endregion + + #region Warnings + + modelBuilder.Entity(warn => + { + warn.HasIndex(x => x.GuildId); + warn.HasIndex(x => x.UserId); + warn.HasIndex(x => x.DateAdded); + warn.Property(x => x.Weight).HasDefaultValue(1); + }); + + #endregion + + #region XpStats + + var xps = modelBuilder.Entity(); + xps.HasIndex(x => new + { + x.UserId, + x.GuildId + }) + .IsUnique(); + + xps.HasIndex(x => x.UserId); + xps.HasIndex(x => x.GuildId); + xps.HasIndex(x => x.Xp); + xps.HasIndex(x => x.AwardedXp); + + #endregion + + #region XpSettings + + modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.XpSettings); + + #endregion + + #region XpRoleReward + + modelBuilder.Entity() + .HasIndex(x => new + { + x.XpSettingsId, + x.Level + }) + .IsUnique(); + + #endregion + + #region Club + + var ci = modelBuilder.Entity(); + ci.HasOne(x => x.Owner) + .WithOne() + .HasForeignKey(x => x.OwnerId) + .OnDelete(DeleteBehavior.SetNull); + + ci.HasIndex(x => new + { + x.Name + }) + .IsUnique(); + + #endregion + + #region ClubManytoMany + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Applicants); + + modelBuilder.Entity() + .HasKey(t => new + { + t.ClubId, + t.UserId + }); + + modelBuilder.Entity() + .HasOne(pt => pt.User) + .WithMany(); + + modelBuilder.Entity() + .HasOne(pt => pt.Club) + .WithMany(x => x.Bans); + + #endregion + + #region CurrencyTransactions + + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId) + .IsUnique(false); + + e.Property(x => x.OtherId) + .HasDefaultValueSql(CurrencyTransactionOtherIdDefaultValue); + + e.Property(x => x.Type) + .IsRequired(); + + e.Property(x => x.Extra) + .IsRequired(); + }); + + #endregion + + #region Reminders + + modelBuilder.Entity().HasIndex(x => x.When); + + #endregion + + #region GroupName + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildConfigId, + x.Number + }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.GuildConfig) + .WithMany(x => x.SelfAssignableRoleGroupNames) + .IsRequired(); + + #endregion + + #region BanTemplate + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + modelBuilder.Entity() + .Property(x => x.PruneDays) + .HasDefaultValue(null) + .IsRequired(false); + + #endregion + + #region Perm Override + + modelBuilder.Entity() + .HasIndex(x => new + { + x.GuildId, + x.Command + }) + .IsUnique(); + + #endregion + + #region Music + + modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); + + modelBuilder.Entity().Property(x => x.Volume).HasDefaultValue(100); + + #endregion + + #region Reaction roles + + modelBuilder.Entity(rr2 => + { + rr2.HasIndex(x => x.GuildId) + .IsUnique(false); + + rr2.HasIndex(x => new + { + x.MessageId, + x.Emote + }) + .IsUnique(); + }); + + #endregion + + #region LogSettings + + modelBuilder.Entity(ls => ls.HasIndex(x => x.GuildId).IsUnique()); + + modelBuilder.Entity(ls => ls + .HasMany(x => x.LogIgnores) + .WithOne(x => x.LogSetting) + .OnDelete(DeleteBehavior.Cascade)); + + modelBuilder.Entity(ili => ili + .HasIndex(x => new + { + x.LogSettingId, + x.LogItemId, + x.ItemType + }) + .IsUnique()); + + #endregion + + modelBuilder.Entity(ioc => ioc.HasIndex(x => x.ChannelId).IsUnique()); + + var atch = modelBuilder.Entity(); + atch.HasIndex(x => x.GuildId).IsUnique(false); + + atch.HasIndex(x => x.ChannelId).IsUnique(); + + atch.HasMany(x => x.Users).WithOne(x => x.Channel).OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity(atu => atu.HasAlternateKey(x => new + { + x.ChannelId, + x.UserId + })); + + #region BANK + + modelBuilder.Entity(bu => bu.HasIndex(x => x.UserId).IsUnique()); + + #endregion + + + #region Patron + + // currency rewards + var pr = modelBuilder.Entity(); + pr.HasIndex(x => x.PlatformUserId).IsUnique(); + + // patrons + // patrons are not identified by their user id, but by their platform user id + // as multiple accounts (even maybe on different platforms) could have + // the same account connected to them + modelBuilder.Entity(pu => + { + pu.HasIndex(x => x.UniquePlatformUserId).IsUnique(); + pu.HasKey(x => x.UserId); + }); + + // quotes are per user id + modelBuilder.Entity(pq => + { + pq.HasIndex(x => x.UserId).IsUnique(false); + pq.HasKey(x => new + { + x.UserId, + x.FeatureType, + x.Feature + }); + }); + + #endregion + + #region Xp Item Shop + + modelBuilder.Entity( + x => + { + // user can own only one of each item + x.HasIndex(model => new + { + model.UserId, + model.ItemType, + model.ItemKey + }) + .IsUnique(); + }); + + #endregion + + #region AutoPublish + + modelBuilder.Entity(apc => apc + .HasIndex(x => x.GuildId) + .IsUnique()); + + #endregion + + #region GamblingStats + + modelBuilder.Entity(gs => gs + .HasIndex(x => x.Feature) + .IsUnique()); + + #endregion + + #region Sticky Roles + + modelBuilder.Entity(sr => sr.HasIndex(x => new + { + x.GuildId, + x.UserId + }).IsUnique()); + + #endregion + + + #region Giveaway + + modelBuilder.Entity() + .HasMany(x => x.Participants) + .WithOne() + .HasForeignKey(x => x.GiveawayId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity(gu => gu + .HasIndex(x => new + { + x.GiveawayId, + x.UserId + }) + .IsUnique()); + + #endregion + + #region Todo + + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasIndex(x => x.UserId) + .IsUnique(false); + + modelBuilder.Entity() + .HasMany(x => x.Items) + .WithOne() + .HasForeignKey(x => x.ArchiveId) + .OnDelete(DeleteBehavior.Cascade); + + #endregion + } + +#if DEBUG + private static readonly ILoggerFactory _debugLoggerFactory = LoggerFactory.Create(x => x.AddConsole()); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseLoggerFactory(_debugLoggerFactory); +#endif +} \ No newline at end of file diff --git a/src/EllieBot/Db/EllieDbService.cs b/src/EllieBot/Db/EllieDbService.cs new file mode 100644 index 0000000..2a3382e --- /dev/null +++ b/src/EllieBot/Db/EllieDbService.cs @@ -0,0 +1,76 @@ +using LinqToDB.Common; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class EllieDbService : DbService +{ + private readonly IBotCredsProvider _creds; + + // these are props because creds can change at runtime + private string DbType => _creds.GetCreds().Db.Type.ToLowerInvariant().Trim(); + private string ConnString => _creds.GetCreds().Db.ConnectionString; + + public EllieDbService(IBotCredsProvider creds) + { + LinqToDBForEFTools.Initialize(); + Configuration.Linq.DisableQueryCache = true; + + _creds = creds; + } + + public override async Task SetupAsync() + { + var dbType = DbType; + var connString = ConnString; + + await using var context = CreateRawDbContext(dbType, connString); + + // make sure sqlite db is in wal journal mode + if (context is SqliteContext) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL"); + } + + await context.Database.MigrateAsync(); + } + + public override EllieContext CreateRawDbContext(string dbType, string connString) + { + switch (dbType) + { + case "postgresql": + case "postgres": + case "pgsql": + return new PostgreSqlContext(connString); + case "mysql": + return new MysqlContext(connString); + case "sqlite": + return new SqliteContext(connString); + default: + throw new NotSupportedException($"The database provide type of '{dbType}' is not supported."); + } + } + + private EllieContext GetDbContextInternal() + { + var dbType = DbType; + var connString = ConnString; + + var context = CreateRawDbContext(dbType, connString); + if (context is SqliteContext) + { + var conn = context.Database.GetDbConnection(); + conn.Open(); + using var com = conn.CreateCommand(); + com.CommandText = "PRAGMA synchronous=OFF"; + com.ExecuteNonQuery(); + } + + return context; + } + + public override EllieContext GetDbContext() + => GetDbContextInternal(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/ClubExtensions.cs b/src/EllieBot/Db/Extensions/ClubExtensions.cs new file mode 100644 index 0000000..d8183fc --- /dev/null +++ b/src/EllieBot/Db/Extensions/ClubExtensions.cs @@ -0,0 +1,34 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class ClubExtensions +{ + private static IQueryable Include(this DbSet clubs) + => clubs.Include(x => x.Owner) + .Include(x => x.Applicants) + .ThenInclude(x => x.User) + .Include(x => x.Bans) + .ThenInclude(x => x.User) + .Include(x => x.Members) + .AsQueryable(); + + public static ClubInfo GetByOwner(this DbSet clubs, ulong userId) + => Include(clubs).FirstOrDefault(c => c.Owner.UserId == userId); + + public static ClubInfo GetByOwnerOrAdmin(this DbSet clubs, ulong userId) + => Include(clubs) + .FirstOrDefault(c => c.Owner.UserId == userId || c.Members.Any(u => u.UserId == userId && u.IsClubAdmin)); + + public static ClubInfo GetByMember(this DbSet clubs, ulong userId) + => Include(clubs).FirstOrDefault(c => c.Members.Any(u => u.UserId == userId)); + + public static ClubInfo GetByName(this DbSet clubs, string name) + => Include(clubs) + .FirstOrDefault(c => c.Name == name); + + public static List GetClubLeaderboardPage(this DbSet clubs, int page) + => clubs.AsNoTracking().OrderByDescending(x => x.Xp).Skip(page * 9).Take(9).ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs b/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs new file mode 100644 index 0000000..69401b0 --- /dev/null +++ b/src/EllieBot/Db/Extensions/CurrencyTransactionExtensions.cs @@ -0,0 +1,20 @@ +#nullable disable +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class CurrencyTransactionExtensions +{ + public static Task> GetPageFor( + this DbSet set, + ulong userId, + int page) + => set.ToLinqToDBTable() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.DateAdded) + .Skip(15 * page) + .Take(15) + .ToListAsyncLinqToDB(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/DbExtensions.cs b/src/EllieBot/Db/Extensions/DbExtensions.cs new file mode 100644 index 0000000..fafade9 --- /dev/null +++ b/src/EllieBot/Db/Extensions/DbExtensions.cs @@ -0,0 +1,12 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class DbExtensions +{ + public static T GetById(this DbSet set, int id) + where T : DbEntity + => set.FirstOrDefault(x => x.Id == id); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs new file mode 100644 index 0000000..6d74316 --- /dev/null +++ b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs @@ -0,0 +1,125 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class DiscordUserExtensions +{ + public static Task GetByUserIdAsync( + this IQueryable set, + ulong userId) + => set.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId); + + public static void EnsureUserCreated( + this DbContext ctx, + ulong userId, + string username, + string discrim, + string avatarId) + => ctx.GetTable() + .InsertOrUpdate( + () => new() + { + UserId = userId, + Username = username, + Discriminator = discrim, + AvatarId = avatarId, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + Username = username, + Discriminator = discrim, + AvatarId = avatarId + }, + () => new() + { + UserId = userId + }); + + public static Task EnsureUserCreatedAsync( + this DbContext ctx, + ulong userId) + => ctx.GetTable() + .InsertOrUpdateAsync( + () => new() + { + UserId = userId, + Username = "Unknown", + Discriminator = "????", + AvatarId = string.Empty, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + }, + () => new() + { + UserId = userId + }); + + //temp is only used in updatecurrencystate, so that i don't overwrite real usernames/discrims with Unknown + public static DiscordUser GetOrCreateUser( + this DbContext ctx, + ulong userId, + string username, + string discrim, + string avatarId, + Func, IQueryable> includes = null) + { + ctx.EnsureUserCreated(userId, username, discrim, avatarId); + + IQueryable queryable = ctx.Set(); + if (includes is not null) + queryable = includes(queryable); + return queryable.First(u => u.UserId == userId); + } + + + public static int GetUserGlobalRank(this DbSet users, ulong id) + => users.AsQueryable() + .Where(x => x.TotalXp + > users.AsQueryable().Where(y => y.UserId == id).Select(y => y.TotalXp).FirstOrDefault()) + .Count() + + 1; + + public static DiscordUser[] GetUsersXpLeaderboardFor(this DbSet users, int page, int perPage) + => users.AsQueryable().OrderByDescending(x => x.TotalXp).Skip(page * perPage).Take(perPage).AsEnumerable() + .ToArray(); + + public static Task> GetTopRichest( + this DbSet users, + ulong botId, + int page = 0, int perPage = 9) + => users.AsQueryable() + .Where(c => c.CurrencyAmount > 0 && botId != c.UserId) + .OrderByDescending(c => c.CurrencyAmount) + .Skip(page * perPage) + .Take(perPage) + .ToListAsyncLinqToDB(); + + public static async Task GetUserCurrencyAsync(this DbSet users, ulong userId) + => (await users.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId))?.CurrencyAmount ?? 0; + + public static void RemoveFromMany(this DbSet users, IEnumerable ids) + { + var items = users.AsQueryable().Where(x => ids.Contains(x.UserId)); + foreach (var item in items) + item.CurrencyAmount = 0; + } + + public static decimal GetTotalCurrency(this DbSet users) + => users.Sum((Func)(x => x.CurrencyAmount)); + + public static decimal GetTopOnePercentCurrency(this DbSet users, ulong botId) + => users.AsQueryable() + .Where(x => x.UserId != botId) + .OrderByDescending(x => x.CurrencyAmount) + .Take(users.Count() / 100 == 0 ? 1 : users.Count() / 100) + .Sum(x => x.CurrencyAmount); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs b/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs new file mode 100644 index 0000000..02a3453 --- /dev/null +++ b/src/EllieBot/Db/Extensions/EllieExpressionExtensions.cs @@ -0,0 +1,15 @@ +#nullable disable +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class EllieExpressionExtensions +{ + public static int ClearFromGuild(this DbSet exprs, ulong guildId) + => exprs.Delete(x => x.GuildId == guildId); + + public static IEnumerable ForId(this DbSet exprs, ulong id) + => exprs.AsNoTracking().AsQueryable().Where(x => x.GuildId == id).ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs new file mode 100644 index 0000000..bffa943 --- /dev/null +++ b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs @@ -0,0 +1,227 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class GuildConfigExtensions +{ + private static List DefaultWarnPunishments + => new() + { + new() + { + Count = 3, + Punishment = PunishmentAction.Kick + }, + new() + { + Count = 5, + Punishment = PunishmentAction.Ban + } + }; + + /// + /// Gets full stream role settings for the guild with the specified id. + /// + /// Db Context + /// Id of the guild to get stream role settings for. + /// Guild'p stream role settings + public static StreamRoleSettings GetStreamRoleSettings(this DbContext ctx, ulong guildId) + { + var conf = ctx.GuildConfigsForId(guildId, + set => set.Include(y => y.StreamRole) + .Include(y => y.StreamRole.Whitelist) + .Include(y => y.StreamRole.Blacklist)); + + if (conf.StreamRole is null) + conf.StreamRole = new(); + + return conf.StreamRole; + } + + private static IQueryable IncludeEverything(this DbSet configs) + => configs.AsQueryable() + .AsSplitQuery() + .Include(gc => gc.CommandCooldowns) + .Include(gc => gc.FollowedStreams) + .Include(gc => gc.StreamRole) + .Include(gc => gc.XpSettings) + .ThenInclude(x => x.ExclusionList) + .Include(gc => gc.DelMsgOnCmdChannels); + + public static IEnumerable GetAllGuildConfigs( + this DbSet configs, + IReadOnlyList availableGuilds) + => configs.IncludeEverything().AsNoTracking().Where(x => availableGuilds.Contains(x.GuildId)).ToList(); + + /// + /// Gets and creates if it doesn't exist a config for a guild. + /// + /// Context + /// Id of the guide + /// Use to manipulate the set however you want. Pass null to include everything + /// Config for the guild + public static GuildConfig GuildConfigsForId( + this DbContext ctx, + ulong guildId, + Func, IQueryable> includes) + { + GuildConfig config; + + if (includes is null) + config = ctx.Set().IncludeEverything().FirstOrDefault(c => c.GuildId == guildId); + else + { + var set = includes(ctx.Set()); + config = set.FirstOrDefault(c => c.GuildId == guildId); + } + + if (config is null) + { + ctx.Set().Add(config = new() + { + GuildId = guildId, + Permissions = Permissionv2.GetDefaultPermlist, + WarningsInitialized = true, + WarnPunishments = DefaultWarnPunishments + }); + ctx.SaveChanges(); + } + + if (!config.WarningsInitialized) + { + config.WarningsInitialized = true; + config.WarnPunishments = DefaultWarnPunishments; + } + + return config; + + // ctx.GuildConfigs + // .ToLinqToDBTable() + // .InsertOrUpdate(() => new() + // { + // GuildId = guildId, + // Permissions = Permissionv2.GetDefaultPermlist, + // WarningsInitialized = true, + // WarnPunishments = DefaultWarnPunishments + // }, + // _ => new(), + // () => new() + // { + // GuildId = guildId + // }); + // + // if(includes is null) + // return ctx.GuildConfigs + // .ToLinqToDBTable() + // .First(x => x.GuildId == guildId); + } + + public static LogSetting LogSettingsFor(this DbContext ctx, ulong guildId) + { + var logSetting = ctx.Set() + .AsQueryable() + .Include(x => x.LogIgnores) + .Where(x => x.GuildId == guildId) + .FirstOrDefault(); + + if (logSetting is null) + { + ctx.Set() + .Add(logSetting = new() + { + GuildId = guildId + }); + ctx.SaveChanges(); + } + + return logSetting; + } + + public static IEnumerable PermissionsForAll(this DbSet configs, List include) + { + var query = configs.AsQueryable().Where(x => include.Contains(x.GuildId)).Include(gc => gc.Permissions); + + return query.ToList(); + } + + public static GuildConfig GcWithPermissionsFor(this DbContext ctx, ulong guildId) + { + var config = ctx.Set().AsQueryable() + .Where(gc => gc.GuildId == guildId) + .Include(gc => gc.Permissions) + .FirstOrDefault(); + + if (config is null) // if there is no guildconfig, create new one + { + ctx.Set().Add(config = new() + { + GuildId = guildId, + Permissions = Permissionv2.GetDefaultPermlist + }); + ctx.SaveChanges(); + } + else if (config.Permissions is null || !config.Permissions.Any()) // if no perms, add default ones + { + config.Permissions = Permissionv2.GetDefaultPermlist; + ctx.SaveChanges(); + } + + return config; + } + + public static IEnumerable GetFollowedStreams(this DbSet configs) + => configs.AsQueryable().Include(x => x.FollowedStreams).SelectMany(gc => gc.FollowedStreams).ToArray(); + + public static IEnumerable GetFollowedStreams(this DbSet configs, List included) + => configs.AsQueryable() + .Where(gc => included.Contains(gc.GuildId)) + .Include(gc => gc.FollowedStreams) + .SelectMany(gc => gc.FollowedStreams) + .ToList(); + + public static void SetCleverbotEnabled(this DbSet configs, ulong id, bool cleverbotEnabled) + { + var conf = configs.FirstOrDefault(gc => gc.GuildId == id); + + if (conf is null) + return; + + conf.CleverbotEnabled = cleverbotEnabled; + } + + public static XpSettings XpSettingsFor(this DbContext ctx, ulong guildId) + { + var gc = ctx.GuildConfigsForId(guildId, + set => set.Include(x => x.XpSettings) + .ThenInclude(x => x.RoleRewards) + .Include(x => x.XpSettings) + .ThenInclude(x => x.CurrencyRewards) + .Include(x => x.XpSettings) + .ThenInclude(x => x.ExclusionList)); + + if (gc.XpSettings is null) + gc.XpSettings = new(); + + return gc.XpSettings; + } + + public static IEnumerable GetGeneratingChannels(this DbSet configs) + => configs.AsQueryable() + .Include(x => x.GenerateCurrencyChannelIds) + .Where(x => x.GenerateCurrencyChannelIds.Any()) + .SelectMany(x => x.GenerateCurrencyChannelIds) + .Select(x => new GeneratingChannel + { + ChannelId = x.ChannelId, + GuildId = x.GuildConfig.GuildId + }) + .ToArray(); + + public class GeneratingChannel + { + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/QuoteExtensions.cs b/src/EllieBot/Db/Extensions/QuoteExtensions.cs new file mode 100644 index 0000000..67698e9 --- /dev/null +++ b/src/EllieBot/Db/Extensions/QuoteExtensions.cs @@ -0,0 +1,53 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class QuoteExtensions +{ + public static IEnumerable GetForGuild(this DbSet quotes, ulong guildId) + => quotes.AsQueryable().Where(x => x.GuildId == guildId); + + public static IReadOnlyCollection GetGroup( + this DbSet quotes, + ulong guildId, + int page, + OrderType order) + { + var q = quotes.AsQueryable().Where(x => x.GuildId == guildId); + if (order == OrderType.Keyword) + q = q.OrderBy(x => x.Keyword); + else + q = q.OrderBy(x => x.Id); + + return q.Skip(15 * page).Take(15).ToArray(); + } + + public static async Task GetRandomQuoteByKeywordAsync( + this DbSet quotes, + ulong guildId, + string keyword) + { + return (await quotes.AsQueryable().Where(q => q.GuildId == guildId && q.Keyword == keyword).ToArrayAsync()) + .RandomOrDefault(); + } + + public static async Task SearchQuoteKeywordTextAsync( + this DbSet quotes, + ulong guildId, + string keyword, + string text) + { + return (await quotes.AsQueryable() + .Where(q => q.GuildId == guildId + && (keyword == null || q.Keyword == keyword) + && (EF.Functions.Like(q.Text.ToUpper(), $"%{text.ToUpper()}%") + || EF.Functions.Like(q.AuthorName, text))) + .ToArrayAsync()) + .RandomOrDefault(); + } + + public static void RemoveAllByKeyword(this DbSet quotes, ulong guildId, string keyword) + => quotes.RemoveRange(quotes.AsQueryable().Where(x => x.GuildId == guildId && x.Keyword.ToUpper() == keyword)); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/ReminderExtensions.cs b/src/EllieBot/Db/Extensions/ReminderExtensions.cs new file mode 100644 index 0000000..8a7d992 --- /dev/null +++ b/src/EllieBot/Db/Extensions/ReminderExtensions.cs @@ -0,0 +1,23 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class ReminderExtensions +{ + public static IEnumerable GetIncludedReminders( + this DbSet reminders, + IEnumerable guildIds) + => reminders.AsQueryable().Where(x => guildIds.Contains(x.ServerId) || x.ServerId == 0).ToList(); + + public static IEnumerable RemindersFor(this DbSet reminders, ulong userId, int page) + => reminders.AsQueryable().Where(x => x.UserId == userId).OrderBy(x => x.DateAdded).Skip(page * 10).Take(10); + + public static IEnumerable RemindersForServer(this DbSet reminders, ulong serverId, int page) + => reminders.AsQueryable() + .Where(x => x.ServerId == serverId) + .OrderBy(x => x.DateAdded) + .Skip(page * 10) + .Take(10); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs b/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs new file mode 100644 index 0000000..740a155 --- /dev/null +++ b/src/EllieBot/Db/Extensions/SelfAssignableRolesExtensions.cs @@ -0,0 +1,22 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class SelfAssignableRolesExtensions +{ + public static bool DeleteByGuildAndRoleId(this DbSet roles, ulong guildId, ulong roleId) + { + var role = roles.FirstOrDefault(s => s.GuildId == guildId && s.RoleId == roleId); + + if (role is null) + return false; + + roles.Remove(role); + return true; + } + + public static IReadOnlyCollection GetFromGuild(this DbSet roles, ulong guildId) + => roles.AsQueryable().Where(s => s.GuildId == guildId).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/UserXpExtensions.cs b/src/EllieBot/Db/Extensions/UserXpExtensions.cs new file mode 100644 index 0000000..c7e0f8c --- /dev/null +++ b/src/EllieBot/Db/Extensions/UserXpExtensions.cs @@ -0,0 +1,71 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class UserXpExtensions +{ + public static UserXpStats GetOrCreateUserXpStats(this DbContext ctx, ulong guildId, ulong userId) + { + var usr = ctx.Set().FirstOrDefault(x => x.UserId == userId && x.GuildId == guildId); + + if (usr is null) + { + ctx.Add(usr = new() + { + Xp = 0, + UserId = userId, + NotifyOnLevelUp = XpNotificationLocation.None, + GuildId = guildId + }); + } + + return usr; + } + + public static List GetUsersFor(this DbSet xps, ulong guildId, int page) + => xps.AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Skip(page * 9) + .Take(9) + .ToList(); + + public static List GetTopUserXps(this DbSet xps, ulong guildId, int count) + => xps.AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Take(count) + .ToList(); + + public static int GetUserGuildRanking(this DbSet xps, ulong userId, ulong guildId) + => xps.AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId + && x.Xp + x.AwardedXp + > xps.AsQueryable() + .Where(y => y.UserId == userId && y.GuildId == guildId) + .Select(y => y.Xp + y.AwardedXp) + .FirstOrDefault()) + .Count() + + 1; + + public static void ResetGuildUserXp(this DbSet xps, ulong userId, ulong guildId) + => xps.Delete(x => x.UserId == userId && x.GuildId == guildId); + + public static void ResetGuildXp(this DbSet xps, ulong guildId) + => xps.Delete(x => x.GuildId == guildId); + + public static async Task GetLevelDataFor(this ITable userXp, ulong guildId, ulong userId) + => await userXp + .Where(x => x.GuildId == guildId && x.UserId == userId) + .FirstOrDefaultAsyncLinqToDB() is UserXpStats uxs + ? new(uxs.Xp + uxs.AwardedXp) + : new(0); + +} \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/WarningExtensions.cs b/src/EllieBot/Db/Extensions/WarningExtensions.cs new file mode 100644 index 0000000..c223f7d --- /dev/null +++ b/src/EllieBot/Db/Extensions/WarningExtensions.cs @@ -0,0 +1,60 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class WarningExtensions +{ + public static Warning[] ForId(this DbSet warnings, ulong guildId, ulong userId) + { + var query = warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .OrderByDescending(x => x.DateAdded); + + return query.ToArray(); + } + + public static bool Forgive( + this DbSet warnings, + ulong guildId, + ulong userId, + string mod, + int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + + var warn = warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .OrderByDescending(x => x.DateAdded) + .Skip(index) + .FirstOrDefault(); + + if (warn is null || warn.Forgiven) + return false; + + warn.Forgiven = true; + warn.ForgivenBy = mod; + return true; + } + + public static async Task ForgiveAll( + this DbSet warnings, + ulong guildId, + ulong userId, + string mod) + => await warnings.AsQueryable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .ForEachAsync(x => + { + if (x.Forgiven != true) + { + x.Forgiven = true; + x.ForgivenBy = mod; + } + }); + + public static Warning[] GetForGuild(this DbSet warnings, ulong id) + => warnings.AsQueryable().Where(x => x.GuildId == id).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Helpers/ActivityType.cs b/src/EllieBot/Db/Helpers/ActivityType.cs new file mode 100644 index 0000000..64bdd73 --- /dev/null +++ b/src/EllieBot/Db/Helpers/ActivityType.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Db; + +public enum DbActivityType +{ + /// The user is playing a game. + Playing, + + /// The user is streaming online. + Streaming, + + /// The user is listening to a song. + Listening, + + /// The user is watching some form of media. + Watching, + + /// The user has set a custom status. + CustomStatus, + + /// The user is competing in a game. + Competing, +} \ No newline at end of file diff --git a/src/EllieBot/Db/Helpers/GuildPerm.cs b/src/EllieBot/Db/Helpers/GuildPerm.cs new file mode 100644 index 0000000..4713afa --- /dev/null +++ b/src/EllieBot/Db/Helpers/GuildPerm.cs @@ -0,0 +1,47 @@ +namespace EllieBot.Db; + +[Flags] +public enum GuildPerm : ulong +{ + CreateInstantInvite = 1, + KickMembers = 2, + BanMembers = 4, + Administrator = 8, + ManageChannels = 16, // 0x0000000000000010 + ManageGuild = 32, // 0x0000000000000020 + ViewGuildInsights = 524288, // 0x0000000000080000 + AddReactions = 64, // 0x0000000000000040 + ViewAuditLog = 128, // 0x0000000000000080 + ViewChannel = 1024, // 0x0000000000000400 + SendMessages = 2048, // 0x0000000000000800 + SendTTSMessages = 4096, // 0x0000000000001000 + ManageMessages = 8192, // 0x0000000000002000 + EmbedLinks = 16384, // 0x0000000000004000 + AttachFiles = 32768, // 0x0000000000008000 + ReadMessageHistory = 65536, // 0x0000000000010000 + MentionEveryone = 131072, // 0x0000000000020000 + UseExternalEmojis = 262144, // 0x0000000000040000 + Connect = 1048576, // 0x0000000000100000 + Speak = 2097152, // 0x0000000000200000 + MuteMembers = 4194304, // 0x0000000000400000 + DeafenMembers = 8388608, // 0x0000000000800000 + MoveMembers = 16777216, // 0x0000000001000000 + UseVAD = 33554432, // 0x0000000002000000 + PrioritySpeaker = 256, // 0x0000000000000100 + Stream = 512, // 0x0000000000000200 + ChangeNickname = 67108864, // 0x0000000004000000 + ManageNicknames = 134217728, // 0x0000000008000000 + ManageRoles = 268435456, // 0x0000000010000000 + ManageWebhooks = 536870912, // 0x0000000020000000 + ManageEmojisAndStickers = 1073741824, // 0x0000000040000000 + UseApplicationCommands = 2147483648, // 0x0000000080000000 + RequestToSpeak = 4294967296, // 0x0000000100000000 + ManageEvents = 8589934592, // 0x0000000200000000 + ManageThreads = 17179869184, // 0x0000000400000000 + CreatePublicThreads = 34359738368, // 0x0000000800000000 + CreatePrivateThreads = 68719476736, // 0x0000001000000000 + UseExternalStickers = 137438953472, // 0x0000002000000000 + SendMessagesInThreads = 274877906944, // 0x0000004000000000 + StartEmbeddedActivities = 549755813888, // 0x0000008000000000 + ModerateMembers = 1099511627776, // 0x0000010000000000 +} diff --git a/src/EllieBot/Db/LevelStats.cs b/src/EllieBot/Db/LevelStats.cs new file mode 100644 index 0000000..fbb2e47 --- /dev/null +++ b/src/EllieBot/Db/LevelStats.cs @@ -0,0 +1,40 @@ +#nullable disable +namespace EllieBot.Db; + +public readonly struct LevelStats +{ + public const int XP_REQUIRED_LVL_1 = 36; + + public long Level { get; } + public long LevelXp { get; } + public long RequiredXp { get; } + public long TotalXp { get; } + + public LevelStats(long xp) + { + if (xp < 0) + xp = 0; + + TotalXp = xp; + + const int baseXp = XP_REQUIRED_LVL_1; + + var required = baseXp; + var totalXp = 0; + var lvl = 1; + while (true) + { + required = (int)(baseXp + (baseXp / 4.0 * (lvl - 1))); + + if (required + totalXp > xp) + break; + + totalXp += required; + lvl++; + } + + Level = lvl - 1; + LevelXp = xp - totalXp; + RequiredXp = required; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoCommand.cs b/src/EllieBot/Db/Models/AutoCommand.cs new file mode 100644 index 0000000..f412448 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoCommand.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoCommand : DbEntity +{ + public string CommandText { get; set; } + public ulong ChannelId { get; set; } + public string ChannelName { get; set; } + public ulong? GuildId { get; set; } + public string GuildName { get; set; } + public ulong? VoiceChannelId { get; set; } + public string VoiceChannelName { get; set; } + public int Interval { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoPublishChannel.cs b/src/EllieBot/Db/Models/AutoPublishChannel.cs new file mode 100644 index 0000000..c632776 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoPublishChannel.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Db.Models; + +public class AutoPublishChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoTranslateChannel.cs b/src/EllieBot/Db/Models/AutoTranslateChannel.cs new file mode 100644 index 0000000..33423c1 --- /dev/null +++ b/src/EllieBot/Db/Models/AutoTranslateChannel.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoTranslateChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public bool AutoDelete { get; set; } + public IList Users { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/AutoTranslateUser.cs b/src/EllieBot/Db/Models/AutoTranslateUser.cs new file mode 100644 index 0000000..459d58b --- /dev/null +++ b/src/EllieBot/Db/Models/AutoTranslateUser.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class AutoTranslateUser : DbEntity +{ + public int ChannelId { get; set; } + public AutoTranslateChannel Channel { get; set; } + public ulong UserId { get; set; } + public string Source { get; set; } + public string Target { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/BlacklistEntry.cs b/src/EllieBot/Db/Models/BlacklistEntry.cs new file mode 100644 index 0000000..d457e8a --- /dev/null +++ b/src/EllieBot/Db/Models/BlacklistEntry.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class BlacklistEntry : DbEntity +{ + public ulong ItemId { get; set; } + public BlacklistType Type { get; set; } +} + +public enum BlacklistType +{ + Server, + Channel, + User +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CommandAlias.cs b/src/EllieBot/Db/Models/CommandAlias.cs new file mode 100644 index 0000000..28f614a --- /dev/null +++ b/src/EllieBot/Db/Models/CommandAlias.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CommandAlias : DbEntity +{ + public string Trigger { get; set; } + public string Mapping { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CommandCooldown.cs b/src/EllieBot/Db/Models/CommandCooldown.cs new file mode 100644 index 0000000..e12ef9c --- /dev/null +++ b/src/EllieBot/Db/Models/CommandCooldown.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CommandCooldown : DbEntity +{ + public int Seconds { get; set; } + public string CommandName { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/CurrencyTransaction.cs b/src/EllieBot/Db/Models/CurrencyTransaction.cs new file mode 100644 index 0000000..8b9f6a6 --- /dev/null +++ b/src/EllieBot/Db/Models/CurrencyTransaction.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CurrencyTransaction : DbEntity +{ + public long Amount { get; set; } + public string Note { get; set; } + public ulong UserId { get; set; } + public string Type { get; set; } + public string Extra { get; set; } + public ulong? OtherId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DbEntity.cs b/src/EllieBot/Db/Models/DbEntity.cs new file mode 100644 index 0000000..0ed8388 --- /dev/null +++ b/src/EllieBot/Db/Models/DbEntity.cs @@ -0,0 +1,12 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class DbEntity +{ + [Key] + public int Id { get; set; } + + public DateTime? DateAdded { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs new file mode 100644 index 0000000..6cbe756 --- /dev/null +++ b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class DelMsgOnCmdChannel : DbEntity +{ + public ulong ChannelId { get; set; } + public bool State { get; set; } + + public override int GetHashCode() + => ChannelId.GetHashCode(); + + public override bool Equals(object obj) + => obj is DelMsgOnCmdChannel x && x.ChannelId == ChannelId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DiscordPemOverride.cs b/src/EllieBot/Db/Models/DiscordPemOverride.cs new file mode 100644 index 0000000..b9ecd24 --- /dev/null +++ b/src/EllieBot/Db/Models/DiscordPemOverride.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class DiscordPermOverride : DbEntity +{ + public GuildPerm Perm { get; set; } + + public ulong? GuildId { get; set; } + public string Command { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/DiscordUser.cs b/src/EllieBot/Db/Models/DiscordUser.cs new file mode 100644 index 0000000..83bda60 --- /dev/null +++ b/src/EllieBot/Db/Models/DiscordUser.cs @@ -0,0 +1,35 @@ +#nullable disable +namespace EllieBot.Db.Models; + + +// FUTURE remove LastLevelUp from here and UserXpStats +public class DiscordUser : DbEntity +{ + public ulong UserId { get; set; } + public string Username { get; set; } + public string Discriminator { get; set; } + public string AvatarId { get; set; } + + public int? ClubId { get; set; } + public ClubInfo Club { get; set; } + public bool IsClubAdmin { get; set; } + + public long TotalXp { get; set; } + public XpNotificationLocation NotifyOnLevelUp { get; set; } + + public long CurrencyAmount { get; set; } + + public override bool Equals(object obj) + => obj is DiscordUser du ? du.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override string ToString() + { + if (string.IsNullOrWhiteSpace(Discriminator) || Discriminator == "0000") + return Username; + + return Username + "#" + Discriminator; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Event.cs b/src/EllieBot/Db/Models/Event.cs new file mode 100644 index 0000000..63202f6 --- /dev/null +++ b/src/EllieBot/Db/Models/Event.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class CurrencyEvent +{ + public enum Type + { + Reaction, + + GameStatus + //NotRaid, + } + + public ulong ServerId { get; set; } + public ulong ChannelId { get; set; } + public ulong MessageId { get; set; } + public Type EventType { get; set; } + + /// + /// Amount of currency that the user will be rewarded. + /// + public long Amount { get; set; } + + /// + /// Maximum amount of currency that can be handed out. + /// + public long PotSize { get; set; } + + public List AwardedUsers { get; set; } + + /// + /// Used as extra data storage for events which need it. + /// + public ulong ExtraId { get; set; } + + /// + /// May be used for some future event. + /// + public ulong ExtraId2 { get; set; } + + /// + /// May be used for some future event. + /// + public string ExtraString { get; set; } +} + +public class AwardedUser +{ +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/FeedSub.cs b/src/EllieBot/Db/Models/FeedSub.cs new file mode 100644 index 0000000..66fc6f1 --- /dev/null +++ b/src/EllieBot/Db/Models/FeedSub.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FeedSub : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public ulong ChannelId { get; set; } + public string Url { get; set; } + + public string Message { get; set; } + + public override int GetHashCode() + => Url.GetHashCode(StringComparison.InvariantCulture) ^ GuildConfigId.GetHashCode(); + + public override bool Equals(object obj) + => obj is FeedSub s && s.Url.ToLower() == Url.ToLower() && s.GuildConfigId == GuildConfigId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/FollowedStream.cs b/src/EllieBot/Db/Models/FollowedStream.cs new file mode 100644 index 0000000..c880a8d --- /dev/null +++ b/src/EllieBot/Db/Models/FollowedStream.cs @@ -0,0 +1,33 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FollowedStream : DbEntity +{ + public enum FType + { + Twitch = 0, + Picarto = 3, + Youtube = 4, + Facebook = 5, + Trovo = 6 + } + + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public string Username { get; set; } + public FType Type { get; set; } + public string Message { get; set; } + + protected bool Equals(FollowedStream other) + => ChannelId == other.ChannelId + && Username.Trim().ToUpperInvariant() == other.Username.Trim().ToUpperInvariant() + && Type == other.Type; + + public override int GetHashCode() + => HashCode.Combine(ChannelId, Username, (int)Type); + + public override bool Equals(object obj) + => obj is FollowedStream fs && Equals(fs); + + +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GCChannelId.cs b/src/EllieBot/Db/Models/GCChannelId.cs new file mode 100644 index 0000000..c6f922b --- /dev/null +++ b/src/EllieBot/Db/Models/GCChannelId.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GCChannelId : DbEntity +{ + public GuildConfig GuildConfig { get; set; } + public ulong ChannelId { get; set; } + + public override bool Equals(object obj) + => obj is GCChannelId gc && gc.ChannelId == ChannelId; + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GamblingStats.cs b/src/EllieBot/Db/Models/GamblingStats.cs new file mode 100644 index 0000000..3c91a3b --- /dev/null +++ b/src/EllieBot/Db/Models/GamblingStats.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GamblingStats : DbEntity +{ + public string Feature { get; set; } + public decimal Bet { get; set; } + public decimal PaidOut { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GroupName.cs b/src/EllieBot/Db/Models/GroupName.cs new file mode 100644 index 0000000..3e29b31 --- /dev/null +++ b/src/EllieBot/Db/Models/GroupName.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GroupName : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public int Number { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GuildColors.cs b/src/EllieBot/Db/Models/GuildColors.cs new file mode 100644 index 0000000..efd5fc1 --- /dev/null +++ b/src/EllieBot/Db/Models/GuildColors.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class GuildColors +{ + [Key] + public ulong GuildId { get; set; } + + [Length(0, 9)] + public string? OkColor { get; set; } + + [Length(0, 9)] + public string? ErrorColor { get; set; } + + [Length(0, 9)] + public string? PendingColor { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GuildConfig.cs b/src/EllieBot/Db/Models/GuildConfig.cs new file mode 100644 index 0000000..a7b5ac5 --- /dev/null +++ b/src/EllieBot/Db/Models/GuildConfig.cs @@ -0,0 +1,107 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class GuildConfig : DbEntity +{ + public ulong GuildId { get; set; } + + public string Prefix { get; set; } + + public bool DeleteMessageOnCommand { get; set; } + public HashSet DelMsgOnCmdChannels { get; set; } = new(); + + public string AutoAssignRoleIds { get; set; } + + //greet stuff + public int AutoDeleteGreetMessagesTimer { get; set; } = 30; + public int AutoDeleteByeMessagesTimer { get; set; } = 30; + + public ulong GreetMessageChannelId { get; set; } + public ulong ByeMessageChannelId { get; set; } + + public bool SendDmGreetMessage { get; set; } + public string DmGreetMessageText { get; set; } = "Welcome to the %server% server, %user%!"; + + public bool SendChannelGreetMessage { get; set; } + public string ChannelGreetMessageText { get; set; } = "Welcome to the %server% server, %user%!"; + + public bool SendChannelByeMessage { get; set; } + public string ChannelByeMessageText { get; set; } = "%user% has left!"; + + //self assignable roles + public bool ExclusiveSelfAssignedRoles { get; set; } + public bool AutoDeleteSelfAssignedRoleMessages { get; set; } + + //stream notifications + public HashSet FollowedStreams { get; set; } = new(); + + //currencyGeneration + public HashSet GenerateCurrencyChannelIds { get; set; } = new(); + + public List Permissions { get; set; } + public bool VerbosePermissions { get; set; } = true; + public string PermissionRole { get; set; } + + public HashSet CommandCooldowns { get; set; } = new(); + + //filtering + public bool FilterInvites { get; set; } + public bool FilterLinks { get; set; } + public HashSet FilterInvitesChannelIds { get; set; } = new(); + public HashSet FilterLinksChannelIds { get; set; } = new(); + + //public bool FilterLinks { get; set; } + //public HashSet FilterLinksChannels { get; set; } = new HashSet(); + + public bool FilterWords { get; set; } + public HashSet FilteredWords { get; set; } = new(); + public HashSet FilterWordsChannelIds { get; set; } = new(); + + public HashSet MutedUsers { get; set; } = new(); + + public string MuteRoleName { get; set; } + public bool CleverbotEnabled { get; set; } + + public AntiRaidSetting AntiRaidSetting { get; set; } + public AntiSpamSetting AntiSpamSetting { get; set; } + public AntiAltSetting AntiAltSetting { get; set; } + + public string Locale { get; set; } + public string TimeZoneId { get; set; } + + public HashSet UnmuteTimers { get; set; } = new(); + public HashSet UnbanTimer { get; set; } = new(); + public HashSet UnroleTimer { get; set; } = new(); + public HashSet VcRoleInfos { get; set; } + public HashSet CommandAliases { get; set; } = new(); + public List WarnPunishments { get; set; } = new(); + public bool WarningsInitialized { get; set; } + public HashSet SlowmodeIgnoredUsers { get; set; } + public HashSet SlowmodeIgnoredRoles { get; set; } + + public List ShopEntries { get; set; } + public ulong? GameVoiceChannel { get; set; } + public bool VerboseErrors { get; set; } = true; + + public StreamRoleSettings StreamRole { get; set; } + + public XpSettings XpSettings { get; set; } + public List FeedSubs { get; set; } = new(); + public bool NotifyStreamOffline { get; set; } + public bool DeleteStreamOnlineMessage { get; set; } + public List SelfAssignableRoleGroupNames { get; set; } + public int WarnExpireHours { get; set; } + public WarnExpireAction WarnExpireAction { get; set; } = WarnExpireAction.Clear; + + public bool DisableGlobalExpressions { get; set; } = false; + + #region Boost Message + + public bool SendBoostMessage { get; set; } + public string BoostMessage { get; set; } = "%user% just boosted this server!"; + public ulong BoostMessageChannelId { get; set; } + public int BoostMessageDeleteAfter { get; set; } + public bool StickyRoles { get; set; } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/IgnoredLogItem.cs b/src/EllieBot/Db/Models/IgnoredLogItem.cs new file mode 100644 index 0000000..7424d84 --- /dev/null +++ b/src/EllieBot/Db/Models/IgnoredLogItem.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class IgnoredLogItem : DbEntity +{ + public int LogSettingId { get; set; } + public LogSetting LogSetting { get; set; } + public ulong LogItemId { get; set; } + public IgnoredItemType ItemType { get; set; } +} + +public enum IgnoredItemType +{ + Channel, + User +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs b/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs new file mode 100644 index 0000000..cbbda9e --- /dev/null +++ b/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class IgnoredVoicePresenceChannel : DbEntity +{ + public LogSetting LogSetting { get; set; } + public ulong ChannelId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/ImageOnlyChannel.cs b/src/EllieBot/Db/Models/ImageOnlyChannel.cs new file mode 100644 index 0000000..01d01fa --- /dev/null +++ b/src/EllieBot/Db/Models/ImageOnlyChannel.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class ImageOnlyChannel : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public OnlyChannelType Type { get; set; } +} + +public enum OnlyChannelType +{ + Image, + Link +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/LogSetting.cs b/src/EllieBot/Db/Models/LogSetting.cs new file mode 100644 index 0000000..677a128 --- /dev/null +++ b/src/EllieBot/Db/Models/LogSetting.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class LogSetting : DbEntity +{ + public List LogIgnores { get; set; } = new(); + + public ulong GuildId { get; set; } + public ulong? LogOtherId { get; set; } + public ulong? MessageUpdatedId { get; set; } + public ulong? MessageDeletedId { get; set; } + + public ulong? UserJoinedId { get; set; } + public ulong? UserLeftId { get; set; } + public ulong? UserBannedId { get; set; } + public ulong? UserUnbannedId { get; set; } + public ulong? UserUpdatedId { get; set; } + + public ulong? ChannelCreatedId { get; set; } + public ulong? ChannelDestroyedId { get; set; } + public ulong? ChannelUpdatedId { get; set; } + + + public ulong? ThreadDeletedId { get; set; } + public ulong? ThreadCreatedId { get; set; } + + public ulong? UserMutedId { get; set; } + + //userpresence + public ulong? LogUserPresenceId { get; set; } + + //voicepresence + + public ulong? LogVoicePresenceId { get; set; } + + public ulong? LogVoicePresenceTTSId { get; set; } + public ulong? LogWarnsId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Permission.cs b/src/EllieBot/Db/Models/Permission.cs new file mode 100644 index 0000000..5670dd8 --- /dev/null +++ b/src/EllieBot/Db/Models/Permission.cs @@ -0,0 +1,55 @@ +#nullable disable +using System.ComponentModel.DataAnnotations.Schema; +using System.Diagnostics; + +namespace EllieBot.Db.Models; + +[DebuggerDisplay("{PrimaryTarget}{SecondaryTarget} {SecondaryTargetName} {State} {PrimaryTargetId}")] +public class Permissionv2 : DbEntity, IIndexed +{ + public int? GuildConfigId { get; set; } + public int Index { get; set; } + + public PrimaryPermissionType PrimaryTarget { get; set; } + public ulong PrimaryTargetId { get; set; } + + public SecondaryPermissionType SecondaryTarget { get; set; } + public string SecondaryTargetName { get; set; } + + public bool IsCustomCommand { get; set; } + + public bool State { get; set; } + + [NotMapped] + public static Permissionv2 AllowAllPerm + => new() + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = true, + Index = 0 + }; + + public static List GetDefaultPermlist + => new() + { + AllowAllPerm + }; +} + +public enum PrimaryPermissionType +{ + User, + Channel, + Role, + Server +} + +public enum SecondaryPermissionType +{ + Module, + Command, + AllModules +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/PlantedCurrency.cs b/src/EllieBot/Db/Models/PlantedCurrency.cs new file mode 100644 index 0000000..7e640a5 --- /dev/null +++ b/src/EllieBot/Db/Models/PlantedCurrency.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class PlantedCurrency : DbEntity +{ + public long Amount { get; set; } + public string Password { get; set; } + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public ulong UserId { get; set; } + public ulong MessageId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/PlaylistSong.cs b/src/EllieBot/Db/Models/PlaylistSong.cs new file mode 100644 index 0000000..ccd9312 --- /dev/null +++ b/src/EllieBot/Db/Models/PlaylistSong.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class PlaylistSong : DbEntity +{ + public string Provider { get; set; } + public MusicType ProviderType { get; set; } + public string Title { get; set; } + public string Uri { get; set; } + public string Query { get; set; } +} + +public enum MusicType +{ + Radio, + YouTube, + Local, +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Reminder.cs b/src/EllieBot/Db/Models/Reminder.cs new file mode 100644 index 0000000..046615a --- /dev/null +++ b/src/EllieBot/Db/Models/Reminder.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Reminder : DbEntity +{ + public DateTime When { get; set; } + public ulong ChannelId { get; set; } + public ulong ServerId { get; set; } + public ulong UserId { get; set; } + public string Message { get; set; } + public bool IsPrivate { get; set; } + public ReminderType Type { get; set; } +} + +public enum ReminderType +{ + User, + Timely +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Repeater.cs b/src/EllieBot/Db/Models/Repeater.cs new file mode 100644 index 0000000..d0ef69e --- /dev/null +++ b/src/EllieBot/Db/Models/Repeater.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Repeater +{ + public int Id { get; set; } + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + public ulong? LastMessageId { get; set; } + public string Message { get; set; } + public TimeSpan Interval { get; set; } + public TimeSpan? StartTimeOfDay { get; set; } + public bool NoRedundant { get; set; } + public DateTime DateAdded { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/RotatingPlayingStatus.cs b/src/EllieBot/Db/Models/RotatingPlayingStatus.cs new file mode 100644 index 0000000..6cf5cc4 --- /dev/null +++ b/src/EllieBot/Db/Models/RotatingPlayingStatus.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class RotatingPlayingStatus : DbEntity +{ + public string Status { get; set; } + public DbActivityType Type { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/ShopEntry.cs b/src/EllieBot/Db/Models/ShopEntry.cs new file mode 100644 index 0000000..34cc8fe --- /dev/null +++ b/src/EllieBot/Db/Models/ShopEntry.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum ShopEntryType +{ + Role, + + List, + Command +} + +public class ShopEntry : DbEntity, IIndexed +{ + public int Index { get; set; } + public int Price { get; set; } + public string Name { get; set; } + public ulong AuthorId { get; set; } + + public ShopEntryType Type { get; set; } + + //role + public string RoleName { get; set; } + public ulong RoleId { get; set; } + + //list + public HashSet Items { get; set; } = new(); + public ulong? RoleRequirement { get; set; } + + // command + public string Command { get; set; } +} + +public class ShopEntryItem : DbEntity +{ + public string Text { get; set; } + + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + return ((ShopEntryItem)obj).Text == Text; + } + + public override int GetHashCode() + => Text.GetHashCode(StringComparison.InvariantCulture); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/StreamOnlineMessage.cs b/src/EllieBot/Db/Models/StreamOnlineMessage.cs new file mode 100644 index 0000000..c6443a6 --- /dev/null +++ b/src/EllieBot/Db/Models/StreamOnlineMessage.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class StreamOnlineMessage : DbEntity +{ + public ulong ChannelId { get; set; } + public ulong MessageId { get; set; } + + public FollowedStream.FType Type { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/StreamRoleSettings.cs b/src/EllieBot/Db/Models/StreamRoleSettings.cs new file mode 100644 index 0000000..02674d5 --- /dev/null +++ b/src/EllieBot/Db/Models/StreamRoleSettings.cs @@ -0,0 +1,68 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class StreamRoleSettings : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + /// + /// Whether the feature is enabled in the guild. + /// + public bool Enabled { get; set; } + + /// + /// Id of the role to give to the users in the role 'FromRole' when they start streaming + /// + public ulong AddRoleId { get; set; } + + /// + /// Id of the role whose users are eligible to get the 'AddRole' + /// + public ulong FromRoleId { get; set; } + + /// + /// If set, feature will only apply to users who have this keyword in their streaming status. + /// + public string Keyword { get; set; } + + /// + /// A collection of whitelisted users' IDs. Whitelisted users don't require 'keyword' in + /// order to get the stream role. + /// + public HashSet Whitelist { get; set; } = new(); + + /// + /// A collection of blacklisted users' IDs. Blacklisted useres will never get the stream role. + /// + public HashSet Blacklist { get; set; } = new(); +} + +public class StreamRoleBlacklistedUser : DbEntity +{ + public ulong UserId { get; set; } + public string Username { get; set; } + + public override bool Equals(object obj) + { + if (obj is not StreamRoleBlacklistedUser x) + return false; + + return x.UserId == UserId; + } + + public override int GetHashCode() + => UserId.GetHashCode(); +} + +public class StreamRoleWhitelistedUser : DbEntity +{ + public ulong UserId { get; set; } + public string Username { get; set; } + + public override bool Equals(object obj) + => obj is StreamRoleWhitelistedUser x ? x.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/VcRoleInfo.cs b/src/EllieBot/Db/Models/VcRoleInfo.cs new file mode 100644 index 0000000..bb28450 --- /dev/null +++ b/src/EllieBot/Db/Models/VcRoleInfo.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class VcRoleInfo : DbEntity +{ + public ulong VoiceChannelId { get; set; } + public ulong RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Waifu.cs b/src/EllieBot/Db/Models/Waifu.cs new file mode 100644 index 0000000..78ca0b3 --- /dev/null +++ b/src/EllieBot/Db/Models/Waifu.cs @@ -0,0 +1,75 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Services.Database.Models; + +public class WaifuInfo : DbEntity +{ + public int WaifuId { get; set; } + public DiscordUser Waifu { get; set; } + + public int? ClaimerId { get; set; } + public DiscordUser Claimer { get; set; } + + public int? AffinityId { get; set; } + public DiscordUser Affinity { get; set; } + + public long Price { get; set; } + public List Items { get; set; } = new(); + + public override string ToString() + { + var status = string.Empty; + + var waifuUsername = Waifu.ToString().TrimTo(20); + var claimer = Claimer?.ToString().TrimTo(20) + ?? "no one"; + + var affinity = Affinity?.ToString().TrimTo(20); + + if (AffinityId is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (AffinityId == ClaimerId) + status = $"... and {waifuUsername} likes {claimer} too <3"; + else + { + status = + $"... but {waifuUsername}'s heart belongs to {affinity}"; + } + + return $"**{waifuUsername}** - claimed by **{claimer}**\n\t{status}"; + } +} + +public class WaifuLbResult +{ + public string Username { get; set; } + public string Discrim { get; set; } + + public string Claimer { get; set; } + public string ClaimerDiscrim { get; set; } + + public string Affinity { get; set; } + public string AffinityDiscrim { get; set; } + + public long Price { get; set; } + + public override string ToString() + { + var claimer = "no one"; + var status = string.Empty; + + var waifuUsername = Username.TrimTo(20); + var claimerUsername = Claimer?.TrimTo(20); + + if (Claimer is not null) + claimer = $"{claimerUsername}#{ClaimerDiscrim}"; + if (Affinity is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (Affinity + AffinityDiscrim == Claimer + ClaimerDiscrim) + status = $"... and {waifuUsername} likes {claimerUsername} too <3"; + else + status = $"... but {waifuUsername}'s heart belongs to {Affinity.TrimTo(20)}#{AffinityDiscrim}"; + return $"**{waifuUsername}#{Discrim}** - claimed by **{claimer}**\n\t{status}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiAltSetting.cs b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs new file mode 100644 index 0000000..b9f9e58 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs @@ -0,0 +1,11 @@ +namespace EllieBot.Db.Models; + +public class AntiAltSetting +{ + public int Id { get; set; } + public int GuildConfigId { get; set; } + public TimeSpan MinAge { get; set; } + public PunishmentAction Action { get; set; } + public int ActionDurationMinutes { get; set; } + public ulong? RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs new file mode 100644 index 0000000..aef2658 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Db.Models; + + +public class AntiRaidSetting : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public int UserThreshold { get; set; } + public int Seconds { get; set; } + public PunishmentAction Action { get; set; } + + /// + /// Duration of the punishment, in minutes. This works only for supported Actions, like: + /// Mute, Chatmute, Voicemute, etc... + /// + public int PunishDuration { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs b/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs new file mode 100644 index 0000000..a3cd623 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiSpamIgnore.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Db.Models; + +public class AntiSpamIgnore : DbEntity +{ + public ulong ChannelId { get; set; } + + public override int GetHashCode() + => ChannelId.GetHashCode(); + + public override bool Equals(object? obj) + => obj is AntiSpamIgnore inst && inst.ChannelId == ChannelId; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs new file mode 100644 index 0000000..42c2183 --- /dev/null +++ b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public class AntiSpamSetting : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public PunishmentAction Action { get; set; } + public int MessageThreshold { get; set; } = 3; + public int MuteTime { get; set; } + public ulong? RoleId { get; set; } + public HashSet IgnoredChannels { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/club/ClubInfo.cs b/src/EllieBot/Db/Models/club/ClubInfo.cs new file mode 100644 index 0000000..e5b7407 --- /dev/null +++ b/src/EllieBot/Db/Models/club/ClubInfo.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class ClubInfo : DbEntity +{ + [MaxLength(20)] + public string Name { get; set; } + public string Description { get; set; } + public string ImageUrl { get; set; } = string.Empty; + + public int Xp { get; set; } = 0; + public int? OwnerId { get; set; } + public DiscordUser Owner { get; set; } + + public List Members { get; set; } = new(); + public List Applicants { get; set; } = new(); + public List Bans { get; set; } = new(); + + public override string ToString() + => Name; +} + +public class ClubApplicants +{ + public int ClubId { get; set; } + public ClubInfo Club { get; set; } + + public int UserId { get; set; } + public DiscordUser User { get; set; } +} + +public class ClubBans +{ + public int ClubId { get; set; } + public ClubInfo Club { get; set; } + + public int UserId { get; set; } + public DiscordUser User { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/currency/BankUser.cs b/src/EllieBot/Db/Models/currency/BankUser.cs new file mode 100644 index 0000000..b62b49d --- /dev/null +++ b/src/EllieBot/Db/Models/currency/BankUser.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Db.Models; + +public class BankUser : DbEntity +{ + public ulong UserId { get; set; } + public long Balance { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/expr/EllieExpression.cs b/src/EllieBot/Db/Models/expr/EllieExpression.cs new file mode 100644 index 0000000..53eef8b --- /dev/null +++ b/src/EllieBot/Db/Models/expr/EllieExpression.cs @@ -0,0 +1,27 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class EllieExpression : DbEntity +{ + public ulong? GuildId { get; set; } + public string Response { get; set; } + public string Trigger { get; set; } + + public bool AutoDeleteTrigger { get; set; } + public bool DmResponse { get; set; } + public bool ContainsAnywhere { get; set; } + public bool AllowTarget { get; set; } + public string Reactions { get; set; } + + public string[] GetReactions() + => string.IsNullOrWhiteSpace(Reactions) ? Array.Empty() : Reactions.Split("@@@"); + + public bool IsGlobal() + => GuildId is null or 0; +} + +public class ReactionResponse : DbEntity +{ + public bool OwnerOnly { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/expr/Quote.cs b/src/EllieBot/Db/Models/expr/Quote.cs new file mode 100644 index 0000000..62f57d7 --- /dev/null +++ b/src/EllieBot/Db/Models/expr/Quote.cs @@ -0,0 +1,26 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class Quote : DbEntity +{ + public ulong GuildId { get; set; } + + [Required] + public string Keyword { get; set; } + + [Required] + public string AuthorName { get; set; } + + public ulong AuthorId { get; set; } + + [Required] + public string Text { get; set; } +} + +public enum OrderType +{ + Id = -1, + Keyword = -2 +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilterChannelId.cs b/src/EllieBot/Db/Models/filter/FilterChannelId.cs new file mode 100644 index 0000000..fe3b97b --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterChannelId.cs @@ -0,0 +1,30 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterChannelId : DbEntity +{ + public ulong ChannelId { get; set; } + + public bool Equals(FilterChannelId other) + => ChannelId == other.ChannelId; + + public override bool Equals(object obj) + => obj is FilterChannelId fci && Equals(fci); + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} + +public class FilterWordsChannelId : DbEntity +{ + public ulong ChannelId { get; set; } + + public bool Equals(FilterWordsChannelId other) + => ChannelId == other.ChannelId; + + public override bool Equals(object obj) + => obj is FilterWordsChannelId fci && Equals(fci); + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs b/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs new file mode 100644 index 0000000..50aca96 --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterLinksChannelId.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterLinksChannelId : DbEntity +{ + public ulong ChannelId { get; set; } + + public override bool Equals(object obj) + => obj is FilterLinksChannelId f && f.ChannelId == ChannelId; + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilteredWord.cs b/src/EllieBot/Db/Models/filter/FilteredWord.cs new file mode 100644 index 0000000..de66d7a --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilteredWord.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilteredWord : DbEntity +{ + public string Word { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs b/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs new file mode 100644 index 0000000..ca077b2 --- /dev/null +++ b/src/EllieBot/Db/Models/giveaway/GiveawayModel.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class GiveawayModel +{ + public int Id { get; set; } + public ulong GuildId { get; set; } + public ulong MessageId { get; set; } + public ulong ChannelId { get; set; } + public string Message { get; set; } + + public IList Participants { get; set; } = new List(); + public DateTime EndsAt { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs b/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs new file mode 100644 index 0000000..a8b964e --- /dev/null +++ b/src/EllieBot/Db/Models/giveaway/GiveawayUser.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class GiveawayUser +{ + public int Id { get; set; } + public int GiveawayId { get; set; } + public ulong UserId { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/BanTemplate.cs b/src/EllieBot/Db/Models/punish/BanTemplate.cs new file mode 100644 index 0000000..0c8519f --- /dev/null +++ b/src/EllieBot/Db/Models/punish/BanTemplate.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class BanTemplate : DbEntity +{ + public ulong GuildId { get; set; } + public string Text { get; set; } + public int? PruneDays { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/MutedUserId.cs b/src/EllieBot/Db/Models/punish/MutedUserId.cs new file mode 100644 index 0000000..f067e77 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/MutedUserId.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MutedUserId : DbEntity +{ + public ulong UserId { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is MutedUserId mui ? mui.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/PunishmentAction.cs b/src/EllieBot/Db/Models/punish/PunishmentAction.cs new file mode 100644 index 0000000..5788e65 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/PunishmentAction.cs @@ -0,0 +1,15 @@ +namespace EllieBot.Db.Models; + +public enum PunishmentAction +{ + Mute, + Kick, + Ban, + Softban, + RemoveRoles, + ChatMute, + VoiceMute, + AddRole, + Warn, + TimeOut +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/WarnExpireAction.cs b/src/EllieBot/Db/Models/punish/WarnExpireAction.cs new file mode 100644 index 0000000..0de916e --- /dev/null +++ b/src/EllieBot/Db/Models/punish/WarnExpireAction.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum WarnExpireAction +{ + Clear, + Delete +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/Warning.cs b/src/EllieBot/Db/Models/punish/Warning.cs new file mode 100644 index 0000000..454a4cb --- /dev/null +++ b/src/EllieBot/Db/Models/punish/Warning.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class Warning : DbEntity +{ + public ulong GuildId { get; set; } + public ulong UserId { get; set; } + public string Reason { get; set; } + public bool Forgiven { get; set; } + public string ForgivenBy { get; set; } + public string Moderator { get; set; } + public long Weight { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/punish/WarningPunishment.cs b/src/EllieBot/Db/Models/punish/WarningPunishment.cs new file mode 100644 index 0000000..5368938 --- /dev/null +++ b/src/EllieBot/Db/Models/punish/WarningPunishment.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WarningPunishment : DbEntity +{ + public int Count { get; set; } + public PunishmentAction Punishment { get; set; } + public int Time { get; set; } + public ulong? RoleId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/ReactionRole.cs b/src/EllieBot/Db/Models/roles/ReactionRole.cs new file mode 100644 index 0000000..2dedbfe --- /dev/null +++ b/src/EllieBot/Db/Models/roles/ReactionRole.cs @@ -0,0 +1,18 @@ +#nullable disable +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Db.Models; + +public class ReactionRoleV2 : DbEntity +{ + public ulong GuildId { get; set; } + public ulong ChannelId { get; set; } + + public ulong MessageId { get; set; } + + [MaxLength(100)] + public string Emote { get; set; } + public ulong RoleId { get; set; } + public int Group { get; set; } + public int LevelReq { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs b/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs new file mode 100644 index 0000000..ac147b6 --- /dev/null +++ b/src/EllieBot/Db/Models/roles/SelfAssignableRole.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SelfAssignedRole : DbEntity +{ + public ulong GuildId { get; set; } + public ulong RoleId { get; set; } + + public int Group { get; set; } + public int LevelRequirement { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/roles/StickyRoles.cs b/src/EllieBot/Db/Models/roles/StickyRoles.cs new file mode 100644 index 0000000..3e01ae9 --- /dev/null +++ b/src/EllieBot/Db/Models/roles/StickyRoles.cs @@ -0,0 +1,14 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public class StickyRole : DbEntity +{ + public ulong GuildId { get; set; } + public string RoleIds { get; set; } + public ulong UserId { get; set; } + + public ulong[] GetRoleIds() + => string.IsNullOrWhiteSpace(RoleIds) + ? [] + : RoleIds.Split(',').Select(ulong.Parse).ToArray(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs new file mode 100644 index 0000000..e41c2e7 --- /dev/null +++ b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredRole.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SlowmodeIgnoredRole : DbEntity +{ + public ulong RoleId { get; set; } + + // override object.Equals + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return ((SlowmodeIgnoredRole)obj).RoleId == RoleId; + } + + // override object.GetHashCode + public override int GetHashCode() + => RoleId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs new file mode 100644 index 0000000..7ae0a05 --- /dev/null +++ b/src/EllieBot/Db/Models/slowmode/SlowmodeIgnoredUser.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class SlowmodeIgnoredUser : DbEntity +{ + public ulong UserId { get; set; } + + // override object.Equals + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return ((SlowmodeIgnoredUser)obj).UserId == UserId; + } + + // override object.GetHashCode + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/support/PatronQuota.cs b/src/EllieBot/Db/Models/support/PatronQuota.cs new file mode 100644 index 0000000..b87dcbc --- /dev/null +++ b/src/EllieBot/Db/Models/support/PatronQuota.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace EllieBot.Db.Models; + +/// +/// Contains data about usage of Patron-Only commands per user +/// in order to provide support for quota limitations +/// (allow user x who is pledging amount y to use the specified command only +/// x amount of times in the specified time period) +/// +public class PatronQuota +{ + public ulong UserId { get; set; } + public FeatureType FeatureType { get; set; } + public string Feature { get; set; } + public uint HourlyCount { get; set; } + public uint DailyCount { get; set; } + public uint MonthlyCount { get; set; } +} + +public enum FeatureType +{ + Command, + Group, + Module, + Limit +} + +public class PatronUser +{ + public string UniquePlatformUserId { get; set; } + public ulong UserId { get; set; } + public int AmountCents { get; set; } + + public DateTime LastCharge { get; set; } + + // Date Only component + public DateTime ValidThru { get; set; } + + public PatronUser Clone() + => new PatronUser() + { + UniquePlatformUserId = this.UniquePlatformUserId, + UserId = this.UserId, + AmountCents = this.AmountCents, + LastCharge = this.LastCharge, + ValidThru = this.ValidThru + }; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/support/RewardedUser.cs b/src/EllieBot/Db/Models/support/RewardedUser.cs new file mode 100644 index 0000000..bc12bdd --- /dev/null +++ b/src/EllieBot/Db/Models/support/RewardedUser.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class RewardedUser : DbEntity +{ + public ulong UserId { get; set; } + public string PlatformUserId { get; set; } + public long AmountRewardedThisMonth { get; set; } + public DateTime LastReward { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs b/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs new file mode 100644 index 0000000..b213788 --- /dev/null +++ b/src/EllieBot/Db/Models/todo/ArchivedTodoListModel.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class ArchivedTodoListModel +{ + public int Id { get; set; } + public ulong UserId { get; set; } + public string Name { get; set; } + public List Items { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/todo/TodoModel.cs b/src/EllieBot/Db/Models/todo/TodoModel.cs new file mode 100644 index 0000000..ba3c8c1 --- /dev/null +++ b/src/EllieBot/Db/Models/todo/TodoModel.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Db.Models; + +#nullable disable +public sealed class TodoModel +{ + public int Id { get; set; } + public ulong UserId { get; set; } + public string Todo { get; set; } + + public DateTime DateAdded { get; set; } + public bool IsDone { get; set; } + public int? ArchiveId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnbanTimer.cs b/src/EllieBot/Db/Models/untimer/UnbanTimer.cs new file mode 100644 index 0000000..2f61402 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnbanTimer.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnbanTimer : DbEntity +{ + public ulong UserId { get; set; } + public DateTime UnbanAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnbanTimer ut ? ut.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs b/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs new file mode 100644 index 0000000..18b2903 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnmuteTimer.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnmuteTimer : DbEntity +{ + public ulong UserId { get; set; } + public DateTime UnmuteAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnmuteTimer ut ? ut.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/untimer/UnroleTimer.cs b/src/EllieBot/Db/Models/untimer/UnroleTimer.cs new file mode 100644 index 0000000..27193c2 --- /dev/null +++ b/src/EllieBot/Db/Models/untimer/UnroleTimer.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UnroleTimer : DbEntity +{ + public ulong UserId { get; set; } + public ulong RoleId { get; set; } + public DateTime UnbanAt { get; set; } + + public override int GetHashCode() + => UserId.GetHashCode() ^ RoleId.GetHashCode(); + + public override bool Equals(object obj) + => obj is UnroleTimer ut ? ut.UserId == UserId && ut.RoleId == RoleId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/UserXpStats.cs b/src/EllieBot/Db/Models/xp/UserXpStats.cs new file mode 100644 index 0000000..d603360 --- /dev/null +++ b/src/EllieBot/Db/Models/xp/UserXpStats.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class UserXpStats : DbEntity +{ + public ulong UserId { get; set; } + public ulong GuildId { get; set; } + public long Xp { get; set; } + public long AwardedXp { get; set; } + public XpNotificationLocation NotifyOnLevelUp { get; set; } +} + +public enum XpNotificationLocation { None, Dm, Channel } \ No newline at end of file diff --git a/src/EllieBot/Db/Models/xp/XpSettings.cs b/src/EllieBot/Db/Models/xp/XpSettings.cs new file mode 100644 index 0000000..694b289 --- /dev/null +++ b/src/EllieBot/Db/Models/xp/XpSettings.cs @@ -0,0 +1,62 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class XpSettings : DbEntity +{ + public int GuildConfigId { get; set; } + public GuildConfig GuildConfig { get; set; } + + public HashSet RoleRewards { get; set; } = new(); + public HashSet CurrencyRewards { get; set; } = new(); + public HashSet ExclusionList { get; set; } = new(); + public bool ServerExcluded { get; set; } +} + +public enum ExcludedItemType { Channel, Role } + +public class XpRoleReward : DbEntity +{ + public int XpSettingsId { get; set; } + public XpSettings XpSettings { get; set; } + + public int Level { get; set; } + public ulong RoleId { get; set; } + + /// + /// Whether the role should be removed (true) or added (false) + /// + public bool Remove { get; set; } + + public override int GetHashCode() + => Level.GetHashCode() ^ XpSettingsId.GetHashCode(); + + public override bool Equals(object obj) + => obj is XpRoleReward xrr && xrr.Level == Level && xrr.XpSettingsId == XpSettingsId; +} + +public class XpCurrencyReward : DbEntity +{ + public int XpSettingsId { get; set; } + public XpSettings XpSettings { get; set; } + + public int Level { get; set; } + public int Amount { get; set; } + + public override int GetHashCode() + => Level.GetHashCode() ^ XpSettingsId.GetHashCode(); + + public override bool Equals(object obj) + => obj is XpCurrencyReward xrr && xrr.Level == Level && xrr.XpSettingsId == XpSettingsId; +} + +public class ExcludedItem : DbEntity +{ + public ulong ItemId { get; set; } + public ExcludedItemType ItemType { get; set; } + + public override int GetHashCode() + => ItemId.GetHashCode() ^ ItemType.GetHashCode(); + + public override bool Equals(object obj) + => obj is ExcludedItem ei && ei.ItemId == ItemId && ei.ItemType == ItemType; +} \ No newline at end of file diff --git a/src/EllieBot/Db/MysqlContext.cs b/src/EllieBot/Db/MysqlContext.cs new file mode 100644 index 0000000..e8f4eba --- /dev/null +++ b/src/EllieBot/Db/MysqlContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public sealed class MysqlContext : EllieContext +{ + private readonly string _connStr; + private readonly string _version; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public MysqlContext(string connStr = "Server=localhost", string version = "8.0") + { + _connStr = connStr; + _version = version; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseMySql(_connStr, ServerVersion.Parse(_version)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // mysql is case insensitive by default + // we can set binary collation to change that + modelBuilder.Entity() + .Property(x => x.Name) + .UseCollation("utf8mb4_bin"); + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/PostgreSqlContext.cs b/src/EllieBot/Db/PostgreSqlContext.cs new file mode 100644 index 0000000..aea3e7c --- /dev/null +++ b/src/EllieBot/Db/PostgreSqlContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class PostgreSqlContext : EllieContext +{ + private readonly string _connStr; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public PostgreSqlContext(string connStr = "Host=localhost") + { + _connStr = connStr; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + base.OnConfiguring(optionsBuilder); + optionsBuilder + .UseLowerCaseNamingConvention() + .UseNpgsql(_connStr); + } +} \ No newline at end of file diff --git a/src/EllieBot/Db/SqliteContext.cs b/src/EllieBot/Db/SqliteContext.cs new file mode 100644 index 0000000..e284968 --- /dev/null +++ b/src/EllieBot/Db/SqliteContext.cs @@ -0,0 +1,26 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Db; + +public sealed class SqliteContext : EllieContext +{ + private readonly string _connectionString; + + protected override string CurrencyTransactionOtherIdDefaultValue + => "NULL"; + + public SqliteContext(string connectionString = "Data Source=data/EllieBot.db", int commandTimeout = 60) + { + _connectionString = connectionString; + Database.SetCommandTimeout(commandTimeout); + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + var builder = new SqliteConnectionStringBuilder(_connectionString); + builder.DataSource = Path.Combine(AppContext.BaseDirectory, builder.DataSource); + optionsBuilder.UseSqlite(builder.ToString()); + } +} \ No newline at end of file -- 2.43.0 From 2e587e83ebd0a4611bfd4a3da3d1ff7c26798c2e Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 14 May 2024 23:59:24 +1200 Subject: [PATCH 010/340] Updated data files and GlobalUsings.cs --- src/EllieBot/GlobalUsings.cs | 3 +- src/EllieBot/data/aliases.yml | 147 +- src/EllieBot/data/bot.yml | 7 +- src/EllieBot/data/gambling.yml | 54 +- src/EllieBot/{ => data/lib}/libsodium.dll | Bin src/EllieBot/{ => data/lib}/libsodium.so | Bin src/EllieBot/{ => data/lib}/opus.dll | Bin src/EllieBot/{libopus.so => data/lib/opus.so} | Bin .../data/strings/commands/commands.en-US.yml | 4998 ++++++++++++----- .../strings/responses/responses.en-US.json | 44 +- src/EllieBot/data/units.json | 11 + 11 files changed, 3689 insertions(+), 1575 deletions(-) rename src/EllieBot/{ => data/lib}/libsodium.dll (100%) rename src/EllieBot/{ => data/lib}/libsodium.so (100%) rename src/EllieBot/{ => data/lib}/opus.dll (100%) rename src/EllieBot/{libopus.so => data/lib/opus.so} (100%) diff --git a/src/EllieBot/GlobalUsings.cs b/src/EllieBot/GlobalUsings.cs index 7984372..dbac5fd 100644 --- a/src/EllieBot/GlobalUsings.cs +++ b/src/EllieBot/GlobalUsings.cs @@ -3,16 +3,15 @@ global using NonBlocking; // packages global using Serilog; -global using Humanizer; // elliebot global using EllieBot; +global using EllieBot.Db; global using EllieBot.Services; global using Ellie.Common; // new project global using EllieBot.Common; // old + elliebot specific things global using EllieBot.Common.Attributes; global using EllieBot.Extensions; -global using Ellie.Marmalade; // discord global using Discord; diff --git a/src/EllieBot/data/aliases.yml b/src/EllieBot/data/aliases.yml index 9348169..4ec12e0 100644 --- a/src/EllieBot/data/aliases.yml +++ b/src/EllieBot/data/aliases.yml @@ -185,6 +185,9 @@ threaddelete: prune: - prune - clear +prunecancel: + - prunecancel + - prunec die: - die setname: @@ -195,6 +198,8 @@ setnick: setavatar: - setavatar - setav +setbanner: + - setbanner setgame: - setgame send: @@ -416,8 +421,6 @@ typestop: - typestop typeadd: - typeadd -pollend: - - pollend pick: - pick plant: @@ -432,8 +435,6 @@ choose: - choose rps: - rps -linux: - - linux next: - next - n @@ -461,9 +462,6 @@ queuesearch: - queuesearch - qs - yqs -soundcloudqueue: - - soundcloudqueue - - sq listqueue: - listqueue - lq @@ -477,9 +475,6 @@ volume: playlist: - playlist - pl -soundcloudpl: - - soundcloudpl - - scpl localplaylist: - localplaylist - lopl @@ -511,6 +506,8 @@ queuerepeat: queueautoplay: - queueautoplay - qap +queuefairplay: + - qfp save: - save streamrole: @@ -647,8 +644,6 @@ chucknorris: magicitem: - magicitem - mi -safebooru: - - safebooru wiki: - wiki - wikipedia @@ -658,25 +653,6 @@ color: avatar: - avatar - av -hentai: - - hentai -danbooru: - - danbooru -derpibooru: - - derpibooru - - derpi -gelbooru: - - gelbooru -rule34: - - rule34 -e621: - - e621 -boobs: - - boobs -butts: - - butts - - ass - - butt translate: - translate - trans @@ -757,10 +733,6 @@ chatmute: - chatmute voicemute: - voicemute -konachan: - - konachan -sankaku: - - sankaku muterole: - muterole - setmuterole @@ -774,13 +746,6 @@ unmute: - unmute xkcd: - xkcd -placelist: - - placelist -place: - - place -poll: - - poll - - ppoll autotranslang: - autotranslang - atl @@ -797,8 +762,6 @@ typelist: - typelist listservers: - listservers -hentaibomb: - - hentaibomb cleverbot: - cleverbot - chatgpt @@ -807,8 +770,6 @@ shorten: wikia: - wikia - fandom -yandere: - - yandere magicthegathering: - magicthegathering - mtg @@ -833,8 +794,6 @@ define: - def activity: - activity -autohentai: - - autohentai setstatus: - setstatus invitecreate: @@ -848,8 +807,6 @@ invitedelete: - invitedelete - invrm - invdel -pollstats: - - pollstats antilist: - antilist - antilst @@ -919,8 +876,6 @@ languageset: languageslist: - languageslist - langli -rategirl: - - rategirl aliaslist: - aliaslist - cmdmaplist @@ -1055,9 +1010,6 @@ configreload: - creload - confreload - crel -nsfwtagblacklist: - - nsfwtagbl - - nsfwtbl experience: - experience - xp @@ -1137,10 +1089,8 @@ clubleaderboard: - clubs clubadmin: - clubadmin -autoboobs: - - autoboobs -autobutts: - - autobutts +clubrename: + - clubrename eightball: - eightball - 8ball @@ -1163,6 +1113,8 @@ sqlexec: - sqlexec sqlselect: - sqlselect +sqlselectcsv: + - sqlselectcsv deletewaifus: - deletewaifus deletewaifu: @@ -1211,19 +1163,19 @@ pathofexilecurrency: - poec rollduel: - rollduel -reactionroleadd: - - reactionroleadd +reroadd: + - reroadd - reroa -reactionroleslist: - - reactionroleslist +rerolist: + - rerolist - reroli -reactionrolesremove: - - reactionrolesremove +reroremove: + - reroremove - rerorm -reactionrolesdeleteall: +rerodeleteall: - rerodeleteall - rerodela -reactionrolestransfer: +rerotransfer: - rerotransfer - rerot blackjack: @@ -1247,10 +1199,9 @@ delete: roleid: - roleid - rid -nsfwtoggle: +agerestricttoggle: - nsfwtoggle - - nsfw - - nsfwtgl + - artoggle economy: - economy purgeuser: @@ -1356,6 +1307,9 @@ marmaladelist: marmaladeinfo: - marmaladeinfo - mainfo +marmaladesearch: + - marmaladesearch + - masearchW # Bank stuff bankdeposit: - deposit @@ -1389,3 +1343,56 @@ doas: - execas cacheusers: - cacheusers +giveawaystart: + - start +giveawayend: + - end +giveawaycancel: + - cancel +giveawayreroll: + - reroll +giveawaylist: + - list +# todos +todoadd: + - add + - a +todolist: + - list + - ls +tododelete: + - delete + - del + - remove + - rm +todoclear: + - clear + - clr + - cls +todocomplete: + - complete + - done + - finish +todoarchiveadd: + - add + - create + - new +todoarchiveshow: + - show +todoarchivelist: + - list + - ls +todoarchivedelete: + - delete + - del + - remove + - rm +todoedit: + - edit + - change +todoshow: + - show + - sh + - see +stickyroles: + - stickyroles diff --git a/src/EllieBot/data/bot.yml b/src/EllieBot/data/bot.yml index 42753e0..81130d1 100644 --- a/src/EllieBot/data/bot.yml +++ b/src/EllieBot/data/bot.yml @@ -1,5 +1,5 @@ # DO NOT CHANGE -version: 5 +version: 7 # Most commands, when executed, have a small colored line # next to the response. The color depends whether the command # is completed, errored or in progress (pending) @@ -28,6 +28,11 @@ forwardToAllOwners: false # Any messages sent by users in Bot's DM to be forwarded to the specified channel. # This option will only work when ForwardToAllOwners is set to false forwardToChannel: +# Should the bot ignore messages from other bots? +# Settings this to false might get your bot banned if it gets into a spam loop with another bot. +# This will only affect command executions, other features will still block bots from access. +# Default true +ignoreOtherBots: true # When a user DMs the bot with a message which is not a command # they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot. # Supports embeds. How it looks: https://puu.sh/B0BLV.png diff --git a/src/EllieBot/data/gambling.yml b/src/EllieBot/data/gambling.yml index 4064cfe..fbcbdc4 100644 --- a/src/EllieBot/data/gambling.yml +++ b/src/EllieBot/data/gambling.yml @@ -1,12 +1,12 @@ # DO NOT CHANGE -version: 6 +version: 7 # Currency settings currency: -# What is the emoji/character which represents the currency + # What is the emoji/character which represents the currency sign: "💵" # What is the name of the currency name: Ellie Cash - # For how long will the transactions be kept in the database (curtrs) + # For how long (in days) will the transactions be kept in the database (curtrs) # Set 0 to disable cleanup (keep transactions forever) transactionsLifetime: 0 # Minimum amount users can bet (>=0) @@ -16,13 +16,13 @@ minBet: 0 maxBet: 0 # Settings for betflip command betFlip: -# Bet multiplier if user guesses correctly + # Bet multiplier if user guesses correctly multiplier: 1.95 # Settings for betroll command betRoll: -# When betroll is played, user will roll a number 0-100. -# This setting will describe which multiplier is used for when the roll is higher than the given number. -# Doesn't have to be ordered. + # When betroll is played, user will roll a number 0-100. + # This setting will describe which multiplier is used for when the roll is higher than the given number. + # Doesn't have to be ordered. pairs: - whenAbove: 99 multiplyBy: 10 @@ -32,9 +32,9 @@ betRoll: multiplyBy: 2 # Automatic currency generation settings. generation: -# when currency is generated, should it also have a random password -# associated with it which users have to type after the .pick command -# in order to get it + # when currency is generated, should it also have a random password + # associated with it which users have to type after the .pick command + # in order to get it hasPassword: true # Every message sent has a certain % chance to generate the currency # specify the percentage here (1 being 100%, 0 being 0% - for example @@ -50,16 +50,16 @@ generation: # Settings for timely command # (letting people claim X amount of currency every Y hours) timely: -# How much currency will the users get every time they run .timely command -# setting to 0 or less will disable this feature + # How much currency will the users get every time they run .timely command + # setting to 0 or less will disable this feature amount: 120 # How often (in hours) can users claim currency with .timely command # setting to 0 or less will disable this feature cooldown: 12 # How much will each user's owned currency decay over time. decay: -# Percentage of user's current currency which will be deducted every 24h. -# 0 - 1 (1 is 100%, 0.5 50%, 0 disabled) + # Percentage of user's current currency which will be deducted every 24h. + # 0 - 1 (1 is 100%, 0.5 50%, 0 disabled) percent: 0 # Maximum amount of user's currency that can decay at each interval. 0 for unlimited. maxDecay: 0 @@ -67,9 +67,17 @@ decay: minThreshold: 99 # How often, in hours, does the decay run. Default is 24 hours hourInterval: 24 +# What is the bot's cut on some transactions +botCuts: + # Shop sale cut percentage. + # Whenever a user buys something from the shop, bot will take a cut equal to this percentage. + # The rest goes to the user who posted the item/role/whatever to the shop. + # This is a good way to reduce the amount of currency in circulation therefore keeping the inflation in check. + # Default 0.1 (10%). + shopSaleCut: 0.1 # Settings for LuckyLadder command luckyLadder: -# Self-Explanatory. Has to have 8 values, otherwise the command won't work. + # Self-Explanatory. Has to have 8 values, otherwise the command won't work. multipliers: - 2.4 - 1.7 @@ -81,12 +89,12 @@ luckyLadder: - 0.1 # Settings related to waifus waifu: -# Minimum price a waifu can have + # Minimum price a waifu can have minPrice: 50 multipliers: - # Multiplier for waifureset. Default 150. - # Formula (at the time of writing this): - # price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up + # Multiplier for waifureset. Default 150. + # Formula (at the time of writing this): + # price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up waifuReset: 150 # The minimum amount of currency that you have to pay # in order to buy a waifu who doesn't have a crush on you. @@ -117,9 +125,9 @@ waifu: # Settings for periodic waifu price decay. # Waifu price decays only if the waifu has no claimer. decay: - # Percentage (0 - 100) of the waifu value to reduce. - # Set 0 to disable - # For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$) + # Percentage (0 - 100) of the waifu value to reduce. + # Set 0 to disable + # For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$) percent: 0 # How often to decay waifu values, in hours hourInterval: 24 @@ -257,5 +265,5 @@ patreonCurrencyPerCent: 1 voteReward: 100 # Slot config slots: -# Hex value of the color which the numbers on the slot image will have. + # Hex value of the color which the numbers on the slot image will have. currencyFontColor: ff0000 diff --git a/src/EllieBot/libsodium.dll b/src/EllieBot/data/lib/libsodium.dll similarity index 100% rename from src/EllieBot/libsodium.dll rename to src/EllieBot/data/lib/libsodium.dll diff --git a/src/EllieBot/libsodium.so b/src/EllieBot/data/lib/libsodium.so similarity index 100% rename from src/EllieBot/libsodium.so rename to src/EllieBot/data/lib/libsodium.so diff --git a/src/EllieBot/opus.dll b/src/EllieBot/data/lib/opus.dll similarity index 100% rename from src/EllieBot/opus.dll rename to src/EllieBot/data/lib/opus.dll diff --git a/src/EllieBot/libopus.so b/src/EllieBot/data/lib/opus.so similarity index 100% rename from src/EllieBot/libopus.so rename to src/EllieBot/data/lib/opus.so diff --git a/src/EllieBot/data/strings/commands/commands.en-US.yml b/src/EllieBot/data/strings/commands/commands.en-US.yml index a916919..61d97ee 100644 --- a/src/EllieBot/data/strings/commands/commands.en-US.yml +++ b/src/EllieBot/data/strings/commands/commands.en-US.yml @@ -1,35 +1,57 @@ h: - desc: "Either shows a help for a single command, or DMs you help link if no parameters are specified." - args: + desc: Either shows a help for a single command, or DMs you help link if no parameters are specified. + ex: - "{0}cmds" - "" + params: + - fail: + desc: "Fallback parameter if the command is not found." + - com: + desc: "The command information that the help is being requested for." gencmdlist: - desc: "Generates the command list and sends it to the chat. Optionally also uploads it to DO spaces (not supported)." - args: + desc: Generates the command list and sends it to the chat. Optionally also uploads it to DO spaces (not supported). + ex: - "" + params: + - {} donate: - desc: "Instructions for helping the project financially." - args: + desc: Instructions for helping the project financially. + ex: - "" + params: + - {} modules: - desc: "Lists all bot modules." - args: + desc: Lists all bot modules. + ex: - "" + params: + - page: + desc: "The number of the page to display in the list of bot modules." commands: - desc: "List all of the bot's commands from the specified module. You can either specify the full name or only the first few letters of the module name. Specifying no module will show the list of modules instead." - args: - - "Admin" - - "Admin --view 1" + desc: List all of the bot's commands from the specified module. You can either specify the full name or only the first few letters of the module name. Specifying no module will show the list of modules instead. + ex: + - Admin + - Admin --view 1 - "" + params: + - module: + desc: "The name of a module to retrieve command information for." + params: + desc: "The names of one or more modules to retrieve commands from, allowing for partial matching and optional omission." greetdel: - desc: "Sets the time it takes (in seconds) for greet messages to be auto-deleted. Set it to 0 to disable automatic deletion." - args: - - "0" - - "30" + desc: Sets the time it takes (in seconds) for greet messages to be auto-deleted. Set it to 0 to disable automatic deletion. + ex: + - 0 + - 30 + params: + - timer: + desc: "The amount of time before greeting messages are automatically deleted from the chat." greet: - desc: "Toggles announcements on the current channel when someone joins the server." - args: + desc: Toggles announcements on the current channel when someone joins the server. + ex: - "" + params: + - {} greetmsg: desc: |- Sets a new join announcement message which will be shown in the server's channel. @@ -37,12 +59,17 @@ greetmsg: Full list of placeholders can be found here Using it with no message will show the current greet message. You can use embed json from instead of a regular text, if you want the message to be embedded. - args: - - "Welcome, %user.mention%." + ex: + - Welcome, %user.mention%. + params: + - text: + desc: "The new announcement message to be displayed when a user joins the server." bye: - desc: "Toggles announcements on the current channel when someone leaves the server." - args: + desc: Toggles announcements on the current channel when someone leaves the server. + ex: - "" + params: + - {} byemsg: desc: |- Sets a new leave announcement message. @@ -50,160 +77,305 @@ byemsg: Full list of placeholders can be found here Using this command with no message will show the current bye message. You can use embed json from instead of a regular text, if you want the message to be embedded. - args: + ex: - "%user.mention% has left." + params: + - text: + desc: "The user's farewell message to display when they leave the chat." byedel: - desc: "Sets the time it takes (in seconds) for bye messages to be auto-deleted. Set it to `0` to disable automatic deletion." - args: - - "0" - - "30" + desc: Sets the time it takes (in seconds) for bye messages to be auto-deleted. Set it to `0` to disable automatic deletion. + ex: + - 0 + - 30 + params: + - timer: + desc: "The amount of time before a bye message is automatically deleted." greetdm: - desc: "Toggles whether the greet messages will be sent in a DM (This is separate from greet - you can have both, any or neither enabled)." - args: + desc: Toggles whether the greet messages will be sent in a DM (This is separate from greet - you can have both, any or neither enabled). + ex: - "" + params: + - {} greettest: - desc: "Sends the greet message in the current channel as if you just joined the server. You can optionally specify a different user." - args: + desc: Sends the greet message in the current channel as if you just joined the server. You can optionally specify a different user. + ex: - "" - "@SomeoneElse" + params: + - user: + desc: "The user to impersonate when sending the greeting, or null for the bot's own account." greetdmtest: - desc: "Sends the greet direct message to you as if you just joined the server. You can optionally specify a different user." - args: + desc: Sends the greet direct message to you as if you just joined the server. You can optionally specify a different user. + ex: - "" - "@SomeoneElse" + params: + - user: + desc: "The recipient of the greeting, which defaults to the caller if not specified." byetest: - desc: "Sends the bye message in the current channel as if you just left the server. You can optionally specify a different user." - args: + desc: Sends the bye message in the current channel as if you just left the server. You can optionally specify a different user. + ex: - "" - "@SomeoneElse" + params: + - user: + desc: "The user who is leaving the channel, or whose account is being represented as leaving the channel." boost: - desc: "Toggles announcements on the current channel when someone boosts the server." - args: + desc: Toggles announcements on the current channel when someone boosts the server. + ex: - "" + params: + - {} boostmsg: desc: |- Sets a new boost announcement message. Type `%user.mention%` if you want to show the name the user who left. - Full list of placeholders can be found here + Full list of placeholders can be found here Using this command with no message will show the current boost message. You can use embed json from instead of a regular text, if you want the message to be embedded. - args: + ex: - "%user.mention% has boosted the server!!!" + params: + - text: + desc: "The text to set as the new announcement message." boostdel: - desc: "Sets the time it takes (in seconds) for boost messages to be auto-deleted. Set it to `0` to disable automatic deletion." - args: - - "0" - - "30" + desc: Sets the time it takes (in seconds) for boost messages to be auto-deleted. Set it to `0` to disable automatic deletion. + ex: + - 0 + - 30 + params: + - timer: + desc: "The amount of time before boost messages are automatically deleted." logserver: - desc: "Enables or Disables ALL log events. If enabled, all log events will log to this channel." - args: - - "enable" - - "disable" + desc: Enables or Disables ALL log events. If enabled, all log events will log to this channel. + ex: + - enable + - disable + params: + - action: + desc: "The type of action to take on the log event." logignore: - desc: "Toggles whether the `{0}logserver` command ignores the specified channel or user. Provide no arguments to see the list of currently ignored users and channels" - args: + desc: Toggles whether the `{0}logserver` command ignores the specified channel or user. Provide no arguments to see the list of currently ignored users and channels + ex: - "" - "@SomeUser" - "#some-channel" + params: + - {} + - target: + desc: "The channel to ignore or show the list of ignored channels for." + - target: + desc: "The user or channel being targeted for logging ignore or inclusion." repeatlist: - desc: "Shows currently repeating messages and their indexes." - args: + desc: Shows currently repeating messages and their indexes. + ex: - "" + params: + - {} repeatremove: - desc: "Removes a repeating message on a specified index. Use `{0}repeatlist` to see indexes." - args: - - "2" + desc: Removes a repeating message on a specified index. Use `{0}repeatlist` to see indexes. + ex: + - 2 + params: + - index: + desc: "The index at which the repeating message should be removed." repeatinvoke: - desc: "Immediately shows the repeat message on a certain index and restarts its timer." - args: - - "1" + desc: Immediately shows the repeat message on a certain index and restarts its timer. + ex: + - 1 + params: + - index: + desc: "The index at which to display the repeat message." repeat: - desc: "Repeat a message once every specified amount of time in the current channel. You can instead specify time of day for the message to be repeated daily (make sure you've set your server's timezone). If you've specified time of day, you can still override the default daily interval with your own interval. You can have up to 5 repeating messages on the server in total." - args: - - "Hello there" - - "1h5m Hello @erryone" - - "10:00 Daily have a nice day! This will execute once every 24h." - - "21:00 30m Starting at 21 and every 30 minutes after that i will send this message!" + desc: Repeat a message once every specified amount of time in the current channel. You can instead specify time of day for the message to be repeated daily (make sure you've set your server's timezone). If you've specified time of day, you can still override the default daily interval with your own interval. You can have up to 5 repeating messages on the server in total. + ex: + - Hello there + - 1h5m Hello @erryone + - 10:00 Daily have a nice day! This will execute once every 24h. + - 21:00 30m Starting at 21 and every 30 minutes after that i will send this message! + params: + - message: + desc: "The text to be repeated at the specified intervals or times." + - channel: + desc: "The channel where the message will be repeated." + message: + desc: "The text to be repeated at the specified intervals or times." + - interval: + desc: "The amount of time between each repetition." + message: + desc: "The text to be repeated at the specified intervals or times." + - ch: + desc: "The channel where the message will be sent." + interval: + desc: "The amount of time between each repetition." + message: + desc: "The text to be repeated at the specified intervals or times." + - dt: + desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." + message: + desc: "The text to be repeated at the specified intervals or times." + - channel: + desc: "The channel where the message will be repeated." + dt: + desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." + message: + desc: "The text to be repeated at the specified intervals or times." + - dt: + desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." + interval: + desc: "The amount of time between each repetition." + message: + desc: "The text to be repeated at the specified intervals or times." + - channel: + desc: "The channel where the message will be repeated." + dt: + desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." + interval: + desc: "The amount of time between each repetition." + message: + desc: "The text to be repeated at the specified intervals or times." repeatredundant: - desc: "Specify repeater's index (use `{0}repli` to find it) to toggle whether that repeater's message should be reposted if the last message in the channel is the same repeater's message. This is useful if you want to remind everyone to be nice in the channel every so often, but don't want to have the bot spam the channel. This is NOT useful if you want to periodically ping someone." - args: - - "1" + desc: Specify repeater's index (use `{0}repli` to find it) to toggle whether that repeater's message should be reposted if the last message in the channel is the same repeater's message. This is useful if you want to remind everyone to be nice in the channel every so often, but don't want to have the bot spam the channel. This is NOT useful if you want to periodically ping someone. + ex: + - 1 + params: + - index: + desc: "The number of times a message should be repeated before reposting." repeatskip: - desc: "Specify a repeater's ID to toggle whether the next trigger of the repeater will be skipped. This setting is not stored in the database and will get reset if the bot is restarted." - args: - - "3" + desc: Specify a repeater's ID to toggle whether the next trigger of the repeater will be skipped. This setting is not stored in the database and will get reset if the bot is restarted. + ex: + - 3 + params: + - index: + desc: "The number of times to skip before triggering again." rotateplaying: - desc: "Toggles rotation of playing status of the dynamic strings you previously specified." - args: + desc: Toggles rotation of playing status of the dynamic strings you previously specified. + ex: - "" + params: + - {} addplaying: - desc: "Adds a specified string to the list of playing strings to rotate. You have to pick either 'Playing', 'Watching' or 'Listening' as the first parameter." - args: - - "Playing with you" - - "Watching you sleep" + desc: Adds a specified string to the list of playing strings to rotate. You have to pick either 'Playing', 'Watching' or 'Listening' as the first parameter. + ex: + - Playing with you + - Watching you sleep + params: + - t: + desc: "The type of status, allowed values are 'Playing', 'Watching', or 'Listening'." + status: + desc: "The status text." listplaying: - desc: "Lists all playing statuses with their corresponding number." - args: + desc: Lists all playing statuses with their corresponding number. + ex: - "" + params: + - {} removeplaying: - desc: "Removes a playing string on a given number." - args: + desc: Removes a playing string on a given number. + ex: - "" + params: + - index: + desc: "The position in the list where the playing string should be removed." vcrolelist: - desc: "Shows a list of currently set voice channel roles." - args: + desc: Shows a list of currently set voice channel roles. + ex: - "" + params: + - {} vcrole: - desc: "Sets or resets a role which will be given to users who join the voice channel you're in when you run this command. Provide no role name to disable. You must be in a voice channel to run this command." - args: - - "SomeRole" + desc: Sets or resets a role which will be given to users who join the voice channel you're in when you run this command. Provide no role name to disable. You must be in a voice channel to run this command. + ex: + - SomeRole - "" + params: + - role: + desc: "The role that is assigned to new members of the voice channel." vcrolerm: - desc: "Removes vcrole associated with the specified voice channel ID. This is useful if your vcrole has been enabled on a VC which has been deleted." - args: - - "123123123123123" + desc: Removes vcrole associated with the specified voice channel ID. This is useful if your vcrole has been enabled on a VC which has been deleted. + ex: + - 123123123123123 + params: + - vcId: + desc: "The unique identifier of the voice channel to remove the vcrole from." asar: - desc: "Adds a role to the list of self-assignable roles. You can also specify a group. If 'Exclusive self-assignable roles' feature is enabled, users will be able to pick one role per group." - args: - - "Gamer" - - "1 Alliance" - - "1 Horde" + desc: Adds a role to the list of self-assignable roles. You can also specify a group. If 'Exclusive self-assignable roles' feature is enabled, users will be able to pick one role per group. + ex: + - Gamer + - 1 Alliance + - 1 Horde + params: + - role: + desc: "The role that can be assigned by the user." + - group: + desc: "The ID of a group that the new role should belong to." + role: + desc: "The role that can be assigned by the user." rsar: - desc: "Removes a specified role from the list of self-assignable roles." - args: - - "Gamer" - - "Alliance" - - "Horde" + desc: Removes a specified role from the list of self-assignable roles. + ex: + - Gamer + - Alliance + - Horde + params: + - role: + desc: "The role being removed from the list of self-assignable roles." lsar: - desc: "Lists self-assignable roles. Shows 20 roles per page." - args: + desc: Lists self-assignable roles. Shows 20 roles per page. + ex: - "" - - "2" + - 2 + params: + - page: + desc: "The current page number for the list of roles." sargn: - desc: "Sets a self assignable role group name. Provide no name to remove." - args: - - "1 Faction" - - "2" + desc: Sets a self assignable role group name. Provide no name to remove. + ex: + - 1 Faction + - 2 + params: + - group: + desc: "The ID of the group to set as self-assignable." + name: + desc: "The name of the new or existing role group to set." togglexclsar: - desc: "Toggles whether the self-assigned roles are exclusive. While enabled, users can only have one self-assignable role per group." - args: + desc: Toggles whether the self-assigned roles are exclusive. While enabled, users can only have one self-assignable role per group. + ex: - "" + params: + - {} iam: - desc: "Adds a role to you that you choose. Role must be on a list of self-assignable roles." - args: - - "Gamer" + desc: Adds a role to you that you choose. Role must be on a list of self-assignable roles. + ex: + - Gamer + params: + - role: + desc: "The type of access or permission granted to the user." iamnot: - desc: "Removes a specified role from you. Role must be on a list of self-assignable roles." - args: - - "Gamer" + desc: Removes a specified role from you. Role must be on a list of self-assignable roles. + ex: + - Gamer + params: + - role: + desc: "The role being removed from the user's assignment." expradd: desc: "Add an expression with a trigger and a response. Bot will post a response whenever someone types the trigger word. Running this command in server requires the Administration permission. Running this command in DM is Bot Owner only and adds a new global expression. Guide here: " - args: + ex: - '"hello" Hi there %user.mention%' + params: + - key: + desc: "The trigger word that sets off the response when typed by a user." + message: + desc: "The text of the message that triggers the response when typed by a user." expraddserver: desc: "Add an expression with a trigger and a response in this server. Bot will post a response whenever someone types the trigger word. Guide here: " - args: + ex: - '"hello" Hi there %user.mention%' + params: + - key: + desc: "The unique identifier for the expression to be added in this server." + message: + desc: "The text of the message that triggers the bot's response." exprlist: desc: |- Lists global or server expressions (20 commands per page). @@ -213,1019 +385,1803 @@ exprlist: • 🗯️ Triggered if trigger matches any word (`{0}h {0}exca`) • ✉️ Response will be DMed (`{0}h {0}exdm`) • ❌ Trigger will be deleted (`{0}h {0}exad`) - args: - - "1" - - "all" + ex: + - 1 + - all + params: + - page: + desc: "The number of pages to display in the list." exprshow: - desc: "Shows an expression's response on a given ID." - args: - - "1" + desc: Shows an expression's response on a given ID. + ex: + - 1 + params: + - id: + desc: "The identifier for the entity whose response is being displayed." exprdelete: - desc: "Deletes an expression on a specific index. If ran in DM, it is bot owner only and deletes a global expression. If ran in a server, it requires Administration privileges and removes server expression." - args: - - "5" + desc: Deletes an expression on a specific index. If ran in DM, it is bot owner only and deletes a global expression. If ran in a server, it requires Administration privileges and removes server expression. + ex: + - 5 + params: + - id: + desc: "The identifier of the expression to be deleted." exprdeleteserver: - desc: "Deletes an expression on a specific index on this server." - args: - - "5c" + desc: Deletes an expression on a specific index on this server. + ex: + - 5c + params: + - id: + desc: "The identifier of the expression to be deleted." exprclear: - desc: "Deletes all expression on this server." - args: + desc: Deletes all expression on this server. + ex: - "" + params: + - {} fwclear: - desc: "Deletes all filtered words on this server." - args: + desc: Deletes all filtered words on this server. + ex: - "" + params: + - {} filterlist: - desc: "Lists invite and link filter channels and status." - args: + desc: Lists invite and link filter channels and status. + ex: - "" + params: + - {} aliasesclear: - desc: "Deletes all aliases on this server." - args: + desc: Deletes all aliases on this server. + ex: - "" + params: + - {} autoassignrole: desc: |- Toggles the role which will be assigned to every user who joins the server. You can run this command multiple times to add multiple roles (up to 3). Specifying the role that is already added will remove that role from the list. Provide no parameters to list current roles. - args: + ex: - "" - - "RoleName" + - RoleName + params: + - role: + desc: "The role assigned to new users, determining their permissions and access rights within the server." + - {} leave: - desc: "Makes Ellie leave the server. Either server name or server ID is required." - args: - - "123123123331" + desc: Makes Ellie leave the server. Either server name or server ID is required. + ex: + - 123123123331 + params: + - guildStr: + desc: "The name of the server where Ellie should leave." slowmode: - desc: "Toggles slowmode on the current channel with the specified amount of time. Provide no parameters to disable." - args: + desc: Toggles slowmode on the current channel with the specified amount of time. Provide no parameters to disable. + ex: - "" - - "27s" - - "3h15m5s" + - 27s + - 3h15m5s + params: + - time: + desc: "The duration for which the slowmode should be enabled." delmsgoncmd: desc: "Toggles the automatic deletion of the user's successful command message to prevent chat flood. You can use it either as a server toggle, channel whitelist, or channel blacklist, as channel option has 3 settings: Enable (always do it on this channel), Disable (never do it on this channel), and Inherit (respect server setting). Use `list` parameter to see the current states." - args: + ex: - "" - - "channel enable" - - "ch inherit" - - "list" + - channel enable + - ch inherit + - list + params: + - _: + desc: "The list of channels or servers where the automatic deletion is enabled, disabled, or inherited." + - _: + desc: "The server where the command is being executed or monitored for chat flood prevention." + - _: + desc: "The channel where the automatic deletion of successful command messages should be toggled." + s: + desc: "The state of whether automatic deletion is enabled or disabled for a specific channel." + ch: + desc: "The channel where the automatic deletion of successful command messages should be toggled for." + - _: + desc: "The channel where the automatic deletion of successful command messages should be toggled." + s: + desc: "The state of whether automatic deletion is enabled or disabled for a specific channel." + chId: + desc: "The ID of a channel where the automatic deletion should be toggled or inherited." restart: - desc: "Restarts the bot. Might not work." - args: + desc: Restarts the bot. Might not work. + ex: - "" + params: + - {} setrole: - desc: "Gives a role to a user. The role you specify has to be lower in the role hierarchy than your highest role." - args: + desc: Gives a role to a user. The role you specify has to be lower in the role hierarchy than your highest role. + ex: - "@User Guest" + params: + - targetUser: + desc: "The user being given the new role, which must have a lower rank than the assistant's highest role." + roleToAdd: + desc: "The role that is being added grants specific permissions and access rights to the user." removerole: - desc: "Removes a role from a user. The role you specify has to be lower in the role hierarchy than your highest role." - args: + desc: Removes a role from a user. The role you specify has to be lower in the role hierarchy than your highest role. + ex: - "@User Admin" + params: + - targetUser: + desc: "The user account being modified or checked for role eligibility." + roleToRemove: + desc: "The role being removed from the user's set of assigned roles." renamerole: - desc: "Renames a role. The role you specify has to be lower in the role hierarchy than your highest role." - args: + desc: Renames a role. The role you specify has to be lower in the role hierarchy than your highest role. + ex: - '"First role" SecondRole' + params: + - roleToEdit: + desc: "The role being edited or updated." + newname: + desc: "The name for the new role." removeallroles: - desc: "Removes all roles which are lower than your highest role in the role hierarchy from the user you specify." - args: + desc: Removes all roles which are lower than your highest role in the role hierarchy from the user you specify. + ex: - "@User" + params: + - user: + desc: "The user whose roles will be updated to reflect the new role hierarchy." rolehoist: - desc: "Toggles whether this role is displayed in the sidebar or not. The role you specify has to be lower in the role hierarchy than your highest role." - args: - - "Guests" - - "Space Wizards" + desc: Toggles whether this role is displayed in the sidebar or not. The role you specify has to be lower in the role hierarchy than your highest role. + ex: + - Guests + - Space Wizards + params: + - role: + desc: "The role that determines the visibility of the sidebar." createrole: - desc: "Creates a role with a given name." - args: - - "Awesome Role" + desc: Creates a role with a given name. + ex: + - Awesome Role + params: + - roleName: + desc: "The name of the new role being created." deleterole: - desc: "Deletes a role with a given name." - args: - - "Awesome Role" + desc: Deletes a role with a given name. + ex: + - Awesome Role + params: + - role: + desc: "The role being deleted, as identified by its unique identifier." rolecolor: - desc: "Set a role's color using its hex value. Provide no color in order to see the hex value of the color of the specified role. The role you specify has to be lower in the role hierarchy than your highest role." - args: - - "Admin" - - "ffba55 Admin" + desc: Set a role's color using its hex value. Provide no color in order to see the hex value of the color of the specified role. The role you specify has to be lower in the role hierarchy than your highest role. + ex: + - Admin + - ffba55 Admin + params: + - role: + desc: "The role that will have its color set or retrieved." + - color: + desc: "The color used for the role's text and background." + role: + desc: "The role that will have its color set or retrieved." ban: - desc: "Bans a user by ID or name with an optional message. You can specify a time string before the user name to ban the user temporarily." - args: + desc: Bans a user by ID or name with an optional message. You can specify a time string before the user name to ban the user temporarily. + ex: - "@Someone Get out!" - '"Some Guy#1234" Your behaviour is toxic.' - - "1d12h @Someone Come back when u chill" + - 1d12h @Someone Come back when u chill + params: + - time: + desc: "The duration of the temporary ban." + user: + desc: "The user being banned, either by ID or name, and potentially temporarily." + msg: + desc: "The reason for the ban is provided in this message." + - time: + desc: "The duration of the temporary ban." + userId: + desc: "The unique identifier of the user being banned." + msg: + desc: "The reason for the ban is provided in this message." + - userId: + desc: "The unique identifier of the user being banned." + msg: + desc: "The reason for the ban is provided in this message." + - user: + desc: "The user being banned, either by ID or name, and potentially temporarily." + msg: + desc: "The reason for the ban is provided in this message." softban: - desc: "Bans and then unbans a user by ID or name with an optional message." - args: + desc: Bans and then unbans a user by ID or name with an optional message. + ex: - "@Someone Get out!" - '"Some Guy#1234" Your behaviour is toxic.' + params: + - user: + desc: "The user being banned and then unbanned." + msg: + desc: "The reason for the ban is described in this string." + - userId: + desc: "The unique identifier for the user being banned and then unbanned." + msg: + desc: "The reason for the ban is described in this string." kick: - desc: "Kicks a mentioned user." - args: + desc: Kicks a mentioned user. + ex: - "@Someone Get out!" - '"Some Guy#1234" Your behaviour is toxic.' + params: + - user: + desc: "The user being kicked from the guild." + msg: + desc: "The message to display when kicking the user." + - userId: + desc: "The ID of the user being kicked from the chat." + msg: + desc: "The message to display when kicking the user." timeout: - desc: "Times the user out for the specified amount of time. You may optionally specify a reason, which will be sent to the user." - args: + desc: Times the user out for the specified amount of time. You may optionally specify a reason, which will be sent to the user. + ex: - "@Someone 3h Shut up!" - "@Someone 1h30m" + params: + - globalUser: + desc: "The user's account or identity that is being timed out." + time: + desc: "The duration of the timeout period." + msg: + desc: "The brief message explaining why the user was timed out." mute: - desc: "Mutes a mentioned user both from speaking and chatting. You can also specify time string for how long the user should be muted. You can optionally specify a reason." - args: + desc: Mutes a mentioned user both from speaking and chatting. You can also specify time string for how long the user should be muted. You can optionally specify a reason. + ex: - "@Someone" - "@Someone too noisy" - - "1h30m @Someone" - - "1h30m @Someone too noisy" + - 1h30m @Someone + - 1h30m @Someone too noisy + params: + - target: + desc: "The user to whom the mute action is being applied." + reason: + desc: "The optional reason provided helps to explain why the user was muted." + - time: + desc: "The duration of the mute period." + user: + desc: "The user to whom the mute action is being applied." + reason: + desc: "The optional reason provided helps to explain why the user was muted." voiceunmute: - desc: "Gives a previously voice-muted user a permission to speak." - args: + desc: Gives a previously voice-muted user a permission to speak. + ex: - "@Someguy" + params: + - user: + desc: "The user who was previously muted is now able to participate in the conversation again." + reason: + desc: "The reason for the user's previous mute." deafen: - desc: "Deafens mentioned user or users." - args: + desc: Deafens mentioned user or users. + ex: - '"@Someguy"' - '"@Someguy" "@Someguy"' + params: + - users: + desc: "The list of users to be affected by the deafening action." undeafen: - desc: "Undeafens mentioned user or users." - args: + desc: Undeafens mentioned user or users. + ex: - '"@Someguy"' - '"@Someguy" "@Someguy"' + params: + - users: + desc: "The list of users to undeafen." delvoichanl: - desc: "Deletes a voice channel with a given name." - args: - - "VoiceChannelName" + desc: Deletes a voice channel with a given name. + ex: + - VoiceChannelName + params: + - voiceChannel: + desc: "The voice channel being deleted." creatvoichanl: - desc: "Creates a new voice channel with a given name." - args: - - "VoiceChannelName" + desc: Creates a new voice channel with a given name. + ex: + - VoiceChannelName + params: + - channelName: + desc: "The name of the new voice channel being created." deltxtchanl: - desc: "Deletes a text channel with a given name." - args: - - "TextChannelName" + desc: Deletes a text channel with a given name. + ex: + - TextChannelName + params: + - toDelete: + desc: "The channel to be deleted, specified by its object reference." creatxtchanl: - desc: "Creates a new text channel with a given name." - args: - - "TextChannelName" + desc: Creates a new text channel with a given name. + ex: + - TextChannelName + params: + - channelName: + desc: "The name of the new channel to be created." settopic: - desc: "Sets a topic on the current channel." - args: - - "My new topic" + desc: Sets a topic on the current channel. + ex: + - My new topic + params: + - topic: + desc: "The new topic for discussion on the channel." setchanlname: - desc: "Changes the name of the current channel." - args: - - "NewName" + desc: Changes the name of the current channel. + ex: + - NewName + params: + - name: + desc: "The new name for the channel." prune: desc: "`{0}prune` removes all Ellie's messages in the last 100 messages. `{0}prune X` removes last `X` number of messages from the channel (up to 100). `{0}prune @Someone` removes all Someone's messages in the last 100 messages. `{0}prune @Someone X` removes last `X` number of 'Someone's' messages in the channel." - args: + ex: - "" - - "-s" - - "5" - - "5 --safe" + - -s + - 5 + - 5 --safe - "@Someone" - "@Someone --safe" - "@Someone X" - "@Someone X -s" -die: - desc: "Shuts the bot down." - args: + params: + - params: + desc: "The list of users, channels or message counts to be removed from the conversation history." + - count: + desc: "The number of messages to remove from the channel or user's messages." + params: + desc: "The list of users, channels or message counts to be removed from the conversation history." + - user: + desc: "The user whose messages are to be removed from the channel." + count: + desc: "The number of messages to remove from the channel or user's messages." + params: + desc: "The list of users, channels or message counts to be removed from the conversation history." + - userId: + desc: "The ID of a user to filter messages by." + count: + desc: "The number of messages to remove from the channel or user's messages." + params: + desc: "The list of users, channels or message counts to be removed from the conversation history." +prunecancel: + desc: Cancels an active prune if there is any. + ex: - "" + params: + - {} +die: + desc: Shuts the bot down. + ex: + - "" + params: + - graceful: + desc: "The option to perform a controlled shutdown, allowing for any necessary cleanup or notifications before termination." setname: - desc: "Gives the bot a new name." - args: - - "BotName" + desc: Gives the bot a new name. + ex: + - BotName + params: + - newName: + desc: "The new name given to the bot." setnick: - desc: "Changes the nickname of the bot on this server. You can also target other users to change their nickname." - args: - - "BotNickname" + desc: Changes the nickname of the bot on this server. You can also target other users to change their nickname. + ex: + - BotNickname - "@SomeUser New Nickname" + params: + - newNick: + desc: "The new nickname to be displayed for the bot or targeted user." + - gu: + desc: "The guild user that is being targeted for a nickname change." + newNick: + desc: "The new nickname to be displayed for the bot or targeted user." setavatar: - desc: "Sets a new avatar image for the EllieBot. Parameter is a direct link to an image." - args: - - "https://i.imgur.com/xTG3a1I.jpg" + desc: Sets a new avatar image for the EllieBot. Parameter is a direct link to an image. + ex: + - https://i.imgur.com/xTG3a1I.jpg + params: + - img: + desc: "The URL of the image file to be displayed as the bot's avatar." +setbanner: + desc: Sets a new banner image for the EllieBot. Parameter is a direct link to an image. Supports gifs. + ex: + - https://i.imgur.com/xTG3a1I.jpg + params: + - img: + desc: "The URL of the image file to be displayed as the bot's banner." setgame: - desc: "Sets the bots game status to either Playing, Listening, or Watching." - args: - - "Playing with snakes." - - "Watching anime." - - "Listening music." + desc: Sets the bots game status to either Playing, Listening, or Watching. + ex: + - Playing with snakes. + - Watching anime. + - Listening music. + params: + - type: + desc: "The activity type determines whether the bot is engaged in a game, listening to audio, or watching a video." + game: + desc: "The current state of the bot's activity in the game." send: - desc: "Sends a message to someone on a different server through the bot. Separate server and channel/user ids with `|` and prefix the channel id with `c:` and the user id with `u:`." - args: - - "serverid|c:channelid message" - - "serverid|u:userid message" + desc: "Sends a message to a channel or user. Channel or user can be " + ex: + - channel 123123123132312 Stop spamming commands plz + - user 1231231232132 I can see in the console what you're doing. + params: + - to: + desc: "The destination where the message will be sent, such as a specific channel or individual user." + id: + desc: "The identifier of the recipient, either a channel or a user." + text: + desc: "The recipient's preferred format for the message, such as plain text or formatted text with images and links." savechat: - desc: "Saves a number of messages to a text file and sends it to you." - args: - - "150" + desc: Saves a number of messages to a text file and sends it to you. + ex: + - 150 + params: + - cnt: + desc: "The number of messages to be saved." remind: desc: "Sends a message to you or a channel after certain amount of time (max 2 months). First parameter is `me`/`here`/'channelname'. Second parameter is time in a descending order (mo>w>d>h>m) example: 1w5d3h10m. Third parameter is a (multiword) message. Requires ManageMessages server permission if you're targeting a different channel." - args: - - "me 1d5h Do something" + ex: + - me 1d5h Do something - "#general 1m Start now!" + params: + - meorhere: + desc: "The enum value of 'me' if the user wants to be reminded, or 'here' if the user wants to be reminded in the current channel." + remindString: + desc: "The reminder duration and message to be sent. The message must start with a short time string in the form of 5d1h30m for example." + - channel: + desc: "The name of the channel to send the reminder to, or 'here' for the current channel." + remindString: + desc: "The reminder message to be sent." reminddelete: desc: "Deletes a reminder on the specified index. You can specify 'server' option if you're an Administrator, and you want to delete a reminder on this server created by someone else. " - args: - - "3" - - "server 2" + ex: + - 3 + - server 2 + params: + - _: + desc: "The server where the reminder was created or is stored." + index: + desc: "The index of the reminder to be deleted." + - index: + desc: "The index of the reminder to be deleted." remindlist: - desc: "Lists all reminders you created. You can specify 'server' option if you're an Administrator to list all reminders created on this server. Paginated." - args: - - "1" - - "server 2" + desc: Lists all reminders you created. You can specify 'server' option if you're an Administrator to list all reminders created on this server. Paginated. + ex: + - 1 + - server 2 + params: + - _: + desc: "The server where the reminders are stored or being retrieved from." + page: + desc: "The number of the page to display in the result set." + - page: + desc: "The number of the page to display in the result set." serverinfo: - desc: "Shows info about the server the bot is on. If no server is supplied, it defaults to current one." - args: - - "Some Server" + desc: Shows info about the server with the specified ID. The bot has to be on that server. If no server is supplied, it defaults to current one. + ex: + - 123123132233 + params: + - guildId: + desc: "The ID of a server for which to retrieve information." + - {} channelinfo: - desc: "Shows info about the channel. If no channel is supplied, it defaults to current one." - args: + desc: Shows info about the channel. If no channel is supplied, it defaults to current one. + ex: - "#some-channel" + params: + - channel: + desc: "The channel where the information will be retrieved from or displayed in." roleinfo: - desc: "Shows info about the specified role." - args: - - "Gamers" + desc: Shows info about the specified role. + ex: + - Gamers + params: + - role: + desc: "The type of user account associated with the role." userinfo: - desc: "Shows info about the user. If no user is supplied, it defaults a user running the command." - args: + desc: Shows info about the user. If no user is supplied, it defaults a user running the command. + ex: - "@SomeUser" + params: + - usr: + desc: "The guild user that the information is being retrieved for." whosplaying: - desc: "Shows a list of users who are playing the specified game." - args: - - "Overwatch" + desc: Shows a list of users who are playing the specified game. + ex: + - Overwatch + params: + - game: + desc: "The name of the game being played by the users." inrole: - desc: "Lists every person from the specified role on this server. You can specify a page before the role to jump to that page. Provide no role to list users who have no roles" - args: - - "RoleName" - - "5 RoleName" + desc: Lists every person from the specified role on this server. You can specify a page before the role to jump to that page. Provide no role to list users who have no roles + ex: + - RoleName + - 5 RoleName - "" + params: + - page: + desc: "The starting page number for the result set." + role: + desc: "The type of user or group being targeted for listing." + - role: + desc: "The type of user or group being targeted for listing." checkperms: - desc: "Checks yours or bot's user-specific permissions on this channel." - args: - - "me" - - "bot" + desc: Checks yours or bot's user-specific permissions on this channel. + ex: + - me + - bot + params: + - who: + desc: "The identity of the entity whose permissions are being checked." stats: - desc: "Shows some basic stats for Ellie." - args: + desc: Shows some basic stats for Ellie. + ex: - "" + params: + - {} userid: - desc: "Shows user ID." - args: + desc: Shows user ID. + ex: - "" - "@Someone" + params: + - target: + desc: "The guild the user is a member of." channelid: - desc: "Shows current channel ID." - args: + desc: Shows current channel ID. + ex: - "" + params: + - {} serverid: - desc: "Shows current server ID." - args: + desc: Shows current server ID. + ex: - "" + params: + - {} roles: - desc: "List roles on this server or roles of a user if specified. Paginated, 20 roles per page." - args: - - "2" + desc: List roles on this server or roles of a user if specified. Paginated, 20 roles per page. + ex: + - 2 - "@Someone" + params: + - target: + desc: "The guild or user for which to list the roles." + page: + desc: "The page number for the list of roles to be displayed." + - page: + desc: "The page number for the list of roles to be displayed." channeltopic: - desc: "Sends current channel's topic as a message." - args: + desc: Sends current channel's topic as a message. + ex: - "" + params: + - channel: + desc: "The channel where the topic is retrieved from." chnlfilterinv: - desc: "Toggles automatic deletion of invites posted in the channel. Does not negate the `{0}srvrfilterinv` enabled setting. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of invites posted in the channel. Does not negate the `{0}srvrfilterinv` enabled setting. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} srvrfilterinv: - desc: "Toggles automatic deletion of invites posted in the server. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of invites posted in the server. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} chnlfilterlin: - desc: "Toggles automatic deletion of links posted in the channel. Does not negate the `{0}srvrfilterlin` enabled setting. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of links posted in the channel. Does not negate the `{0}srvrfilterlin` enabled setting. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} srvrfilterlin: - desc: "Toggles automatic deletion of links posted in the server. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of links posted in the server. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} chnlfilterwords: - desc: "Toggles automatic deletion of messages containing filtered words on the channel. Does not negate the `{0}srvrfilterwords` enabled setting. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of messages containing filtered words on the channel. Does not negate the `{0}srvrfilterwords` enabled setting. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} filterword: - desc: "Adds or removes (if it exists) a word from the list of filtered words. Use`{0}sfw` or `{0}cfw` to toggle filtering." - args: - - "poop" + desc: Adds or removes (if it exists) a word from the list of filtered words. Use`{0}sfw` or `{0}cfw` to toggle filtering. + ex: + - poop + params: + - word: + desc: "The word to be added or removed from the list of filtered words." srvrfilterwords: - desc: "Toggles automatic deletion of messages containing filtered words on the server. Does not affect users with the Administrator permission." - args: + desc: Toggles automatic deletion of messages containing filtered words on the server. Does not affect users with the Administrator permission. + ex: - "" + params: + - {} lstfilterwords: - desc: "Shows a list of filtered words." - args: + desc: Shows a list of filtered words. + ex: - "" + params: + - page: + desc: "The current page number in the list of filtered words." permrole: - desc: "Sets a role which can change permissions. Supply no parameters to see the current one. Type 'reset' instead of the role name to reset the currently set permission role. Users with the Administrator server permissions can use permission commands regardless of whether they have the specified role. There is no default permission role." - args: - - "Some Role" - - "reset" + desc: Sets a role which can change permissions. Supply no parameters to see the current one. Type 'reset' instead of the role name to reset the currently set permission role. Users with the Administrator server permissions can use permission commands regardless of whether they have the specified role. There is no default permission role. + ex: + - Some Role + - reset + params: + - role: + desc: "The role that a user must have to change permissions." + - _: + desc: "The role that users must have to execute certain permission commands." verbose: - desc: "Toggles or sets whether to show when a command/module is blocked." - args: + desc: Toggles or sets whether to show when a command/module is blocked. + ex: - "" - - "true" + - true + params: + - action: + desc: "The permission required for the action to proceed." srvrmdl: - desc: "Sets a module's permission at the server level." - args: - - "ModuleName enable" + desc: Sets a module's permission at the server level. + ex: + - ModuleName enable + params: + - module: + desc: "The type of module or content repository information being set for server-level permissions." + action: + desc: "The type of permission action to perform, such as granting or revoking access." srvrcmd: - desc: "Sets a command's permission at the server level." - args: + desc: Sets a command's permission at the server level. + ex: - '"command name" disable' + params: + - command: + desc: "The type of command or expression being set, such as a specific SQL query or system function." + action: + desc: "The type of action to take on the permission, such as granting or revoking access." rolemdl: - desc: "Sets a module's permission at the role level." - args: - - "ModuleName enable MyRole" + desc: Sets a module's permission at the role level. + ex: + - ModuleName enable MyRole + params: + - module: + desc: "The type of content or resource being managed by the module." + action: + desc: "The type of action that can be performed by users with this role." + role: + desc: "The role that determines the permissions for the module." rolecmd: - desc: "Sets a command's permission at the role level." - args: + desc: Sets a command's permission at the role level. + ex: - '"command name" disable MyRole' + params: + - command: + desc: "The command or expression that is being set as a permission for a specific role." + action: + desc: "The type of action to take on the permission, such as granting or denying access." + role: + desc: "The role that determines who can use this command." chnlmdl: - desc: "Sets a module's permission at the channel level." - args: - - "ModuleName enable SomeChannel" + desc: Sets a module's permission at the channel level. + ex: + - ModuleName enable SomeChannel + params: + - module: + desc: "The type of entity being set as a permission for the channel." + action: + desc: "The type of permission action to take on the channel." + chnl: + desc: "The channel where the permission is being set for the module." chnlcmd: - desc: "Sets a command's permission at the channel level." - args: + desc: Sets a command's permission at the channel level. + ex: - '"command name" enable SomeChannel' + params: + - command: + desc: "The type of command or expression being set for the channel's permissions." + action: + desc: "The type of permission action to take on the channel." + chnl: + desc: "The channel where the command's permission is being set." usrmdl: - desc: "Sets a module's permission at the user level." - args: - - "ModuleName enable SomeUsername" + desc: Sets a module's permission at the user level. + ex: + - ModuleName enable SomeUsername + params: + - module: + desc: "The type of module or content reference information being set for the user's permissions." + action: + desc: "The type of permission action to take on the module, such as granting or revoking access." + user: + desc: "The user who owns the guild and is being granted or denied access to the module's features." usrcmd: - desc: "Sets a command's permission at the user level." - args: + desc: Sets a command's permission at the user level. + ex: - '"command name" enable SomeUsername' + params: + - command: + desc: "The type of command or expression being set for the specified user." + action: + desc: "The type of action to take on the permission, such as granting or revoking access." + user: + desc: "The user who owns the guild or has the specified role can execute the command." allsrvrmdls: - desc: "Enable or disable all modules for your server." - args: + desc: Enable or disable all modules for your server. + ex: - "[enable/disable]" + params: + - action: + desc: "The type of action to take on the enabled/disabled modules, such as enable or disable." allchnlmdls: - desc: "Enable or disable all modules in a specified channel." - args: + desc: Enable or disable all modules in a specified channel. + ex: - "enable #SomeChannel" + params: + - action: + desc: "The type of permission action to apply to the module, such as granting or revoking access." + chnl: + desc: "The channel where the operation is being performed on the modules." allrolemdls: - desc: "Enable or disable all modules for a specific role." - args: + desc: Enable or disable all modules for a specific role. + ex: - "[enable/disable] MyRole" + params: + - action: + desc: "The type of permission action to perform, such as granting or revoking access." + role: + desc: "The role that the operation is being performed on." userblacklist: desc: |- Either [add]s or [rem]oves a user or users specified by a Mention or an ID from a blacklist. Specify no argument or a page number to list blacklisted users. - args: - - "add @SomeUser @SomeUser2 @SomeUser3" - - "rem 12312312313" + ex: + - add @SomeUser @SomeUser2 @SomeUser3 + - rem 12312312313 - "" - - "4" + - 4 + params: + - page: + desc: "The page number for pagination of the listed blacklisted users." + - action: + desc: "The type of operation to perform on the user, either adding or removing them from the blacklist." + id: + desc: "The unique identifier of the user to be added, removed, or listed." + - action: + desc: "The type of operation to perform on the user, either adding or removing them from the blacklist." + usr: + desc: "The ID of the user to be added, removed, or listed." channelblacklist: desc: |- Either [add]s or [rem]oves a channel or channels specified an ID from a blacklist. Specify no argument or a page number to list blacklisted channels. - args: - - "add 12312312312 66666666666" - - "rem 12312312312" + ex: + - add 12312312312 66666666666 + - rem 12312312312 - "" - - "3" + - 3 + params: + - page: + desc: "The page number for pagination of the blacklisted channels list." + - action: + desc: "The type of operation to perform on the channel, either adding it to the blacklist or removing it from it." + id: + desc: "The unique identifier of the channel being added, removed, or listed." serverblacklist: desc: |- Either [add]s or [rem]oves a server, or servers specified by an ID from a blacklist. Specify no argument or a page number to list blacklisted servers. - args: - - "add 12312321312" - - "rem 12312321312" + ex: + - add 12312321312 + - rem 12312321312 - "" - - "2" + - 2 + params: + - page: + desc: "The page number for pagination of the blacklist listing." + - action: + desc: "The type of operation to perform on the server(s). It can be either adding or removing them from the blacklist." + id: + desc: "The unique identifier of the server being added, removed, or listed." + - action: + desc: "The type of operation to perform on the server(s). It can be either adding or removing them from the blacklist." + guild: + desc: "The guild for which the server blacklist is being managed." cmdcooldown: - desc: - "Sets a cooldown, in seconds, for a command or an expression which will be applied per user. - Set it to 0 to remove the cooldown. - Supports a special command `cleverbot:response` which can be used limit how often users can talk to cleverbot" - args: - - ".h 5" - - ".pat 30" + desc: Sets a cooldown, in seconds, for a command or an expression which will be applied per user. Set it to 0 to remove the cooldown. Supports a special command `cleverbot:response` which can be used limit how often users can talk to cleverbot + ex: + - .h 5 + - .pat 30 + params: + - command: + desc: "The response string from CleverBot, used to generate a conversation with the user." + secs: + desc: "The time, in seconds, after which the user is allowed to use the command again." + - command: + desc: "The command or expression that is being cooled down, allowing for control over when it can be executed again." + secs: + desc: "The time, in seconds, after which the user is allowed to use the command again." allcmdcooldowns: - desc: "Shows a list of all commands and their respective cooldowns." - args: + desc: Shows a list of all commands and their respective cooldowns. + ex: - "" + params: + - page: + desc: "The number of the page to display in the list of command cooldowns." quoteadd: - desc: "Adds a new quote with the specified name and message." - args: - - "sayhi Hi" + desc: Adds a new quote with the specified name and message. + ex: + - sayhi Hi + params: + - keyword: + desc: "The name of the quote used to retrieve the quote." + text: + desc: "The message of the quote." quoteprint: - desc: "Prints a random quote with a specified name." - args: - - "abc" + desc: Prints a random quote with a specified name. + ex: + - abc + params: + - keyword: + desc: "The author or origin of the quote being printed." quoteshow: - desc: "Shows information about a quote with the specified ID." - args: - - "123" + desc: Shows information about a quote with the specified ID. + ex: + - 123 + params: + - id: + desc: "The unique identifier for the quote being queried." quotesearch: desc: "Shows a random quote given a search query. Partially matches in several ways: 1) Only content of any quote, 2) only by author, 3) keyword and content, 3) or keyword and author" - args: + ex: - '"find this long text"' - - "AuthorName" - - "keyword some text" - - "keyword AuthorName" + - AuthorName + - keyword some text + - keyword AuthorName + params: + - textOrAuthor: + desc: "The search term to find a matching quote." + - keyword: + desc: "The search term to look for in the quote's content." + textOrAuthor: + desc: "The search term to find a matching quote." quoteid: - desc: "Displays the quote with the specified ID number. Quote ID numbers can be found by typing `{0}liqu [num]` where `[num]` is a number of a page which contains 15 quotes." - args: - - "123456" + desc: Displays the quote with the specified ID number. Quote ID numbers can be found by typing `{0}liqu [num]` where `[num]` is a number of a page which contains 15 quotes. + ex: + - 123456 + params: + - id: + desc: "The unique identifier for the quote to be displayed." quotedelete: - desc: "Deletes a quote with the specified ID. You have to either have the Manage Messages permission or be the creator of the quote to delete it." - args: - - "123456" + desc: Deletes a quote with the specified ID. You have to either have the Manage Messages permission or be the creator of the quote to delete it. + ex: + - 123456 + params: + - id: + desc: "The unique identifier for the quote being deleted." quotedeleteauthor: - desc: "Deletes all quotes by the specified author. If the author is not you, then ManageMessage server permission is required." - args: + desc: Deletes all quotes by the specified author. If the author is not you, then ManageMessage server permission is required. + ex: - "@QuoteSpammer" + params: + - user: + desc: "The user whose quotes are to be deleted." + - userId: + desc: "The ID of the user whose quotes are to be deleted." draw: - desc: "Draws a card from this server's deck. You can draw up to 10 cards by supplying a number of cards to draw." - args: + desc: Draws a card from this server's deck. You can draw up to 10 cards by supplying a number of cards to draw. + ex: - "" - - "5" + - 5 + params: + - num: + desc: "The number of cards to be drawn from the deck." drawnew: - desc: "Draws a card from the NEW deck of cards. You can draw up to 10 cards by supplying a number of cards to draw." - args: + desc: Draws a card from the NEW deck of cards. You can draw up to 10 cards by supplying a number of cards to draw. + ex: - "" - - "5" + - 5 + params: + - num: + desc: "The number of cards to be drawn from the new deck." playlistshuffle: - desc: "Shuffles the current playlist." - args: + desc: Shuffles the current playlist. + ex: - "" + params: + - {} flip: - desc: "Flips coin(s) - heads or tails, and shows an image." - args: + desc: Flips coin(s) - heads or tails, and shows an image. + ex: - "" - - "3" + - 3 + params: + - count: + desc: "The number of times the coin is flipped." betflip: - desc: "Bet to guess will the result be heads or tails. Guessing awards you 1.95x the currency you've bet (rounded up). Multiplier can be changed by the bot owner." - args: - - "5 heads" - - "3 t" + desc: Bet to guess will the result be heads or tails. Guessing awards you 1.95x the currency you've bet (rounded up). Multiplier can be changed by the bot owner. + ex: + - 5 heads + - 3 t + params: + - amount: + desc: "The amount of money to be wagered on the bet." + guess: + desc: "The user's prediction about whether the next coin flip will result in heads or tails." roll: - desc: "Rolls 0-100. If you supply a number `X` it rolls up to 30 normal dice. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`. `Y` can be a letter 'F' if you want to roll fate dice instead of dnd." - args: + desc: Rolls 0-100. If you supply a number `X` it rolls up to 30 normal dice. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`. `Y` can be a letter 'F' if you want to roll fate dice instead of dnd. + ex: - "" - - "7" - - "3d5" - - "5dF" + - 7 + - 3d5 + - 5dF + params: + - {} + - num: + desc: "The number of sides on the dice being rolled." + - arg: + desc: "The input string specifies the type of dice roll or the number of dice to roll, allowing users to customize their random outcome." rolluo: - desc: "Rolls `X` normal dice (up to 30) unordered. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`." - args: + desc: Rolls `X` normal dice (up to 30) unordered. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`. + ex: - "" - - "7" - - "3d5" + - 7 + - 3d5 + params: + - num: + desc: "The number of sides on the dice being rolled." + - arg: + desc: "The number of sides on the dice to be rolled." nroll: - desc: "Rolls in a given range. If you specify just one number instead of the range, it will roll from 0 to that number." - args: - - "5" - - "5-15" + desc: Rolls in a given range. If you specify just one number instead of the range, it will roll from 0 to that number. + ex: + - 5 + - 5-15 + params: + - range: + desc: "The minimum and maximum values for the random number generation process." race: - desc: "Starts a new animal race." - args: + desc: Starts a new animal race. + ex: - "" + params: + - params: + desc: "The list of commands or actions for the animals in the race." joinrace: - desc: "Joins a new race. You can specify an amount of currency for betting (optional). You will get YourBet*(participants-1) back if you win." - args: + desc: Joins a new race. You can specify an amount of currency for betting (optional). You will get YourBet*(participants-1) back if you win. + ex: - "" - - "5" + - 5 + params: + - amount: + desc: "The amount to be wagered on the race." nunchi: - desc: "Creates or joins an existing nunchi game. Users have to count up by 1 from the starting number shown by the bot. If someone makes a mistake (types an incorrect number, or repeats the same number) they are out of the game and a new round starts without them. Minimum 3 users required." - args: + desc: Creates or joins an existing nunchi game. Users have to count up by 1 from the starting number shown by the bot. If someone makes a mistake (types an incorrect number, or repeats the same number) they are out of the game and a new round starts without them. Minimum 3 users required. + ex: - "" + params: + - {} connect4: - desc: "Creates or joins an existing connect4 game. 2 players are required for the game. Objective of the game is to get 4 of your pieces next to each other in a vertical, horizontal or diagonal line. You can specify a bet when you create a game and only users who bet the same amount will be able to join your game." - args: + desc: Creates or joins an existing connect4 game. 2 players are required for the game. Objective of the game is to get 4 of your pieces next to each other in a vertical, horizontal or diagonal line. You can specify a bet when you create a game and only users who bet the same amount will be able to join your game. + ex: - "" + params: + - params: + desc: "The list of command-line arguments passed by the user to customize the game setup or behavior." raffle: - desc: "Prints a name and ID of a random online user from the server, or from the online user in the specified role." - args: + desc: Prints a name and ID of a random online user from the server, or from the online user in the specified role. + ex: - "" - - "RoleName" + - RoleName + params: + - role: + desc: "The role of the online user to be selected from." raffleany: - desc: "Prints a name and ID of a random user from the server, or from the specified role." - args: + desc: Prints a name and ID of a random user from the server, or from the specified role. + ex: - "" - " RoleName" + params: + - role: + desc: "The role that determines which users are eligible for selection." give: - desc: "Give someone a certain amount of currency. You can specify the reason after the mention." - args: - - "1 @Someone" - - "5 @CootGurl Ur so pwetty" + desc: Give someone a certain amount of currency. You can specify the reason after the mention. + ex: + - 1 @Someone + - 5 @CootGurl Ur so pwetty + params: + - amount: + desc: "The total value of the gift being bestowed." + receiver: + desc: "The user receiving the currency, such as a gift or reward." + msg: + desc: "The message explaining why you're giving them the currency." + - amount: + desc: "The total value of the gift being bestowed." + receiver: + desc: "The user receiving the currency, such as a gift or reward." award: - desc: "Awards someone a certain amount of currency. You can specify the reason after the Username. You can also specify a role name to award currency to all users in a role." - args: - - "100 @person" - - "5 Role Of Gamblers" + desc: Awards someone a certain amount of currency. You can specify the reason after the Username. You can also specify a role name to award currency to all users in a role. + ex: + - 100 @person + - 5 Role Of Gamblers + params: + - amount: + desc: "The amount of currency being awarded." + usr: + desc: "The user or guild member being awarded the currency." + msg: + desc: "The message describing why the user is being awarded the currency." + - amount: + desc: "The amount of currency being awarded." + usr: + desc: "The user or guild member being awarded the currency." + - amount: + desc: "The amount of currency being awarded." + usrId: + desc: "The ID of the user to whom the currency is being awarded." + msg: + desc: "The message describing why the user is being awarded the currency." + - amount: + desc: "The amount of currency being awarded." + role: + desc: "The role for which to award currency to all members." take: - desc: "Takes the specified amount of currency from someone. You can specify a role instead to take the specified amount of currency from all users in the role." - args: - - "1 @Someone" - - "50 SomeRole" + desc: Takes the specified amount of currency from someone. You can specify a role instead to take the specified amount of currency from all users in the role. + ex: + - 1 @Someone + - 50 SomeRole + params: + - amount: + desc: "The total value of the funds being withdrawn." + role: + desc: "The role of the person or group from whom the currency is being taken." + - amount: + desc: "The total value of the funds being withdrawn." + user: + desc: "The user or guild member being affected by the currency removal." + - amount: + desc: "The total value of the funds being withdrawn." + usrId: + desc: "The ID of the user whose funds are being taken." betroll: - desc: "Bets a certain amount of currency and rolls a dice. Rolling over 66 yields x2 of your currency, over 90 - x4 and 100 x10." - args: - - "5" + desc: Bets a certain amount of currency and rolls a dice. Rolling over 66 yields x2 of your currency, over 90 - x4 and 100 x10. + ex: + - 5 + params: + - amount: + desc: "The amount to be wagered on the roll of the dice." luckyladder: - desc: "Bets a certain amount of currency on the lucky ladder. You can stop on one of many different multipliers. Won amount is rounded down to the nearest whole number." - args: - - "10" + desc: Bets a certain amount of currency on the lucky ladder. You can stop on one of many different multipliers. Won amount is rounded down to the nearest whole number. + ex: + - 10 + params: + - amount: + desc: "The total value of the bet being placed." leaderboard: - desc: "Displays the bot's currency leaderboard." - args: + desc: Displays the bot's currency leaderboard. + ex: - "" + params: + - params: + desc: "The list of player names or IDs to display in the leaderboard." + - page: + desc: "The number of pages to display in the leaderboard." + params: + desc: "The list of player names or IDs to display in the leaderboard." trivia: - desc: "Starts a game of trivia. You can add `nohint` to prevent hints. First player to get to 10 points wins by default. You can specify a different number. 30 seconds per question." - args: + desc: Starts a game of trivia. You can add `nohint` to prevent hints. First player to get to 10 points wins by default. You can specify a different number. 30 seconds per question. + ex: - "" - - "--timeout 5 -p -w 3 -q 10" + - --timeout 5 -p -w 3 -q 10 + params: + - params: + desc: "The list of questions and answers for the trivia game." tl: - desc: "Shows a current trivia leaderboard." - args: + desc: Shows a current trivia leaderboard. + ex: - "" + params: + - {} tq: - desc: "Quits current trivia after current question." - args: + desc: Quits current trivia after current question. + ex: - "" + params: + - {} typestart: - desc: "Starts a typing contest." - args: + desc: Starts a typing contest. + ex: - "" + params: + - params: + desc: "The list of words or phrases for the contestants to type." typestop: - desc: "Stops a typing contest on the current channel." - args: + desc: Stops a typing contest on the current channel. + ex: - "" + params: + - {} typeadd: - desc: "Adds a new article to the typing contest." - args: - - "wordswords" -pollend: - desc: "Stops active poll on this server and prints the results in this channel." - args: - - "" + desc: Adds a new article to the typing contest. + ex: + - wordswords + params: + - text: + desc: "The title or name of the article being added." pick: - desc: "Picks the currency planted in this channel. If the plant has a password, you need to specify it." - args: + desc: Picks the currency planted in this channel. If the plant has a password, you need to specify it. + ex: - "" - - "passwd" + - passwd + params: + - pass: + desc: "The password required for accessing the plant's contents." plant: - desc: "Spend an amount of currency to plant it in this channel. Default is 1. You can specify the password after the amount. Password has to be alphanumeric and it will be trimmed down to 10 characters if it's longer." - args: - - "5" - - "10 meow" + desc: Spend an amount of currency to plant it in this channel. Default is 1. You can specify the password after the amount. Password has to be alphanumeric and it will be trimmed down to 10 characters if it's longer. + ex: + - 5 + - 10 meow + params: + - amount: + desc: "The number of units or quantity of something being planted." + pass: + desc: "The password for secure planting of the item in this channel." gencurrency: - desc: "Toggles currency generation on this channel. Every posted message will have chance to spawn currency. Chance is specified by the Bot Owner. (default is 2%)" - args: + desc: Toggles currency generation on this channel. Every posted message will have chance to spawn currency. Chance is specified by the Bot Owner. (default is 2%) + ex: - "" + params: + - {} gencurlist: - desc: "Shows the list of server and channel ids where gc is enabled. Paginated with 9 per page." - args: + desc: Shows the list of server and channel ids where gc is enabled. Paginated with 9 per page. + ex: - "" + params: + - page: + desc: "The current page number for pagination." choose: - desc: "Chooses a thing from a list of things" - args: - - "Get up;Sleep;Sleep more" + desc: Chooses a thing from a list of things + ex: + - Get up;Sleep;Sleep more + params: + - list: + desc: "The type of items in the collection being searched." rps: - desc: "Play a game of Rocket-Paperclip-Scissors with Ellie. You can bet on it. Multiplier is the same as on betflip." - args: - - "r 100" - - "scissors" -linux: - desc: "Prints a customizable Linux interjection" - args: - - "Spyware Windows" + desc: Play a game of Rocket-Paperclip-Scissors with Ellie. You can bet on it. Multiplier is the same as on betflip. + ex: + - r 100 + - scissors + params: + - pick: + desc: "The user's chosen move in the game, such as rock, paper or scissors." + amount: + desc: "The stake to be wagered on the outcome of the game." next: - desc: "Goes to the next song in the queue. You have to be in the same voice channel as the bot" - args: + desc: Goes to the next song in the queue. You have to be in the same voice channel as the bot + ex: - "" + params: + - {} play: - desc: "If no parameters are specified, acts as `{0}next 1` command. If you specify a song number, it will jump to that song. If you specify a search query, acts as a `{0}q` command" - args: + desc: If no parameters are specified, acts as `{0}next 1` command. If you specify a song number, it will jump to that song. If you specify a search query, acts as a `{0}q` command + ex: - "" - - "5" - - "Dream Of Venice" + - 5 + - Dream Of Venice + params: + - {} + - index: + desc: "The index of the desired song or search result to navigate to." + - query: + desc: "The search query is used to find and play songs matching the specified criteria." stop: - desc: "Stops the music and preserves the current song index. Stays in the channel." - args: + desc: Stops the music and preserves the current song index. Stays in the channel. + ex: - "" + params: + - {} destroy: - desc: "Completely stops the music and unbinds the bot from the channel. (may cause weird behaviour)" - args: + desc: Completely stops the music and unbinds the bot from the channel. (may cause weird behaviour) + ex: - "" + params: + - {} pause: - desc: "Pauses or Unpauses the song." - args: + desc: Pauses or Unpauses the song. + ex: - "" + params: + - {} queue: - desc: "Queue a song using keywords or a link. Bot will join your voice channel. **You must be in a voice channel**." - args: - - "Dream Of Venice" + desc: Queue a song using keywords or a link. Bot will join your voice channel. **You must be in a voice channel**. + ex: + - Dream Of Venice + params: + - query: + desc: "The search term used to identify the song to be queued." queuenext: - desc: "Works the same as `{0}queue` command, except it enqueues the new song after the current one. **You must be in a voice channel**." - args: - - "Dream Of Venice" + desc: Works the same as `{0}queue` command, except it enqueues the new song after the current one. **You must be in a voice channel**. + ex: + - Dream Of Venice + params: + - query: + desc: "The title or name of the next song to be played." queuesearch: - desc: "Search for top 5 youtube song result using keywords, and type the index of the song to play that song. Bot will join your voice channel. **You must be in a voice channel**." - args: - - "Dream Of Venice" -soundcloudqueue: - desc: "Queue a soundcloud song using keywords. Bot will join your voice channel. **You must be in a voice channel**." - args: - - "Dream Of Venice" + desc: Search for top 5 youtube song result using keywords, and type the index of the song to play that song. Bot will join your voice channel. **You must be in a voice channel**. + ex: + - Dream Of Venice + params: + - query: + desc: "The search query used to find relevant YouTube songs." listqueue: - desc: "Lists 10 currently queued songs per page. Default page is 1." - args: + desc: Lists 10 currently queued songs per page. Default page is 1. + ex: - "" - - "2" + - 2 + params: + - {} + - page: + desc: "The current page number for the song queue listing." nowplaying: - desc: "Shows the song that the bot is currently playing." - args: + desc: Shows the song that the bot is currently playing. + ex: - "" + params: + - {} volume: - desc: "Sets the music playback volume (0-100%). Persistent server setting. Default 100" - args: - - "50" + desc: Sets the music playback volume (0-100%). Persistent server setting. Default 100 + ex: + - 50 + params: + - vol: + desc: "The level at which the music is played back." playlist: - desc: "Queues up to 500 songs from a youtube playlist specified by a link, or keywords." - args: - - "" -soundcloudpl: - desc: "Queue a Soundcloud playlist using a link." - args: - - "https://soundcloud.com/classical-music-playlist/sets/classical-music-essential-collection" - - "" + desc: Queues up to 500 songs from a youtube playlist specified by a link, or keywords. + ex: + - + params: + - playlistQuery: + desc: "The search query used to find the YouTube playlist." localplaylist: - desc: "Queues all songs from a directory." - args: - - "C:/music/classical" + desc: Queues all songs from a directory. + ex: + - C:/music/classical + params: + - dirPath: + desc: "The path to the directory containing the songs to be queued." radio: desc: "Queues a radio stream from a link. It can be a direct mp3 radio stream, .m3u, .pls .asx or .xspf (Usage Video: )" - args: - - "radio link here" + ex: + - radio link here + params: + - radioLink: + desc: "The URL of the radio station to be played." local: - desc: "Queues a local file by specifying a full path." - args: - - "C:/music/mysong.mp3" + desc: Queues a local file by specifying a full path. + ex: + - C:/music/mysong.mp3 + params: + - path: + desc: "The directory or location where the file is stored." join: - desc: "Makes the bot join your voice channel." - args: + desc: Makes the bot join your voice channel. + ex: - "" + params: + - {} trackremove: desc: "Remove a song by its # in the queue, or 'all' (or provide no parameter) to remove all songs from the queue." - args: - - "5" - - "all" + ex: + - 5 + - all - "" + params: + - index: + desc: "The position of the song to be removed from the playlist." + - _: + desc: "The number of items to be removed from the list." trackmove: - desc: "Moves a song from one position to another." - args: - - "5 3" -setmaxqueue: - desc: "Sets a maximum queue size. Specify no parameters to have no limit." - args: - - "50" - - "" + desc: Moves a song from one position to another. + ex: + - 5 3 + params: + - from: + desc: "The starting position of the song in the playlist." + to: + desc: "The destination index in the playlist where the song should be moved." queuerepeat: - desc: "Sets music player repeat strategy for this server.\n- `n` / `no` - player will stop once it reaches the end of the queue\n- `s` / `song` - player will repeat current song\n- `q` / `queue` or empty - player will repeat entire music queue" - args: + desc: |- + Sets music player repeat strategy for this server. + - `n` / `no` - player will stop once it reaches the end of the queue + - `s` / `song` - player will repeat current song + - `q` / `queue` or empty - player will repeat entire music queue + ex: - "" - - "n" - - "song" + - n + - song + params: + - type: + desc: "The type of repeat strategy to be set." save: - desc: "Saves a playlist under a certain name. Playlist name must be no longer than 20 characters and must not contain dashes." - args: - - "classical1" + desc: Saves a playlist under a certain name. Playlist name must be no longer than 20 characters and must not contain dashes. + ex: + - classical1 + params: + - name: + desc: "The name provided is used to uniquely identify the saved playlist." streamrole: - desc: "Sets a role which is monitored for streamers (FromRole), and a role to add if a user from 'FromRole' is streaming (AddRole). When a user from 'FromRole' starts streaming, they will receive an 'AddRole'. You can only have 1 Stream Role per server. Provide no parameters to disable" - args: + desc: Sets a role which is monitored for streamers (FromRole), and a role to add if a user from 'FromRole' is streaming (AddRole). When a user from 'FromRole' starts streaming, they will receive an 'AddRole'. You can only have 1 Stream Role per server. Provide no parameters to disable + ex: - '"Eligible Streamers" "Featured Streams"' + params: + - fromRole: + desc: "The role of users being monitored for streamer status." + addRole: + desc: "The role to be added to users when they start streaming." + - {} load: - desc: "Loads a saved playlist using its ID. Use `{0}pls` to list all saved playlists and `{0}save` to save new ones." - args: - - "5" + desc: Loads a saved playlist using its ID. Use `{0}pls` to list all saved playlists and `{0}save` to save new ones. + ex: + - 5 + params: + - id: + desc: "The unique identifier of the playlist to be loaded." playlists: - desc: "Lists all playlists. Paginated, 20 per page." - args: - - "1" + desc: Lists all playlists. Paginated, 20 per page. + ex: + - 1 + params: + - num: + desc: "The number of pages to retrieve." playlistshow: - desc: "Lists all songs in a playlist specified by its id. Paginated, 20 per page." - args: - - "1" + desc: Lists all songs in a playlist specified by its id. Paginated, 20 per page. + ex: + - 1 + params: + - id: + desc: "The unique identifier for the playlist to retrieve songs from." + page: + desc: "The current page number for the pagination." deleteplaylist: - desc: "Deletes a saved playlist using its id. Works only if you made it or if you are the bot owner." - args: - - "5" + desc: Deletes a saved playlist using its id. Works only if you made it or if you are the bot owner. + ex: + - 5 + params: + - id: + desc: "The identifier for the playlist to be deleted." queueautoplay: - desc: "Toggles autoplay - When the song is finished, automatically queue a related Youtube song. (Works only for Youtube songs)" - args: + desc: Toggles autoplay - When the song is finished, automatically queue a related Youtube song. (Works only for Youtube songs) + ex: - "" + params: + - {} streamadd: - desc: "Notifies this channel when the stream on the specified URL goes online or offline. Offline notifications will only show if you enable `{0}streamoff`. Maximum 10 per server." - args: - - "twitch.tv/someguy" + desc: Notifies this channel when the stream on the specified URL goes online or offline. Offline notifications will only show if you enable `{0}streamoff`. Maximum 10 per server. + ex: + - twitch.tv/someguy + params: + - link: + desc: "The URL of a live streaming service that triggers the notification." streamsclear: - desc: "Removes all followed streams on this server." - args: + desc: Removes all followed streams on this server. + ex: - "" + params: + - {} streamremove: - desc: "Stops following the stream on the specified index. (use `{0}stl` to see indexes)" - args: - - "2" + desc: Stops following the stream on the specified index. (use `{0}stl` to see indexes) + ex: + - 2 + params: + - index: + desc: "The index at which to stop following the stream." streamlist: - desc: "Lists all streams you are following on this server and their respective indexes." - args: + desc: Lists all streams you are following on this server and their respective indexes. + ex: - "" + params: + - page: + desc: "The number of the page to retrieve from the list of followed streams." streamoffline: - desc: "Toggles whether the bot will also notify when added streams go offline." - args: + desc: Toggles whether the bot will also notify when added streams go offline. + ex: - "" + params: + - {} streamonlinedelete: - desc: "Toggles whether the bot will delete stream online message when the stream goes offline." - args: + desc: Toggles whether the bot will delete stream online message when the stream goes offline. + ex: - "" + params: + - {} streammessage: - desc: "Sets the message which will show when the stream on the specified index comes online. You can use %user% and %platform% placeholders." - args: - - "1 Hey @erryone %user% is back online on %platform%!1!!" + desc: Sets the message which will show when the stream on the specified index comes online. You can use %user% and %platform% placeholders. + ex: + - 1 Hey @erryone %user% is back online on %platform%!1!! + params: + - index: + desc: "The index of the stream to set the coming online message for." + message: + desc: "The text to be displayed in the stream's online status message." streammessageall: - desc: "Sets the message which will show when any of the currently followed streams comes online. This does not apply to the streams which get added afterwards. You can use %user% and %platform% placeholders." - args: - - "Hey @erryone %user% is back online!1!!" + desc: Sets the message which will show when any of the currently followed streams comes online. This does not apply to the streams which get added afterwards. You can use %user% and %platform% placeholders. + ex: + - Hey @erryone %user% is back online!1!! + params: + - message: + desc: "The text that appears in the notification when a streamer goes live." streamcheck: - desc: "Retrieves information about a stream." - args: - - "https://twitch.tv/somedude" + desc: Retrieves information about a stream. + ex: + - https://twitch.tv/somedude + params: + - url: + desc: "The URL of the stream being checked." convert: - desc: "Convert quantities. Use `{0}convertlist` to see supported dimensions and currencies." - args: - - "m km 1000" + desc: Convert quantities. Use `{0}convertlist` to see supported dimensions and currencies. + ex: + - m km 1000 + params: + - origin: + desc: "The location or source from which the quantity originates." + target: + desc: "The target unit or currency for the conversion." + value: + desc: "The value to be converted." convertlist: - desc: "List of the convertible dimensions and currencies." - args: + desc: List of the convertible dimensions and currencies. + ex: - "" + params: + - {} wowjoke: - desc: "Get one of penultimate WoW jokes." - args: + desc: Get one of penultimate WoW jokes. + ex: - "" + params: + - {} calculate: - desc: "Evaluate a mathematical expression." - args: - - "1+1" + desc: Evaluate a mathematical expression. + ex: + - 1+1 + params: + - expression: + desc: "The input string represents the mathematical formula to be evaluated." osu: - desc: "Shows osu! stats for a player." - args: - - "Name" - - "Name taiko" + desc: Shows osu! stats for a player. + ex: + - Name + - Name taiko + params: + - user: + desc: "The username of the player whose osu! stats are being retrieved." + mode: + desc: "The type of game mode to display statistics for, such as 'osu' or 'taiko'." gatari: - desc: "Shows osu!gatari stats for a player." - args: - - "Name" - - "Name ctb" + desc: Shows osu!gatari stats for a player. + ex: + - Name + - Name ctb + params: + - user: + desc: "The username of the player whose osu!gatari stats are being retrieved." + mode: + desc: "The type of game mode to display statistics for, such as 'osu' or 'taiko'." osu5: - desc: "Displays a user's top 5 plays." - args: - - "Name" + desc: Displays a user's top 5 plays. + ex: + - Name + params: + - user: + desc: "The username of the player whose top 5 plays are being displayed." + mode: + desc: 'The type of game mode to display the top 5 plays for, such as "osu", "taiko", or "ctb".' pokemon: - desc: "Searches for a pokemon." - args: - - "Sylveon" + desc: Searches for a pokemon. + ex: + - Sylveon + params: + - pokemon: + desc: "The name of the Pokémon to search for." pokemonability: - desc: "Searches for a pokemon ability." - args: - - "overgrow" + desc: Searches for a pokemon ability. + ex: + - overgrow + params: + - ability: + desc: "The type of the Pokémon's special power or trait that can be used in battle." memelist: - desc: "Shows a list of template keys (and their respective names) used for `{0}memegen`." - args: + desc: Shows a list of template keys (and their respective names) used for `{0}memegen`. + ex: - "" + params: + - page: + desc: "The number of pages in the list to be displayed." memegen: - desc: "Generates a meme from memelist with specified text. Separate multiple text values with semicolons. Provide no meme text to see an example meme with that template." - args: - - "biw gets iced coffee;in the winter" - - "ntot" + desc: Generates a meme from memelist with specified text. Separate multiple text values with semicolons. Provide no meme text to see an example meme with that template. + ex: + - biw gets iced coffee;in the winter + - ntot + params: + - meme: + desc: "The caption or punchline of the meme, which can be a single sentence or multiple sentences separated by semicolons." + memeText: + desc: "The user-provided text to be displayed on the generated meme." weather: - desc: "Shows weather data for a specified city. You can also specify a country after a comma." - args: - - "Moscow, RU" + desc: Shows weather data for a specified city. You can also specify a country after a comma. + ex: + - Moscow, RU + params: + - query: + desc: "The location to retrieve weather information for." youtube: - desc: "Searches youtubes and shows the first result" - args: - - "query" + desc: Searches youtubes and shows the first result + ex: + - query + params: + - query: + desc: "The search term or phrase to look for." anime: - desc: "Queries anilist for an anime and shows the first result." - args: - - "aquarion evol" + desc: Queries anilist for an anime and shows the first result. + ex: + - aquarion evol + params: + - query: + desc: "The search term used to find a specific anime on Anilist." steam: - desc: "Returns a store link for a steam game with the specified name. It doesn't work very well because bundles." - args: - - "Sakura Agent" + desc: Returns a store link for a steam game with the specified name. It doesn't work very well because bundles. + ex: + - Sakura Agent + params: + - query: + desc: "The name of the game to search for." movie: - desc: "Queries omdb for movies or series, show first result." - args: - - "Batman vs Superman" + desc: Queries omdb for movies or series, show first result. + ex: + - Batman vs Superman + params: + - query: + desc: "The title of the movie or TV series being searched for." manga: - desc: "Queries anilist for a manga and shows the first result." - args: - - "Shingeki no kyojin" + desc: Queries anilist for a manga and shows the first result. + ex: + - Shingeki no kyojin + params: + - query: + desc: "The title or keywords to search for in Anilist's database." randomcat: - desc: "Shows a random cat image." - args: + desc: Shows a random cat image. + ex: - "" + params: + - {} randomdog: - desc: "Shows a random dog image." - args: + desc: Shows a random dog image. + ex: - "" + params: + - {} randomfood: - desc: "Shows a random food image." - args: + desc: Shows a random food image. + ex: - "" + params: + - {} randombird: - desc: "Shows a random bird image." - args: + desc: Shows a random bird image. + ex: - "" + params: + - {} image: - desc: "Pulls a random image using a search parameter." - args: - - "cute kitten" + desc: Pulls a random image using a search parameter. + ex: + - cute kitten + params: + - query: + desc: "The search term used to retrieve the desired image." lmgtfy: - desc: "Google something for an idiot." - args: - - "query" + desc: Google something for an idiot. + ex: + - query + params: + - ffs: + desc: "The search query to be entered into the search engine." google: - desc: "Get a Google search link for some terms." - args: - - "query" -duckduckgo: - desc: "Get duckduckgo search results." - args: - - "cat pictures" + desc: Get a Google search link for some terms. + ex: + - query + params: + - query: + desc: "The search terms to look up on Google." hearthstone: - desc: "Searches for a Hearthstone card and shows its image. Takes a while to complete." - args: - - "Ysera" + desc: Searches for a Hearthstone card and shows its image. Takes a while to complete. + ex: + - Ysera + params: + - name: + desc: "The name of the Hearthstone card to search for." urbandict: - desc: "Searches Urban Dictionary for a word." - args: - - "Pineapple" + desc: Searches Urban Dictionary for a word. + ex: + - Pineapple + params: + - query: + desc: "The term being searched for in the dictionary." catfact: - desc: "Shows a random catfact from " - args: + desc: Shows a random catfact from + ex: - "" + params: + - {} yomama: - desc: "Shows a random joke from " - args: + desc: Shows a random joke from + ex: - "" + params: + - {} randjoke: - desc: "Shows a random joke." - args: + desc: Shows a random joke. + ex: - "" + params: + - {} chucknorris: - desc: "Shows a random Chuck Norris joke." - args: + desc: Shows a random Chuck Norris joke. + ex: - "" + params: + - {} magicitem: - desc: "Shows a random magic item from " - args: + desc: Shows a random magic item from + ex: - "" -revav: - desc: "Returns a Google reverse image search for someone's avatar." - args: - - "@Someone" -revimg: - desc: "Returns a Google reverse image search for an image from a link." - args: - - "Image link" + params: + - {} wiki: - desc: "Gives you back a wikipedia link" - args: - - "query" + desc: Gives you back a wikipedia link + ex: + - query + params: + - query: + desc: "The search term or phrase to look up on Wikipedia." color: - desc: "Shows you pictures of colors which correspond to the inputted hex values. Max 10." - args: - - "00ff00" - - "f00 0f0 00f" + desc: Shows you pictures of colors which correspond to the inputted hex values. Max 10. + ex: + - 00ff00 + - f00 0f0 00f + params: + - colors: + desc: "The array of colors to display as images." avatar: - desc: "Shows a mentioned person's avatar." - args: + desc: Shows a mentioned person's avatar. + ex: - "@Someone" -hentai: - desc: "Shows a hentai image from a random website (gelbooru, danbooru, konachan or yandere) with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags. Only 1 tag allowed." - args: - - "yuri" -autohentai: - desc: "Posts a hentai every X seconds with a random tag from the provided tags. Use `|` to separate tag groups. Random group will be chosen every time the image is sent. Max 2 tags per group. 20 seconds minimum. Provide no parameters to disable." - args: - - "30 yuri kissing|tail long_hair" - - "" -hentaibomb: - desc: "Shows a total 5 images (from gelbooru, danbooru, konachan and yandere). Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri" -yandere: - desc: "Shows a random image from yandere with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -danbooru: - desc: "Shows a random hentai image from danbooru with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -derpibooru: - desc: "Shows a random image from derpibooru with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -gelbooru: - desc: "Shows a random hentai image from gelbooru with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -sankaku: - desc: "Shows a random hentai image from chan.sankakucomplex.com with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kiss" -rule34: - desc: "Shows a random image from rule34.xx with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -e621: - desc: "Shows a random hentai image from e621.net with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -safebooru: - desc: "Shows a random image from safebooru with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri kissing" -boobs: - desc: "Real adult content." - args: - - "" -butts: - desc: "Real adult content." - args: - - "" + params: + - usr: + desc: "The user whose avatar is being displayed." translate: - desc: "Translates text from the given language to the destination language." - args: - - "en fr Hello" + desc: Translates text from the given language to the destination language. + ex: + - en fr Hello + params: + - fromLang: + desc: "The source language as 'english'" + toLang: + desc: "The target language such as 'english' or 'french'" + text: + desc: "The source text to be translated." translangs: - desc: "Lists the valid languages for translation." - args: + desc: Lists the valid languages for translation. + ex: - "" + params: + - {} guide: - desc: "Sends a readme and a guide links to the channel." - args: + desc: Sends a readme and a guide links to the channel. + ex: - "" + params: + - {} calcops: - desc: "Shows all available operations in the `{0}calc` command" - args: + desc: Shows all available operations in the `{0}calc` command + ex: - "" + params: + - {} delallquotes: - desc: "Deletes all quotes on a specified keyword." - args: - - "kek" + desc: Deletes all quotes on a specified keyword. + ex: + - kek + params: + - keyword: + desc: "The keyword to search for in the text." greetdmmsg: - desc: "Sets a new join announcement message which will be sent to the user who joined. Type `%user.mention%` if you want to mention the new member. Using it with no message will show the current DM greet message. You can use embed json from instead of a regular text, if you want the message to be embedded." - args: - - "Welcome to the server, %user.mention%" + desc: Sets a new join announcement message which will be sent to the user who joined. Type `%user.mention%` if you want to mention the new member. Using it with no message will show the current DM greet message. You can use embed json from instead of a regular text, if you want the message to be embedded. + ex: + - Welcome to the server, %user.mention% + params: + - text: + desc: "The new join announcement message that will be sent to the user who joined." cash: - desc: "Check how much currency a person has. (Defaults to yourself)" - args: + desc: Check how much currency a person has. If no argument is provided it will check your own balance. + ex: - "" - "@Someone" + params: + - userId: + desc: "Optional user ID of the account holder whose currency balance is being checked." + - user: + desc: "Optional ID of the person whose currency balance is being checked." currencytransactions: - desc: "Shows your currency transactions on the specified page. Bot owner can see other people's transactions too." - args: - - "2" + desc: Shows your currency transactions on the specified page. Bot owner can see other people's transactions too. + ex: + - 2 - "@SomeUser 2" + params: + - page: + desc: "The number of pages to display in the list of currency transactions." + - usr: + desc: "The user whose transactions are being displayed." + - usr: + desc: "The user whose transactions are being displayed." + page: + desc: "The number of pages to display in the list of currency transactions." currencytransaction: - desc: "Shows full details about a currency transaction with the specified ID. You can only check your own transactions." - args: - - "3yvd" + desc: Shows full details about a currency transaction with the specified ID. You can only check your own transactions. + ex: + - 3yvd + params: + - id: + desc: "The unique identifier for the transaction being queried." listperms: - desc: "Lists whole permission chain with their indexes. You can specify an optional page number if there are a lot of permissions." - args: + desc: Lists whole permission chain with their indexes. You can specify an optional page number if there are a lot of permissions. + ex: - "" - - "3" + - 3 + params: + - page: + desc: "The page number for pagination, allowing the retrieval of large permission chains in manageable chunks." allusrmdls: - desc: "Enable or disable all modules for a specific user." - args: - - "enable @Someone" + desc: Enable or disable all modules for a specific user. + ex: + - enable @Someone + params: + - action: + desc: "The type of permission action to take, such as granting or revoking access." + user: + desc: "The user account that the operation is being performed on." moveperm: - desc: "Moves permission from one position to another in the Permissions list." - args: - - "2 4" + desc: Moves permission from one position to another in the Permissions list. + ex: + - 2 4 + params: + - from: + desc: "The starting index of the permission to be moved." + to: + desc: "The index at which to insert or remove the permission." removeperm: - desc: "Removes a permission from a given position in the Permissions list." - args: - - "1" + desc: Removes a permission from a given position in the Permissions list. + ex: + - 1 + params: + - index: + desc: "The position at which to start removing permissions." showemojis: - desc: "Shows a name and a link to every SPECIAL emoji in the message." - args: - - "A message full of SPECIAL emojis" + desc: Shows a name and a link to every SPECIAL emoji in the message. + ex: + - A message full of SPECIAL emojis + params: + - _: + desc: "The text containing the emojis to be processed." emojiadd: desc: |- Adds the specified emoji to this server. @@ -1233,605 +2189,1112 @@ emojiadd: You can specify a name followed by an image link to add a new emoji from an image. You can omit imageUrl and instead upload the image as an attachment. Image size has to be below 256KB. - args: + ex: - ":someonesCustomEmoji:" - "MyEmojiName :someonesCustomEmoji:" - - "owoNice https://cdn.discordapp.com/emojis/587930873811173386.png?size=128" + - owoNice https://cdn.discordapp.com/emojis/587930873811173386.png?size=128 + params: + - name: + desc: "The name of the emoji or the file to be uploaded." + emote: + desc: "The type of emoji being added, such as a smiley face or a thumbs up." + - emote: + desc: "The type of emoji being added, such as a smiley face or a thumbs up." + - name: + desc: "The name of the emoji or the file to be uploaded." + url: + desc: "The URL of an image file to use as the emoji's representation." emojiremove: - desc: "Removes the specified emoji or emojis from this server." - args: + desc: Removes the specified emoji or emojis from this server. + ex: - ":eagleWarrior: :plumedArcher:" + params: + - emotes: + desc: "The list of emojis to be removed from the server." stickeradd: - desc: "Adds the sticker from your message to this server. Send the sticker along with this command (in the same message)." - args: + desc: Adds the sticker from your message to this server. Send the sticker along with this command (in the same message). + ex: - "" - - 'name "description" tag1 tag2 tagN' + - name "description" tag1 tag2 tagN + params: + - name: + desc: "The identifier for the sticker to be added." + description: + desc: "The text that describes the sticker being added." + tags: + desc: "The list of tags associated with the sticker being added." deckshuffle: - desc: "Reshuffles all cards back into the deck." - args: + desc: Reshuffles all cards back into the deck. + ex: - "" + params: + - {} forwardmessages: - desc: "Toggles forwarding of non-command messages sent to bot's DM to the bot owners" - args: + desc: Toggles forwarding of non-command messages sent to bot's DM to the bot owners + ex: - "" + params: + - {} forwardtoall: - desc: "Toggles whether messages will be forwarded to all bot owners or only to the first one specified in the creds.yml file" - args: + desc: Toggles whether messages will be forwarded to all bot owners or only to the first one specified in the creds.yml file + ex: - "" + params: + - {} forwardtochannel: - desc: "Toggles forwarding of non-command messages sent to bot's DM to the current channel" - args: + desc: Toggles forwarding of non-command messages sent to bot's DM to the current channel + ex: - "" + params: + - {} resetperms: - desc: "Resets the bot's permissions module on this server to the default value." - args: + desc: Resets the bot's permissions module on this server to the default value. + ex: - "" + params: + - {} antiraid: desc: "Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut" - args: - - "5 20 Kick" - - "7 9 Ban" - - "10 10 Ban 6h30m" + ex: + - 5 20 Kick + - 7 9 Ban + - 10 10 Ban 6h30m - "" + params: + - {} + - userThreshold: + desc: "The number of users that must join the server within a specified time interval to trigger the anti-raid protection." + seconds: + desc: "The time interval in which the specified number of people needs to join before triggering the protection." + action: + desc: "The punishment action specifies the consequence for users who trigger the anti-raid protection." + punishTime: + desc: "The time duration for which the punishment will be applied." + - userThreshold: + desc: "The number of users that must join the server within a specified time interval to trigger the anti-raid protection." + seconds: + desc: "The time interval in which the specified number of people needs to join before triggering the protection." + action: + desc: "The punishment action specifies the consequence for users who trigger the anti-raid protection." antispam: desc: "Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles, Warn, TimeOut" - args: - - "3 Mute" - - "5 Ban" - - "5 Ban 3h30m" + ex: + - 3 Mute + - 5 Ban + - 5 Ban 3h30m - "" + params: + - {} + - messageCount: + desc: "The maximum number of times a user can send the same message before being punished." + action: + desc: "The type of punishment to be applied to the offender." + role: + desc: "The role to apply the punishment to." + - messageCount: + desc: "The maximum number of times a user can send the same message before being punished." + action: + desc: "The type of punishment to be applied to the offender." + punishTime: + desc: "The time period for which the punishment should be enforced." + - messageCount: + desc: "The maximum number of times a user can send the same message before being punished." + action: + desc: "The type of punishment to be applied to the offender." antialt: - desc: "Applies a punishment action to any user whose account is younger than the specified threshold. Specify time after the punishment to have a timed punishment (not all punishments support timers)." - args: - - "1h Ban" - - "3d Mute 1h" + desc: Applies a punishment action to any user whose account is younger than the specified threshold. Specify time after the punishment to have a timed punishment (not all punishments support timers). + ex: + - 1h Ban + - 3d Mute 1h + params: + - {} + - minAge: + desc: "The minimum age of an account for which the punishment should be applied." + action: + desc: "The type of punishment to be applied to users with accounts younger than the specified threshold." + punishTime: + desc: "The amount of time for which the punishment should be applied." + - minAge: + desc: "The minimum age of an account for which the punishment should be applied." + action: + desc: "The type of punishment to be applied to users with accounts younger than the specified threshold." + role: + desc: "The role of the user being punished, used to determine the severity of the punishment." chatmute: - desc: "Prevents a mentioned user from chatting in text channels. You can also specify time string for how long the user should be muted. You can optionally specify a reason." - args: + desc: Prevents a mentioned user from chatting in text channels. You can also specify time string for how long the user should be muted. You can optionally specify a reason. + ex: - "@Someone" - "@Someone stop writing" - - "15m @Someone" - - "1h30m @Someone" - - "1h @Someone chill" + - 15m @Someone + - 1h30m @Someone + - 1h @Someone chill + params: + - user: + desc: "The user to mute, as specified by their ID or mention." + reason: + desc: "The optional reason provided is used to explain why the user was muted." + - time: + desc: "The duration of the mute period." + user: + desc: "The user to mute, as specified by their ID or mention." + reason: + desc: "The optional reason provided is used to explain why the user was muted." voicemute: - desc: "Prevents a mentioned user from speaking in voice channels. User has to be in a voice channel in order for the command to have an effect. You can also specify time string for how long the user should be muted. You can optionally specify a reason." - args: + desc: Prevents a mentioned user from speaking in voice channels. User has to be in a voice channel in order for the command to have an effect. You can also specify time string for how long the user should be muted. You can optionally specify a reason. + ex: - "@Someone" - "@Someone stop talking" - - "15m @Someone" - - "1h30m @Someone" - - "1h @Someone silence" -konachan: - desc: "Shows a random hentai image from konachan with a given tag. Tag(s) are optional but preferred. Maximum is usually 2 tags." - args: - - "yuri" + - 15m @Someone + - 1h30m @Someone + - 1h @Someone silence + params: + - user: + desc: "The user being targeted by this function, whose voice capabilities are intended to be restricted." + reason: + desc: "The optional reason provided is used to explain why the user was muted." + - time: + desc: "The duration of the silence imposed on the mentioned user." + user: + desc: "The user being targeted by this function, whose voice capabilities are intended to be restricted." + reason: + desc: "The optional reason provided is used to explain why the user was muted." muterole: - desc: "Sets a name of the role which will be assigned to people who should be muted. Provide no arguments to see currently set mute role. Default is ellie-mute." - args: + desc: Sets a name of the role which will be assigned to people who should be muted. Provide no arguments to see currently set mute role. Default is ellie-mute. + ex: - "" - - "Silenced" + - Silenced + params: + - role: + desc: "The role that determines whether users are muted or not." adsarm: - desc: "Toggles the automatic deletion of the user's message and Ellie's confirmations for `{0}iam` and `{0}iamn` commands." - args: + desc: Toggles the automatic deletion of the user's message and Ellie's confirmations for `{0}iam` and `{0}iamn` commands. + ex: - "" + params: + - {} setstream: - desc: "Sets the bots stream. First parameter is the twitch link, second parameter is stream name." - args: - - "TWITCHLINK Hello" + desc: Sets the bots stream. First parameter is the twitch link, second parameter is stream name. + ex: + - TWITCHLINK Hello + params: + - url: + desc: "The URL of the Twitch channel's live stream page." + name: + desc: "The name of the stream being set." chatunmute: - desc: "Removes a mute role previously set on a mentioned user with `{0}chatmute` which prevented him from chatting in text channels." - args: + desc: Removes a mute role previously set on a mentioned user with `{0}chatmute` which prevented him from chatting in text channels. + ex: - "@Someone" + params: + - user: + desc: "The user who was previously muted and is now being unmuted." + reason: + desc: "The reason for the mute being lifted." unmute: - desc: "Unmutes a mentioned user previously muted with `{0}mute` command." - args: + desc: Unmutes a mentioned user previously muted with `{0}mute` command. + ex: - "@Someone" + params: + - user: + desc: "The user who was previously muted and is now being un-muted." + reason: + desc: "The reason for the mute being lifted." xkcd: - desc: 'Shows a XKCD comic. Specify no parameters to retrieve a random one. Number parameter will retrieve a specific comic, and "latest" will get the latest one.' - args: + desc: Shows a XKCD comic. Specify no parameters to retrieve a random one. Number parameter will retrieve a specific comic, and "latest" will get the latest one. + ex: - "" - - "1400" - - "latest" -placelist: - desc: "Shows the list of available tags for the `{0}place` command." - args: - - "" -place: - desc: "Shows a placeholder image of a given tag. Use `{0}placelist` to see all available tags. You can specify the width and height of the image as the last two optional parameters." - args: - - "Cage" - - "steven 500 400" -poll: - desc: "Creates a public poll which requires users to type a number of the voting option in the channel command is ran in." - args: - - "Question?;Answer1;Answ 2;A_3" + - 1400 + - latest + params: + - arg: + desc: 'The URL of the desired comic or "latest" to retrieve the most recent one.' + - num: + desc: "The number of the comic to be retrieved." autotranslang: - desc: "Sets your source and target language to be used with `{0}at`. Specify no parameters to remove previously set value." - args: - - "en fr" + desc: Sets your source and target language to be used with `{0}at`. Specify no parameters to remove previously set value. + ex: + - en fr + params: + - {} + - fromLang: + desc: + toLang: + desc: 'The destination language code, such as "en" for English or "fr" for French.' autotranslate: - desc: 'Starts automatic translation of all messages by users who set their `{0}atl` in this channel. You can set "del" parameter to automatically delete all translated user messages.' - args: + desc: Starts automatic translation of all messages by users who set their `{0}atl` in this channel. You can set "del" parameter to automatically delete all translated user messages. + ex: - "" - - "del" + - del + params: + - autoDelete: + desc: "The option to automatically remove translated messages from the chat." listquotes: - desc: "Lists all quotes on the server ordered alphabetically or by ID. 15 Per page." - args: - - "3" - - "3 id" + desc: Lists all quotes on the server ordered alphabetically or by ID. 15 Per page. + ex: + - 3 + - 3 id + params: + - order: + desc: "The order parameter determines how the quotes are sorted and displayed, allowing users to choose between alphabetical or ID-based ordering." + - page: + desc: "The current page number for pagination." + order: + desc: "The order parameter determines how the quotes are sorted and displayed, allowing users to choose between alphabetical or ID-based ordering." typedel: - desc: "Deletes a typing article given the ID." - args: - - "3" + desc: Deletes a typing article given the ID. + ex: + - 3 + params: + - index: + desc: "The identifier for the typing article to be deleted." typelist: - desc: "Lists added typing articles with their IDs. 15 per page." - args: + desc: Lists added typing articles with their IDs. 15 per page. + ex: - "" - - "3" + - 3 + params: + - page: + desc: "The current page number for the list of typing articles." listservers: - desc: "Lists servers the bot is on with some basic info. 15 per page." - args: - - "3" + desc: Lists servers the bot is on with some basic info. 15 per page. + ex: + - 3 + params: + - page: + desc: "The number of pages to retrieve from the server list." cleverbot: - desc: "Toggles cleverbot/chatgpt session. When enabled, the bot will reply to messages starting with bot mention in the server. Expressions starting with %bot.mention% won't work if cleverbot/chatgpt is enabled." - args: + desc: Toggles cleverbot/chatgpt session. When enabled, the bot will reply to messages starting with bot mention in the server. Expressions starting with %bot.mention% won't work if cleverbot/chatgpt is enabled. + ex: - "" + params: + - {} shorten: - desc: "Attempts to shorten an URL, if it fails, returns the input URL." - args: - - "https://google.com" + desc: Attempts to shorten an URL, if it fails, returns the input URL. + ex: + - https://google.com + params: + - query: + desc: "The search term or identifier used to retrieve a shortened version of the URL from a database or service." wikia: - desc: "Gives you back a fandom link" - args: - - "mtg Vigilance" - - "mlp Dashy" + desc: Gives you back a fandom link + ex: + - mtg Vigilance + - mlp Dashy + params: + - target: + desc: "The URL or title of the fandom being linked." + query: + desc: "The search term or phrase to look for on the wiki." magicthegathering: - desc: "Searches for a Magic The Gathering card." - args: - - "about face" + desc: Searches for a Magic The Gathering card. + ex: + - about face + params: + - search: + desc: "The query string used to search for the desired card." hangmanlist: - desc: "Shows a list of hangman question categories." - args: + desc: Shows a list of hangman question categories. + ex: - "" + params: + - {} hangman: - desc: "Starts a game of hangman in the channel. You can optionally select a category `{0}hangmanlist` to see a list of available categories." - args: + desc: Starts a game of hangman in the channel. You can optionally select a category `{0}hangmanlist` to see a list of available categories. + ex: - "" - - "movies" + - movies + params: + - type: + desc: "The language or theme of the word to be guessed." hangmanstop: - desc: "Stops the active hangman game on this channel if it exists." - args: + desc: Stops the active hangman game on this channel if it exists. + ex: - "" + params: + - {} acrophobia: - desc: "Starts an Acrophobia game." - args: + desc: Starts an Acrophobia game. + ex: - "" - - "-s 30" + - -s 30 + params: + - params: + desc: "The list of command-line arguments passed to the game, which may include options or settings for customizing the gameplay experience." logevents: - desc: "Shows a list of all events you can subscribe to with `{0}log`" - args: + desc: Shows a list of all events you can subscribe to with `{0}log` + ex: - "" + params: + - {} log: - desc: "Toggles logging event. Disables it if it is active anywhere on the server. Enables if it isn't active. Use `{0}logevents` to see a list of all events you can subscribe to." - args: - - "userpresence" - - "userbanned" -fairplay: - desc: "Toggles fairplay. While enabled, the bot will prioritize songs from users who didn't have their song recently played instead of the song's position in the queue." - args: + desc: Toggles logging event. Disables it if it is active anywhere on the server. Enables if it isn't active. Use `{0}logevents` to see a list of all events you can subscribe to. + ex: + - userpresence + - userbanned + params: + - type: + desc: "The type of log event to toggle." +queuefairplay: + desc: Triggers fairplay. The song queue will be re-ordered in a fair manner. No effect on newly added songs. + ex: - "" + params: + - {} define: - desc: "Finds a definition of a word." - args: - - "heresy" -setmaxplaytime: - desc: "Sets a maximum number of seconds (>14) a song can run before being skipped automatically. Set 0 to have no limit." - args: - - "0" - - "270" + desc: Finds a definition of a word. + ex: + - heresy + params: + - word: + desc: "The word being searched for." activity: - desc: "Checks for spammers." - args: + desc: Checks for spammers. + ex: - "" + params: + - page: + desc: "The number of pages to scan for spam." setstatus: - desc: "Sets the bot's status. (Online/Idle/Dnd/Invisible)" - args: - - "Idle" + desc: Sets the bot's status. (Online/Idle/Dnd/Invisible) + ex: + - Idle + params: + - status: + desc: "The current state or mode of the bot's presence in a chat." invitecreate: - desc: "Creates a new invite which has infinite max uses and never expires." - args: + desc: Creates a new invite which has infinite max uses and never expires. + ex: - "" + params: + - params: + desc: "The recipient's email addresses or usernames." invitelist: - desc: "Lists all invites for this channel. Paginated with 9 per page." - args: + desc: Lists all invites for this channel. Paginated with 9 per page. + ex: - "" - - "3" + - 3 + params: + - page: + desc: "The page number to retrieve, starting from 1." + ch: + desc: "The channel in which the invite list is being retrieved." invitedelete: - desc: "Deletes an invite on the specified index. Use `{0}invitelist` to see the list of invites." - args: - - "2" -pollstats: - desc: "Shows the poll results without stopping the poll on this server." - args: - - "" + desc: Deletes an invite on the specified index. Use `{0}invitelist` to see the list of invites. + ex: + - 2 + params: + - index: + desc: "The index at which the invite is located in the invite list." antilist: - desc: "Shows currently enabled protection features." - args: + desc: Shows currently enabled protection features. + ex: - "" + params: + - {} antispamignore: - desc: "Toggles whether antispam ignores current channel. Antispam must be enabled." - args: + desc: Toggles whether antispam ignores current channel. Antispam must be enabled. + ex: - "" + params: + - {} eventstart: desc: "Starts one of the events seen on public ellie. Events: `reaction`, `gamestatus`" - args: - - "reaction" - - "reaction -d 1 -a 50 --pot-size 1500" + ex: + - reaction + - reaction -d 1 -a 50 --pot-size 1500 + params: + - ev: + desc: "The type of event being started." + options: + desc: "The types of events that can be started." betstats: - desc: "Shows the total stats of several gambling features. Updates once an hour." - args: + desc: Shows the total stats of several gambling features. Updates once an hour. + ex: - "" -slottest: - desc: "Tests to see how much slots payout for X number of plays." - args: - - "1000" + params: + - {} slot: - desc: "Play Ellie slots. 1 second cooldown per user." - args: - - "5" + desc: Play Ellie slots. 1 second cooldown per user. + ex: + - 5 + params: + - amount: + desc: "The number of spins to perform in the game." affinity: - desc: "Sets your affinity towards someone you want to be claimed by. Setting affinity will reduce their `{0}claim` on you by 20%. Provide no parameters to clear your affinity. 30 minutes cooldown." - args: + desc: Sets your affinity towards someone you want to be claimed by. Setting affinity will reduce their `{0}claim` on you by 20%. Provide no parameters to clear your affinity. 30 minutes cooldown. + ex: - "@MyHusband" - "" + params: + - user: + desc: "The user being targeted for a potential claim." waifuclaim: - desc: "Claim a waifu for yourself by spending currency. You must spend at least 10% more than her current value unless she set `{0}affinity` towards you." - args: - - "50 @Himesama" + desc: Claim a waifu for yourself by spending currency. You must spend at least 10% more than her current value unless she set `{0}affinity` towards you. + ex: + - 50 @Himesama + params: + - amount: + desc: "The cost of claiming the waifu." + target: + desc: "The user to whom the claim is being made, allowing the waifu to be claimed from their collection." waifureset: - desc: "Resets your waifu stats, except current waifus." - args: + desc: Resets your waifu stats, except current waifus. + ex: - "" + params: + - {} waifutransfer: - desc: "Transfer the ownership of one of your waifus to another user. You must pay 10% of your waifu's value unless that waifu has affinity towards you, in which case you must pay 60% fee. Transferred waifu's price will be reduced by the fee amount." - args: + desc: Transfer the ownership of one of your waifus to another user. You must pay 10% of your waifu's value unless that waifu has affinity towards you, in which case you must pay 60% fee. Transferred waifu's price will be reduced by the fee amount. + ex: - "@ExWaifu @NewOwner" + params: + - waifuId: + desc: "The ID of the waifu being transferred to a new owner." + newOwner: + desc: "The user to whom ownership of the waifu is being transferred." + - waifu: + desc: "The user to whom the ownership of the waifu is being transferred." + newOwner: + desc: "The user to whom ownership of the waifu is being transferred." waifugift: - desc: -| - Gift an item to someone. This will increase their waifu value by a percentage of the gift's value. - Negative gifts will not show up in waifuinfo. - Provide no parameters to see a list of items that you can gift. - args: + desc: -| Gift an item to someone. This will increase their waifu value by a percentage of the gift's value. Negative gifts will not show up in waifuinfo. Provide no parameters to see a list of items that you can gift. + ex: - "" - - "Rose @Himesama" + - Rose @Himesama + params: + - page: + desc: "The number of pages to display when listing available gifting options." + - itemName: + desc: "The name of an item to be gifted, which is used to determine the percentage increase in waifu value." + waifu: + desc: "The user who is receiving the gift." waifulb: - desc: "Shows top 9 waifus. You can specify another page to show other waifus." - args: + desc: Shows top 9 waifus. You can specify another page to show other waifus. + ex: - "" - - "3" + - 3 + params: + - page: + desc: "The number of the page to display." divorce: - desc: "Releases your claim on a specific waifu. You will get 50% of that waifu's value back, unless that waifu has an affinity towards you, in which case they will be reimbursed instead. 6 hours cooldown." - args: + desc: Releases your claim on a specific waifu. You will get 50% of that waifu's value back, unless that waifu has an affinity towards you, in which case they will be reimbursed instead. 6 hours cooldown. + ex: - "@CheatingSloot" + params: + - target: + desc: "The ID or name of the waifu being released from your claim." + - target: + desc: "The user to release the claim on." + - targetId: + desc: "The ID of the waifu to release your claim on." waifuinfo: - desc: "Shows waifu stats for a target person. Defaults to you if no user is provided." - args: + desc: Shows waifu stats for a target person. Defaults to you if no user is provided. + ex: - "@MyCrush" - "" + params: + - target: + desc: "The user being targeted, whose waifu information will be displayed." + - targetId: + desc: "The ID of the person whose waifu stats are being displayed." mal: - desc: "Shows basic info from a MyAnimeList profile." - args: - - "straysocks" + desc: Shows basic info from a MyAnimeList profile. + ex: + - straysocks + params: + - name: + desc: "The username or identifier for the MyAnimeList account being queried." + - usr: + desc: "The user's guild membership information is used to fetch their anime list and other relevant data." setmusicchannel: - desc: "Sets the current channel as the default music output channel. This will output playing, finished, paused and removed songs to that channel instead of the channel where the first song was queued in. Persistent server setting." - args: + desc: Sets the current channel as the default music output channel. This will output playing, finished, paused and removed songs to that channel instead of the channel where the first song was queued in. Persistent server setting. + ex: - "" + params: + - {} unsetmusicchannel: - desc: "Bot will output playing, finished, paused and removed songs to the channel where the first song was queued in. Persistent server setting." - args: + desc: Bot will output playing, finished, paused and removed songs to the channel where the first song was queued in. Persistent server setting. + ex: - "" + params: + - {} musicquality: desc: "Gets or sets the default music player quality. Available settings: Highest, High, Medium, Low. Default is **Highest**. Provide no argument to see current setting." - args: + ex: - "" - - "High" - - "Low" + - High + - Low + params: + - {} + - preset: + desc: "The selected preset determines the level of audio compression and processing applied to the music playback." stringsreload: - desc: "Reloads localized bot strings." - args: + desc: Reloads localized bot strings. + ex: - "" + params: + - {} shardstats: desc: |- Stats for shards. Paginated with 25 shards per page. Format: `[status] | # [shard_id] | [last_heartbeat] | [server_count]` - args: + ex: - "" - - "2" + - 2 + params: + - page: + desc: "The number of pages to retrieve, with each page containing 25 shards." restartshard: - desc: "Try (re)connecting a shard with a certain shardid when it dies. No one knows will it work. Keep an eye on the console for errors." - args: - - "2" + desc: Try (re)connecting a shard with a certain shardid when it dies. No one knows will it work. Keep an eye on the console for errors. + ex: + - 2 + params: + - shardId: + desc: "The ID of the shard to be restarted or reconnected." tictactoe: - desc: "Starts a game of tic tac toe. Another user must run the command in the same channel in order to accept the challenge. Use numbers 1-9 to play." - args: + desc: Starts a game of tic tac toe. Another user must run the command in the same channel in order to accept the challenge. Use numbers 1-9 to play. + ex: - "" + params: + - params: + desc: "The coordinates for placing an X or O on the board." timezones: - desc: "Lists all timezones available on the system to be used with `{0}timezone`." - args: + desc: Lists all timezones available on the system to be used with `{0}timezone`. + ex: - "" + params: + - page: + desc: "The number of pages to retrieve from the list of available timezones." timezone: - desc: "Sets this guilds timezone. This affects bot's time output in this server (logs, etc..) **Setting timezone requires Administrator server permission.**" - args: + desc: Sets this guilds timezone. This affects bot's time output in this server (logs, etc..) **Setting timezone requires Administrator server permission.** + ex: - "" - - "GMT Standard Time" + - GMT Standard Time + params: + - {} + - id: + desc: "The identifier for a specific timezone region." languagesetdefault: - desc: "Sets the bot's default response language. All servers which use a default locale will use this one. Setting to `default` will use the host's current culture. Provide no parameters to see currently set language." - args: - - "en-US" - - "default" + desc: Sets the bot's default response language. All servers which use a default locale will use this one. Setting to `default` will use the host's current culture. Provide no parameters to see currently set language. + ex: + - en-US + - default + params: + - {} + - name: + desc: "The code page or character encoding for the target audience." languageset: - desc: "Sets this server's response language. If bot's response strings have been translated to that language, bot will use that language in this server. Reset by using `default` as the locale name. Provide no parameters to see currently set language." - args: + desc: Sets this server's response language. If bot's response strings have been translated to that language, bot will use that language in this server. Reset by using `default` as the locale name. Provide no parameters to see currently set language. + ex: - "de-DE " - - "default" + - default + params: + - {} + - name: + desc: "The locale name for which the response language should be set." languageslist: - desc: "List of languages for which translation (or part of it) exist atm." - args: + desc: List of languages for which translation (or part of it) exist atm. + ex: - "" -rategirl: - desc: "Use the universal hot-crazy wife zone matrix to determine the girl's worth. It is everything young men need to know about women. At any moment in time, any woman you have previously located on this chart can vanish from that location and appear anywhere else on the chart." - args: - - "@SomeGurl" + params: + - {} exprtoggleglobal: - desc: "Toggles whether global expressions are usable on this server." - args: + desc: Toggles whether global expressions are usable on this server. + ex: - "" + params: + - {} exprreact: - desc: "Sets or resets reactions (up to 3) which will be added to the response message of the Expression with the specified ID. Provide no emojis to reset." - args: - - "59 \U0001F44D \U0001F44E " - - "59 " - - "59" + desc: Sets or resets reactions (up to 3) which will be added to the response message of the Expression with the specified ID. Provide no emojis to reset. + ex: + - "59 👍 👎 " + - 59 + - 59 + params: + - id: + desc: "The ID of the expression." + emojiStrs: + desc: "The set of emojis that can be used to react to the expression." exprad: - desc: "Toggles whether the message triggering the expression will be automatically deleted." - args: - - "59" + desc: Toggles whether the message triggering the expression will be automatically deleted. + ex: + - 59 + params: + - id: + desc: "The ID of a message that should be affected by this toggle." exprat: - desc: "Toggles whether the expression will allow extra input after the trigger. For example, with this feature enabled, expression with trigger 'hi' will also be invoked when a user types 'hi there'. This feature is automatically enabled on expressions which have '%target%' in their response." - args: - - "59" + desc: Toggles whether the expression will allow extra input after the trigger. For example, with this feature enabled, expression with trigger 'hi' will also be invoked when a user types 'hi there'. This feature is automatically enabled on expressions which have '%target%' in their response. + ex: + - 59 + params: + - id: + desc: "The ID of the expression to toggle this feature for. It determines which expression's behavior will be modified by this setting." exprdm: - desc: "Toggles whether the response message of the expression will be sent as a direct message." - args: - - "44" + desc: Toggles whether the response message of the expression will be sent as a direct message. + ex: + - 44 + params: + - id: + desc: "The ID of the user who should receive the direct message." exprca: - desc: "Toggles whether the expression will trigger if the triggering message contains the keyword (instead of only starting with it)." - args: - - "44" + desc: Toggles whether the expression will trigger if the triggering message contains the keyword (instead of only starting with it). + ex: + - 44 + params: + - id: + desc: "The identifier of a specific keyword to be matched." exprsreload: - desc: "Reloads all expressions on all shards. Use this if you've made changes to the database while the bot is running, or used `{0}deleteunusedcrnq`" - args: + desc: Reloads all expressions on all shards. Use this if you've made changes to the database while the bot is running, or used `{0}deleteunusedcrnq` + ex: - "" + params: + - {} exprsimport: - desc: "Upload the file or send the raw .yml data with this command to import all expressions from the specified string or file into the current server (or as global expressions in dm)" - args: - - "" + desc: Upload the file or send the raw .yml data with this command to import all expressions from the specified string or file into the current server (or as global expressions in dm) + ex: + - + params: + - input: + desc: "The path to a file containing YAML data or a raw YAML string to be imported." exprsexport: - desc: "Exports expressions from the current server (or global expressions in DMs) into a .yml file" - args: + desc: Exports expressions from the current server (or global expressions in DMs) into a .yml file + ex: - "" + params: + - {} quotesimport: - desc: "Upload the file or send the raw .yml data with this command to import all quotes from the specified string or file into the current server." - args: - - "" + desc: Upload the file or send the raw .yml data with this command to import all quotes from the specified string or file into the current server. + ex: + - + params: + - input: + desc: "The path to a file containing the YAML data to be imported." quotesexport: - desc: "Exports quotes from the current server into a .yml file" - args: + desc: Exports quotes from the current server into a .yml file + ex: - "" + params: + - {} aliaslist: - desc: "Shows the list of currently set aliases. Paginated." - args: + desc: Shows the list of currently set aliases. Paginated. + ex: - "" - - "3" + - 3 + params: + - page: + desc: "The number of pages to display in the result set." alias: - desc: "Create a custom alias for a certain Ellie command. Provide no alias to remove the existing one." - args: - - "allin {0}bf all h" - - '"linux thingy" >loonix Spyware Windows' + desc: Create a custom alias for a certain Ellie command. Provide no alias to remove the existing one. + ex: + - allin {0}bf all h + params: + - trigger: + desc: "The trigger string that will activate this custom alias." + mapping: + desc: "The name or nickname that will be used as an alternative to invoke the original command." warnlog: - desc: "See a list of warnings of a certain user." - args: + desc: See a list of warnings of a certain user. + ex: - "@Someone" + params: + - page: + desc: "The number of pages to display in the warning log." + user: + desc: "The guild member whose warning log is being retrieved." + - user: + desc: "The guild member whose warning log is being retrieved." + - page: + desc: "The number of pages to display in the warning log." + userId: + desc: "The ID of the user whose warning log is being retrieved." + - userId: + desc: "The ID of the user whose warning log is being retrieved." warnlogall: - desc: "See a list of all warnings on the server. 15 users per page." - args: + desc: See a list of all warnings on the server. 15 users per page. + ex: - "" - - "2" + - 2 + params: + - page: + desc: "The current page number for displaying the warning log." warn: desc: |- Warns a user with an optional reason. You can specify a warning weight integer before the user. For example, 3 would mean that this warning counts as 3 warnings. - args: + ex: - "@Someone Very rude person" - - "3 @Someone Very rude person" + - 3 @Someone Very rude person + params: + - user: + desc: "The user to be warned about the issue or situation." + reason: + desc: "The reason for the warning." + - weight: + desc: "The level of severity for the warning." + user: + desc: "The user to be warned about the issue or situation." + reason: + desc: "The reason for the warning." startupcommandadd: - desc: "Adds a command to the list of commands which will be executed automatically in the current channel, in the order they were added in, by the bot when it startups up." - args: + desc: Adds a command to the list of commands which will be executed automatically in the current channel, in the order they were added in, by the bot when it startups up. + ex: - "{0}stats" + params: + - cmdText: + desc: "The text of the command that should be recognized and executed when a user types it." autocommandadd: - desc: "Adds a command to the list of commands which will be executed automatically every X seconds." - args: - - "60 {0}prune 1000" + desc: Adds a command to the list of commands which will be executed automatically every X seconds. + ex: + - 60 {0}prune 1000 + params: + - interval: + desc: "The time period between consecutive executions of the added command." + cmdText: + desc: "The text of the command to be executed when triggered." startupcommandremove: - desc: "Removes a startup command on the specified index." - args: - - "3" + desc: Removes a startup command on the specified index. + ex: + - 3 + params: + - index: + desc: "The index at which to remove the startup command from the list." autocommandremove: - desc: "Removes an auto command on the specified index." - args: - - "3" + desc: Removes an auto command on the specified index. + ex: + - 3 + params: + - index: + desc: "The index at which the auto command is located in the list of commands." startupcommandsclear: - desc: "Removes all startup commands." - args: + desc: Removes all startup commands. + ex: - "" + params: + - {} startupcommandslist: - desc: "Lists all startup commands in the order they will be executed in." - args: + desc: Lists all startup commands in the order they will be executed in. + ex: - "" + params: + - page: + desc: "The number of items to display per page." autocommandslist: - desc: "Lists all auto commands and the intervals in which they execute." - args: + desc: Lists all auto commands and the intervals in which they execute. + ex: - "" + params: + - page: + desc: "The number of pages to retrieve from the list of auto commands." unban: - desc: "Unbans a user with the provided user#discrim or id." - args: - - "kwoth#1234" - - "123123123" + desc: Unbans a user with the provided user#discrim or id. + ex: + - kwoth#1234 + - 123123123 + params: + - user: + desc: "The ID of the user being unbanned." + - userId: + desc: "The ID of the user to be unbanned." banmessage: desc: "Sets a ban message template which will be used when a user is banned from this server. You can use embed strings and ban-specific placeholders: %ban.mod%, %ban.user%, %ban.duration% and %ban.reason%. You can disable ban message with `{0}banmsg -`" - args: + ex: - "%ban.user%, you've been banned from %server.name%. Reason: %ban.reason%" - '{{ "description": "%ban.user% you have been banned from %server.name% by %ban.mod%" }}' + params: + - message: + desc: "The custom message to be displayed when a user is banned from the server, allowing for placeholders to be replaced with relevant information." banmessagetest: - desc: "If ban message is not disabled, bot will send you the message as if you were banned by yourself. Used for testing the ban message." - args: - - "No reason" - - "1h Test 1 hour ban message" + desc: If ban message is not disabled, bot will send you the message as if you were banned by yourself. Used for testing the ban message. + ex: + - No reason + - 1h Test 1 hour ban message + params: + - reason: + desc: "The reason why a user was banned." + - duration: + desc: "The time period during which the ban message should be sent." + reason: + desc: "The reason why a user was banned." banmsgreset: - desc: "Resets ban message to default. If you want to completely disable ban messages, use `{0}banmsg -`" - args: + desc: Resets ban message to default. If you want to completely disable ban messages, use `{0}banmsg -` + ex: - "" + params: + - {} banprune: desc: |- Sets how many days of messages will be deleted when a user is banned. Only works if the user is banned via the .ban command or punishment. Allowed values: 0 - 7 - args: - - "3" + ex: + - 3 + params: + - days: + desc: "The number of days to retain messages for a banned user before they are automatically deleted." wait: - desc: "Used only as a startup command. Waits a certain number of milliseconds before continuing the execution of the following startup commands." - args: - - "3000" + desc: Used only as a startup command. Waits a certain number of milliseconds before continuing the execution of the following startup commands. + ex: + - 3000 + params: + - miliseconds: + desc: "The time period to pause for." warnexpire: - desc: "Gets or sets the number of days after which the warnings will be cleared automatically. This setting works retroactively. If you want to delete the warnings instead of clearing them, you can set the `--delete` optional parameter. Provide no parameter to see currently set expiry" - args: + desc: Gets or sets the number of days after which the warnings will be cleared automatically. This setting works retroactively. If you want to delete the warnings instead of clearing them, you can set the `--delete` optional parameter. Provide no parameter to see currently set expiry + ex: - "" - - "3" - - "6 --delete" + - 3 + - 6 --delete + params: + - {} + - days: + desc: "The number of days after which expired warnings will be automatically cleared from the system." + params: + desc: "The list of command-line options or flags that can be passed to customize the behavior of the function." warnclear: - desc: "Clears all warnings from a certain user. You can specify a number to clear a specific one." - args: + desc: Clears all warnings from a certain user. You can specify a number to clear a specific one. + ex: - "@PoorDude 3" - "@PoorDude" + params: + - user: + desc: "The user whose warnings are being cleared." + index: + desc: "The index of the warning to be cleared, or 0 to clear all warnings." + - userId: + desc: "The ID of the user whose warnings are being cleared." + index: + desc: "The index of the warning to be cleared, or 0 to clear all warnings." warnpunishlist: - desc: "Lists punishments for warnings." - args: + desc: Lists punishments for warnings. + ex: - "" + params: + - {} warnpunish: desc: "Sets a punishment for a certain number of warnings. You can specify a time string after 'Ban' or *'Mute' punishments to make it a temporary mute/ban. Provide no punishment to remove. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles" - args: - - "3" - - "5 Ban" - - "5 Mute 2d12h" - - "4 AddRole toxic 1h" + ex: + - 3 + - 5 Ban + - 5 Mute 2d12h + - 4 AddRole toxic 1h + params: + - number: + desc: "The number of warnings that will trigger the specified punishment." + _: + desc: "The role that will be added to the user's roles." + role: + desc: "The role that will be affected by the punishment." + time: + desc: "The duration of the temporary punishment." + - number: + desc: "The number of warnings that will trigger the specified punishment." + punish: + desc: "The type of action to take when the specified number of warnings is reached." + time: + desc: "The duration of the temporary punishment." + - number: + desc: "The number of warnings that will trigger the specified punishment." ping: - desc: "Ping the bot to see if there are latency issues." - args: + desc: Ping the bot to see if there are latency issues. + ex: - "" + params: + - {} time: - desc: "Shows the current time and timezone in the specified location." - args: - - "London, UK" + desc: Shows the current time and timezone in the specified location. + ex: + - London, UK + params: + - query: + desc: "The city or region for which to display the current time and timezone." shop: - desc: "Lists this server's administrators' shop. Paginated." - args: + desc: Lists this server's administrators' shop. Paginated. + ex: - "" - - "2" + - 2 + params: + - page: + desc: "The number of the page to retrieve from the list of administrators' shops." shopadd: - desc: "Adds an item to the shop by specifying type price and name. Available types are role and list. 90% of currency from each purchase will be received by the user who added the item to the shop." - args: - - "role 1000 Rich" + desc: "- Available types are role, list and command.\n- If the item is a role, specify a role id or a role name.\n- If the item is a command, specify the full command, replacing the user with %user% (for a mention) or %user.id% for user id.\n90% of currency from each purchase will be received by the user who added the item to the shop. " + ex: + - role 1000 Rich + - cmd 1000 .setrole %user% Rich + params: + - _: + desc: "The command to be executed when an item is purchased." + price: + desc: "The cost at which the item is available for purchase." + command: + desc: "The full command, replacing the user with %user% (for a mention) or %user.id% for user id. This allows users to specify custom" + - _: + desc: "The role that should be granted to users when they purchase this item." + price: + desc: "The cost at which the item is available for purchase." + role: + desc: "The ID or name of a predefined role in the system, used to specify the type of item being added to the shop." + - _: + desc: "The list of items to be added to the shop." + price: + desc: "The cost at which the item is available for purchase." + name: + desc: "The name of the role, command, or list being added to the shop." shopremove: - desc: "Removes an item from the shop by its ID." - args: - - "1" + desc: Removes an item from the shop by its ID. + ex: + - 1 + params: + - index: + desc: "The position in the list of items to remove." shopreq: - desc: "Sets a role which will be required to buy the item on the specified index. Specify only index to remove the requirement." - args: - - "2 Gamers" - - "2" + desc: Sets a role which will be required to buy the item on the specified index. Specify only index to remove the requirement. + ex: + - 2 Gamers + - 2 + params: + - itemIndex: + desc: "The index of an item in the inventory that must be purchased before this one can be bought." + role: + desc: "The role that must be assigned to the player in order to purchase the item." shopchangename: - desc: "Change the name of a shop entry at the specified index. Only works for non-role items" - args: - - "3 Cool stuff" + desc: Change the name of a shop entry at the specified index. Only works for non-role items + ex: + - 3 Cool stuff + params: + - index: + desc: "The index of the shop entry to modify." + newName: + desc: "The new name given to the shop item, which will replace its original name." shopchangeprice: - desc: "Change the price of a shop entry at the specified index. Specify the index of the entry, followed by the price" - args: - - "1 500" + desc: Change the price of a shop entry at the specified index. Specify the index of the entry, followed by the price + ex: + - 1 500 + params: + - index: + desc: "The index of the entry to be updated with the new price." + price: + desc: "The new price for the item being updated." shopswap: - desc: "Swap the index of two shop entries" - args: - - "1 5" + desc: Swap the index of two shop entries + ex: + - 1 5 + params: + - index1: + desc: "The position in the list where a swap operation should take place." + index2: + desc: "The second index of the entry to swap." shopmove: - desc: "Moves the shop entry from the current index to a new one" - args: - - "2 4" + desc: Moves the shop entry from the current index to a new one + ex: + - 2 4 + params: + - fromIndex: + desc: "The starting position in the list that contains the item to be moved." + toIndex: + desc: "The position in the list where the shop entry should be moved." buy: - desc: "Buys an item from the shop on a given index. If buying items, make sure that the bot can DM you." - args: - - "2" + desc: Buys an item from the shop on a given index. If buying items, make sure that the bot can DM you. + ex: + - 2 + params: + - index: + desc: "The index of the item to be purchased in the shop's inventory." gamevoicechannel: - desc: "Toggles game voice channel feature in the voice channel you're currently in. Users who join the game voice channel will get automatically redirected to the voice channel with the name of their current game, if it exists. Can't move users to channels that the bot has no connect permission for. One per server." - args: + desc: Toggles game voice channel feature in the voice channel you're currently in. Users who join the game voice channel will get automatically redirected to the voice channel with the name of their current game, if it exists. Can't move users to channels that the bot has no connect permission for. One per server. + ex: - "" + params: + - {} shoplistadd: - desc: "Adds an item to the list of items for sale in the shop entry given the index. You usually want to run this command in the secret channel, so that the unique items are not leaked." - args: - - "1 Uni-que-Steam-Key" + desc: Adds an item to the list of items for sale in the shop entry given the index. You usually want to run this command in the secret channel, so that the unique items are not leaked. + ex: + - 1 Uni-que-Steam-Key + params: + - index: + desc: "The position at which to insert the new item in the list of items for sale." + itemText: + desc: "The description or name of the item being added to the list." globalcommand: - desc: "Toggles whether a command can be used on any server." - args: + desc: Toggles whether a command can be used on any server. + ex: - "{0}stats" + params: + - cmd: + desc: "The type of command or expression being toggled." globalmodule: - desc: "Toggles whether a module can be used on any server." - args: - - "nsfw" + desc: Toggles whether a module can be used on any server. + ex: + - nsfw + params: + - module: + desc: "The type of module or configuration information being toggled." globalpermlist: - desc: "Lists global permissions set by the bot owner." - args: + desc: Lists global permissions set by the bot owner. + ex: - "" + params: + - {} resetglobalperms: - desc: "Resets global permissions set by bot owner." - args: + desc: Resets global permissions set by bot owner. + ex: - "" + params: + - {} prefix: - desc: "Sets this server's prefix for all bot commands. Provide no parameters to see the current server prefix. **Setting prefix requires Administrator server permission.**" - args: - - "+" + desc: Sets this server's prefix for all bot commands. Provide no parameters to see the current server prefix. **Setting prefix requires Administrator server permission.** + ex: + - + + params: + - {} + - _: + desc: "The default text that is prepended to all bot command names." + newPrefix: + desc: "The new prefix for all bot commands, allowing users to interact with the bot using a custom keyword." + - toSet: + desc: "The new prefix for all bot commands, allowing users to interact with the bot using a custom keyword." defprefix: - desc: "Sets bot's default prefix for all bot commands. Provide no parameters to see the current default prefix. This will not change this server's current prefix." - args: - - "+" + desc: Sets bot's default prefix for all bot commands. Provide no parameters to see the current default prefix. This will not change this server's current prefix. + ex: + - + + params: + - toSet: + desc: "The new prefix to set as the default for all bot commands." verboseerror: - desc: "Toggles or sets whether the bot should print command errors when a command is incorrectly used." - args: + desc: Toggles or sets whether the bot should print command errors when a command is incorrectly used. + ex: - "" - - "false" + - false + params: + - newstate: + desc: "The state that determines whether error messages are displayed to the user." streamrolekeyword: - desc: "Sets keyword which is required in the stream's title in order for the streamrole to apply. Provide no keyword in order to reset." - args: + desc: Sets keyword which is required in the stream's title in order for the streamrole to apply. Provide no keyword in order to reset. + ex: - "" - - "PUBG" + - PUBG + params: + - keyword: + desc: "The keyword that must be present in a stream's title for the associated role to take effect." streamroleblacklist: - desc: "Adds or removes a blacklisted user. Blacklisted users will never receive the stream role." - args: - - "add @Someone#1234" - - "rem @Someone#1234" + desc: Adds or removes a blacklisted user. Blacklisted users will never receive the stream role. + ex: + - add @Someone#1234 + - rem @Someone#1234 + params: + - action: + desc: "The type of operation to perform on the blacklisted user, either adding them to the list or removing them from it." + user: + desc: "The ID of the user who is being added or removed from the blacklist." streamrolewhitelist: - desc: "Adds or removes a whitelisted user. Whitelisted users will receive the stream role even if they don't have the specified keyword in their stream title." - args: - - "add @Someone#1234" - - "rem @Someone#1234" + desc: Adds or removes a whitelisted user. Whitelisted users will receive the stream role even if they don't have the specified keyword in their stream title. + ex: + - add @Someone#1234 + - rem @Someone#1234 + params: + - action: + desc: "The type of operation to perform on the whitelist, either adding or removing a user." + user: + desc: "The user to be added or removed from the whitelist for receiving the stream role." config: desc: |- Gets or sets configuration values. @@ -1839,167 +3302,304 @@ config: Provide config name to see all properties in that configuration and their values. Provide config name and property name to see that property's description and value. Provide config name, property name and value to set that property to the new value. - args: + ex: - "" - - "bot" - - "bot color.ok" - - "bot color.ok ff0000" + - bot + - bot color.ok + - bot color.ok ff0000 + params: + - name: + desc: "The name of a specific configuration section or category. It is used as an identifier to access its properties and values." + prop: + desc: "The name of a specific configuration property or section. It is used to retrieve or modify its settings." + value: + desc: "The new value for a specific configuration property." configreload: - desc: "Reloads specified configuration" - args: - - "bot" - - "gambling" -nsfwtagblacklist: - desc: "Toggles whether the tag is blacklisted or not in nsfw searches. Provide no parameters to see the list of blacklisted tags." - args: - - "poop" + desc: Reloads specified configuration + ex: + - bot + - gambling + params: + - name: + desc: "The path or identifier of the configuration file or section to reload." experience: - desc: "Shows your xp stats. Specify the user to show that user's stats instead." - args: + desc: Shows your xp stats. Specify the user to show that user's stats instead. + ex: - "" - "@someguy" + params: + - user: + desc: "The ID or handle of a player whose XP statistics are being displayed." xptemplatereload: - desc: "Reloads the xp template file. Xp template file allows you to customize the position and color of elements on the `{0}xp` card." - args: + desc: Reloads the xp template file. Xp template file allows you to customize the position and color of elements on the `{0}xp` card. + ex: - "" + params: + - {} xpexclusionlist: - desc: "Shows the roles and channels excluded from the XP system on this server, as well as whether the whole server is excluded." - args: + desc: Shows the roles and channels excluded from the XP system on this server, as well as whether the whole server is excluded. + ex: - "" + params: + - {} xpexclude: - desc: "Exclude a channel, role or current server from the xp system." - args: - - "Role Excluded-Role" - - "Server" + desc: Exclude a channel, role or current server from the xp system. + ex: + - Role Excluded-Role + - Server + params: + - _: + desc: "The ID of the server to exclude from the XP system." + - _: + desc: "The role that should not receive XP rewards." + role: + desc: "The role that should not receive XP rewards." + - _: + desc: "The ID of the channel to exclude from XP tracking." + channel: + desc: "The ID of the channel to exclude from XP tracking." xpnotify: - desc: "Sets how the bot should notify you when you get a `server` or `global` level. This is a personal setting and affects only how you receive Global or Server level-up notifications. You can set `dm` (for the bot to send you a direct message), `channel` (to get notified in the channel you sent the last message in) or `none` to disable." - args: - - "global dm" - - "server channel" + desc: Sets how the bot should notify you when you get a `server` or `global` level. This is a personal setting and affects only how you receive Global or Server level-up notifications. You can set `dm` (for the bot to send you a direct message), `channel` (to get notified in the channel you sent the last message in) or `none` to disable. + ex: + - global dm + - server channel + params: + - {} + - place: + desc: "The location where notifications should be sent, such as a specific channel or DM." + type: + desc: "The location where notifications for server and global level-ups should be sent." xpleveluprewards: - desc: "Shows currently set level up rewards." - args: + desc: Shows currently set level up rewards. + ex: - "" + params: + - page: + desc: "The page number for which the level up rewards are being displayed." xprewsreset: - desc: "Resets all currently set xp level up rewards." - args: + desc: Resets all currently set xp level up rewards. + ex: - "" + params: + - {} xprolereward: desc: |- Add or remove a role from the user who reaches the specified level. Provide no action and role name in order to remove the role reward. - args: - - "1 rm Newbie" - - "3 add Social" - - "5 add Member" - - "5" + ex: + - 1 rm Newbie + - 3 add Social + - 5 add Member + - 5 + params: + - level: + desc: "The level at which the user should be rewarded with the role." + - level: + desc: "The level at which the user should be rewarded with the role." + action: + desc: "The type of operation to perform on the user's roles, either adding or removing one." + role: + desc: "The type of authority or privilege being granted or revoked." xpcurrencyreward: - desc: "Sets a currency reward on a specified level. Provide no amount in order to remove the reward." - args: - - "3 50" + desc: Sets a currency reward on a specified level. Provide no amount in order to remove the reward. + ex: + - 3 50 + params: + - level: + desc: "The level at which the currency reward is set or removed." + amount: + desc: "The amount of currency to be rewarded when completing this level." xpleaderboard: - desc: "Shows current server's xp leaderboard." - args: + desc: Shows current server's xp leaderboard. + ex: - "" + params: + - params: + desc: "The list of player names or IDs to filter the leaderboard by." + - page: + desc: "The number of pages to display in the leaderboard." + params: + desc: "The list of player names or IDs to filter the leaderboard by." xpgloballeaderboard: - desc: "Shows the global xp leaderboard." - args: + desc: Shows the global xp leaderboard. + ex: - "" + params: + - page: + desc: "The current page number for displaying the leaderboard results." xpadd: - desc: "Adds server XP to a single user or all users role on this server. This does not affect their global ranking. You can use negative values." - args: - - "100 @Someone" - - "500 SomeRoleName" + desc: Adds server XP to a single user or all users role on this server. This does not affect their global ranking. You can use negative values. + ex: + - 100 @Someone + - 500 SomeRoleName + params: + - amount: + desc: "The total experience points to be added." + role: + desc: "The type of account or group that the XP is being added to, such as an administrator or moderator." + - amount: + desc: "The total experience points to be added." + userId: + desc: "The ID of the player whose experience points are being modified." + - amount: + desc: "The total experience points to be added." + user: + desc: "The ID of the user whose XP is being added or modified." clubcreate: - desc: "Creates a club. You must be at least level 5 and not be in the club already." - args: - - "My Brand New Club" + desc: Creates a club. You must be at least level 5 and not be in the club already. + ex: + - My Brand New Club + params: + - clubName: + desc: "The name of the new club being created." clubtransfer: - desc: "Transfers the ownership of the club to another member of the club." - args: + desc: Transfers the ownership of the club to another member of the club. + ex: - "@Someone" + params: + - newOwner: + desc: "The user who will take over the management of the club." clubinformation: - desc: "Shows information about the club." - args: - - "My Brand New Club#23" + desc: Shows information about the club. + ex: + - My Brand New Club#23 + params: + - user: + desc: "The identity of the person requesting the club information." + - clubName: + desc: "The name of a specific club being queried for information." clubapply: - desc: "Apply to join a club. You must meet that club's minimum level requirement, and not be on its ban list." - args: - - "My Brand New Club#23" + desc: Apply to join a club. You must meet that club's minimum level requirement, and not be on its ban list. + ex: + - My Brand New Club#23 + params: + - clubName: + desc: "The name of the club you are trying to join." clubaccept: - desc: "Accept a user who applied to your club." - args: - - "user#1337" + desc: Accept a user who applied to your club. + ex: + - user#1337 + params: + - user: + desc: "The identity of the person being accepted into the club." + - userName: + desc: "The username of the person being accepted into the club." clubreject: - desc: "Reject a user who applied to your club." - args: - - "user#1337" + desc: Reject a user who applied to your club. + ex: + - user#1337 + params: + - user: + desc: "The identity of the person being rejected from the club." + - userName: + desc: "The username of the user being rejected from the club." clubleave: - desc: "Leaves the club you're currently in." - args: + desc: Leaves the club you're currently in. + ex: - "" + params: + - {} clubdisband: - desc: "Disbands the club you're the owner of. This action is irreversible." - args: + desc: Disbands the club you're the owner of. This action is irreversible. + ex: - "" + params: + - {} clubkick: - desc: "Kicks the user from the club. You must be the club owner. They will be able to apply again." - args: - - "user#1337" + desc: Kicks the user from the club. You must be the club owner. They will be able to apply again. + ex: + - user#1337 + params: + - user: + desc: "The current member being kicked from the club." + - userName: + desc: "The username of the user to be kicked from the club." clubban: - desc: "Bans the user from the club. You must be the club owner. They will not be able to apply again." - args: - - "user#1337" + desc: Bans the user from the club. You must be the club owner. They will not be able to apply again. + ex: + - user#1337 + params: + - user: + desc: "The user who is being banned." + - userName: + desc: "The username of the user to ban from the club." clubunban: - desc: "Unbans the previously banned user from the club. You must be the club owner." - args: - - "user#1337" + desc: Unbans the previously banned user from the club. You must be the club owner. + ex: + - user#1337 + params: + - user: + desc: "The user who was previously banned is being restored to membership in the club." + - userName: + desc: "The username of the user being unbanned from the club." clubdescription: - desc: "Sets the club description. Maximum 150 characters. Club owner only." - args: - - "This is the best club please join." + desc: Sets the club description. Maximum 150 characters. Club owner only. + ex: + - This is the best club please join. + params: + - desc: + desc: "The brief summary of the club's purpose and activities." clubicon: - desc: "Sets the club icon." - args: - - "https://i.imgur.com/htfDMfU.png" + desc: Sets the club icon. + ex: + - https://i.imgur.com/htfDMfU.png + params: + - url: + desc: "The URL of an image file to use as the club icon." clubapps: - desc: "Shows the list of users who have applied to your club. Paginated. You must be club owner to use this command." - args: - - "2" + desc: Shows the list of users who have applied to your club. Paginated. You must be club owner to use this command. + ex: + - 2 + params: + - page: + desc: "The number of pages to display in the result set." clubbans: - desc: "Shows the list of users who have banned from your club. Paginated. You must be club owner to use this command." - args: - - "2" + desc: Shows the list of users who have banned from your club. Paginated. You must be club owner to use this command. + ex: + - 2 + params: + - page: + desc: "The number of pages to retrieve in the result set." clubleaderboard: - desc: "Shows club rankings on the specified page." - args: - - "2" + desc: Shows club rankings on the specified page. + ex: + - 2 + params: + - page: + desc: "The page number to display, allowing users to navigate through multiple pages of club rankings." clubadmin: - desc: "Assigns (or unassigns) staff role to the member of the club. Admins can ban, kick and accept applications." - args: + desc: Assigns (or unassigns) staff role to the member of the club. Admins can ban, kick and accept applications. + ex: - "@Someone" -autoboobs: - desc: "Posts a boobs every X seconds. 20 seconds minimum. Provide no parameters to disable." - args: - - "30" - - "" -autobutts: - desc: "Posts a butt every X seconds. 20 seconds minimum. Provide no parameters to disable." - args: - - "30" - - "" + params: + - toAdmin: + desc: "The user who is being assigned or unassigned as an admin." eightball: - desc: "Ask the 8ball a yes/no question." - args: - - "Is b1nzy a nice guy?" + desc: Ask the 8ball a yes/no question. + ex: + - Is b1nzy a nice guy? + params: + - question: + desc: "The user's inquiry or query that they want the 8ball to answer." ytuploadnotif: desc: |- Subscribe to a youtube channel's upload rss feed. Shortcut for `.feed https://www.youtube.com/feeds/videos.xml?channel_id=%3Cyoutube_channel_id` You can optionally specify a message which will be posted with an update. - args: - - "https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow" - - "https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow New video is posted" + ex: + - https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow + - https://www.youtube.com/channel/UCSJ4gkVC6NrvII8umztf0Ow New video is posted + params: + - url: + desc: "The URL of the YouTube channel's RSS feed to subscribe to." + message: + desc: "The text to be displayed in the notification when an upload is detected." + - url: + desc: "The URL of the YouTube channel's RSS feed to subscribe to." + channel: + desc: "The ID of the YouTube channel to subscribe to notifications for, or the text channel where updates will be posted." + message: + desc: "The text to be displayed in the notification when an upload is detected." feed: desc: |- Subscribes to a feed. @@ -2008,313 +3608,596 @@ feed: All feeds must have unique URLs. Set a channel as a second optional parameter to specify where to send the updates. You can optionally specify a message after the channel name which will be posted with an update. - args: - - "https://blog.playstation.com/feed/" + ex: + - https://blog.playstation.com/feed/ - "https://blog.playstation.com/feed/ #updates" - "https://blog.playstation.com/feed/ #updates New playstation rss feed post!" + params: + - url: + desc: "The URL of the feed to subscribe to, used to retrieve new content for posting." + message: + desc: "The text to be sent in the update post." + - url: + desc: "The URL of the feed to subscribe to, used to retrieve new content for posting." + channel: + desc: "The channel where updates will be sent." + message: + desc: "The text to be sent in the update post." feedremove: - desc: "Stops tracking a feed on the given index. Use `{0}feeds` command to see a list of feeds and their indexes." - args: - - "3" + desc: Stops tracking a feed on the given index. Use `{0}feeds` command to see a list of feeds and their indexes. + ex: + - 3 + params: + - index: + desc: "The index of the feed to stop tracking." feedlist: - desc: "Shows the list of feeds you've subscribed to on this server." - args: + desc: Shows the list of feeds you've subscribed to on this server. + ex: - "" + params: + - page: + desc: "The number of items to display per page." expredit: - desc: "Edits the expression's response given its ID." - args: - - "123 I'm a magical girl" + desc: Edits the expression's response given its ID. + ex: + - 123 I'm a magical girl + params: + - id: + desc: "The unique identifier for the expression being edited." + message: + desc: "The text that will replace the original response in the expression's output." say: - desc: "Bot will send the message you typed in the specified channel. If you omit the channel name, it will send the message in the current channel. Supports embeds." - args: - - "hi" + desc: Bot will send the message you typed in the specified channel. If you omit the channel name, it will send the message in the current channel. Supports embeds. + ex: + - hi - "#chat hi" + params: + - channel: + desc: "The destination where the bot will send the message." + message: + desc: "The message to be sent by the bot. It can contain rich text formatting." + - message: + desc: "The message to be sent by the bot. It can contain rich text formatting." sqlexec: - desc: "Executes provided sql command and returns the number of affected rows. Dangerous." - args: - - "UPDATE DiscordUser SET CurrencyAmount=CurrencyAmount+1234" + desc: Executes provided sql command and returns the number of affected rows. Dangerous. + ex: + - UPDATE DiscordUser SET CurrencyAmount=CurrencyAmount+1234 + params: + - sql: + desc: "The SQL query string to execute, which should be carefully crafted to avoid any potential security risks." sqlselect: - desc: "Executes provided sql query and returns the results. Dangerous." - args: - - "SELECT * FROM DiscordUser LIMIT 5" + desc: Executes provided sql query and returns the results. Dangerous. + ex: + - SELECT * FROM DiscordUser LIMIT 5 + params: + - sql: + desc: "The SQL query string that is executed by the function." +sqlselectcsv: + desc: Executes provided sql query and returns the results in a csv file. Dangerous. + ex: + - SELECT * FROM DiscordUser LIMIT 5 + params: + - sql: + desc: "The SQL query string that specifies the data to be retrieved and formatted into a CSV file." deletewaifus: - desc: "Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables." - args: + desc: Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables. + ex: - "" + params: + - {} deletewaifu: - desc: "Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables for the specified user. Also makes specified user's waifus free." - args: + desc: Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables for the specified user. Also makes specified user's waifus free. + ex: - "" + params: + - user: + desc: "The ID of the user whose waifus are to be deleted and set free." + - userId: + desc: "The ID of the user whose waifus are to be deleted and set free." deletecurrency: - desc: "Deletes everything from Currency and CurrencyTransactions." - args: + desc: Deletes everything from Currency and CurrencyTransactions. + ex: - "" + params: + - {} deleteplaylists: - desc: "Deletes everything from MusicPlaylists." - args: + desc: Deletes everything from MusicPlaylists. + ex: - "" + params: + - {} deletexp: - desc: "Deletes everything from UserXpStats, Clubs and sets users' TotalXP to 0." - args: + desc: Deletes everything from UserXpStats, Clubs and sets users' TotalXP to 0. + ex: - "" + params: + - {} discordpermoverride: - desc: "Overrides required user permissions that the command has with the specified ones. You can only use server-level permissions. This action will make the bot ignore user permission requirements which command has by default. Provide no permissions to reset to default." - args: + desc: Overrides required user permissions that the command has with the specified ones. You can only use server-level permissions. This action will make the bot ignore user permission requirements which command has by default. Provide no permissions to reset to default. + ex: - "{0}prune ManageMessages BanMembers" - "{0}prune" + params: + - cmd: + desc: "The command or expression that this override applies to, allowing you to customize permissions for specific commands or actions within your Discord server." + perms: + desc: "The set of permissions that should be used instead of the command's default permissions." discordpermoverridelist: - desc: "Lists all discord permission overrides on this server." - args: + desc: Lists all discord permission overrides on this server. + ex: - "" + params: + - page: + desc: "The number of the page to display in the list of permission overrides." discordpermoverridereset: - desc: "Resets ALL currently set discord permission overrides on this server. This will make all commands have default discord permission requirements." - args: + desc: Resets ALL currently set discord permission overrides on this server. This will make all commands have default discord permission requirements. + ex: - "" + params: + - {} rafflecur: - desc: "Starts or joins a currency raffle with a specified amount. Users who join the raffle will lose the amount of currency specified and add it to the pot. After 30 seconds, random winner will be selected who will receive the whole pot. There is also a `mixed` mode in which the users will be able to join the game with any amount of currency, and have their chances be proportional to the amount they've bet." - args: - - "20" - - "mixed 15" + desc: Starts or joins a currency raffle with a specified amount. Users who join the raffle will lose the amount of currency specified and add it to the pot. After 30 seconds, random winner will be selected who will receive the whole pot. There is also a `mixed` mode in which the users will be able to join the game with any amount of currency, and have their chances be proportional to the amount they've bet. + ex: + - 20 + - mixed 15 + params: + - _: + desc: 'The type of game mode to use, either "fixed" or "mixed".' + amount: + desc: "The minimum or maximum amount of currency that can be used for betting." + - amount: + desc: "The minimum or maximum amount of currency that can be used for betting." + mixed: + desc: 'The parameter determines whether the raffle operates in "fixed" or "proportional" mode.' rip: - desc: "Shows the inevitable fate of someone." - args: + desc: Shows the inevitable fate of someone. + ex: - "@Someone" + params: + - usr: + desc: "The user whose fate is being revealed." autodisconnect: - desc: "Toggles whether the bot should disconnect from the voice channel once it's done playing all of the songs and queue repeat option is set to `none`." - args: + desc: Toggles whether the bot should disconnect from the voice channel once it's done playing all of the songs and queue repeat option is set to `none`. + ex: - "" + params: + - {} timelyset: - desc: "Sets the 'timely' currency allowance amount for users. Second parameter is period in hours, default is 24 hours." - args: - - "100" - - "50 12" + desc: Sets the 'timely' currency allowance amount for users. Second parameter is period in hours, default is 24 hours. + ex: + - 100 + - 50 12 + params: + - amount: + desc: "The maximum amount of currency that can be spent within a given time frame." + period: + desc: "The time period within which a user's timely currency allowance can be used." timely: - desc: "Use to claim your 'timely' currency. Bot owner has to specify the amount and the period on how often you can claim your currency." - args: + desc: Use to claim your 'timely' currency. Bot owner has to specify the amount and the period on how often you can claim your currency. + ex: - "" + params: + - {} timelyreset: - desc: "Resets all user timeouts on `{0}timely` command." - args: + desc: Resets all user timeouts on `{0}timely` command. + ex: - "" + params: + - {} crypto: - desc: "Shows basic stats about a cryptocurrency from coinmarketcap.com. You can use either a name or an abbreviation of the currency." - args: - - "btc" - - "bitcoin" + desc: Shows basic stats about a cryptocurrency from coinmarketcap.com. You can use either a name or an abbreviation of the currency. + ex: + - btc + - bitcoin + params: + - name: + desc: "The symbol or abbreviated name of the cryptocurrency to retrieve statistics for." stock: - desc: "Shows basic information about a stock. You can use a symbol or company name" - args: - - "tsla" - - "advanced micro devices" - - "amd" + desc: Shows basic information about a stock. You can use a symbol or company name + ex: + - tsla + - advanced micro devices + - amd + params: + - query: + desc: "The ticker symbol or company name used to retrieve the stock's information." rolelevelreq: - desc: "Set a level requirement on a self-assignable role." - args: - - "5 SomeRole" + desc: Set a level requirement on a self-assignable role. + ex: + - 5 SomeRole + params: + - level: + desc: "The minimum level required for the role." + role: + desc: "The role that must be assigned before the user can assign this one." massban: - desc: "Bans multiple users at once. Specify a space separated list of IDs of users who you wish to ban." - args: - - "123123123 3333333333 444444444" + desc: Bans multiple users at once. Specify a space separated list of IDs of users who you wish to ban. + ex: + - 123123123 3333333333 444444444 + params: + - userStrings: + desc: "The list of user IDs to ban, provided as a comma-separated string." masskill: - desc: "Specify a new-line separated list of `userid reason`. You can use Username#discrim instead of UserId. Specified users will be banned from the current server, blacklisted from the bot, and have all of their flowers taken away." - args: - - "BadPerson#1234 Toxic person" + desc: Specify a new-line separated list of `userid reason`. You can use Username#discrim instead of UserId. Specified users will be banned from the current server, blacklisted from the bot, and have all of their flowers taken away. + ex: + - BadPerson#1234 Toxic person + params: + - people: + desc: "The list of user IDs or usernames to ban from the server and blacklist from the bot." pathofexile: - desc: "Searches characters for a given Path of Exile account. May specify league name to filter results." - args: + desc: Searches characters for a given Path of Exile account. May specify league name to filter results. + ex: - '"Zizaran"' + params: + - usr: + desc: "The username or email address associated with the Path of Exile account being searched." + league: + desc: "The league in which the character is playing, allowing users to focus on a specific game mode or event." + page: + desc: "The number of pages to retrieve from the API." pathofexileleagues: - desc: "Returns a list of the main Path of Exile leagues." - args: + desc: Returns a list of the main Path of Exile leagues. + ex: - "" + params: + - {} pathofexilecurrency: - desc: "Returns the chaos equivalent of a given currency or exchange rate between two currencies." - args: - - 'Standard "Mirror of Kalandra"' + desc: Returns the chaos equivalent of a given currency or exchange rate between two currencies. + ex: + - Standard "Mirror of Kalandra" + params: + - leagueName: + desc: 'The name of the league in which the currency is used, such as "Harbinger" or "Delve".' + currencyName: + desc: "The type of currency being converted." + convertName: + desc: "The type of currency being converted from or to." rollduel: - desc: "Challenge someone to a roll duel by specifying the amount and the user you wish to challenge as the parameters. To accept the challenge, just specify the name of the user who challenged you, without the amount." - args: - - "50 @Someone" + desc: Challenge someone to a roll duel by specifying the amount and the user you wish to challenge as the parameters. To accept the challenge, just specify the name of the user who challenged you, without the amount. + ex: + - 50 @Someone - "@Challenger" -reactionroleadd: + params: + - u: + desc: "The user being challenged or accepting the challenge." + - amount: + desc: "The stakes for the roll duel." + u: + desc: "The user being challenged or accepting the challenge." +reroadd: desc: |- Specify a message id, emote and a role name to have the bot assign the specified role to the user who reacts to the specified message (in this channel) with the specified emoji. You can optionally specify an exclusivity group. Default is group 0 which is non-exclusive. Other groups are exclusive. Exclusive groups will let the user only have one of the roles specified in that group. You can optionally specify a level requirement after a group. Users who don't meet the level requirement will not receive the role. You can have up to 50 reaction roles per server in total. - args: - - 971276352684691466 😊 gamer - - 971276352684691466 😢 emo 1 - - 971276352684691466 🤔 philosopher 5 20 - - 971276352684691466 👨 normie 5 20 -reactionroleslist: - desc: "Lists all ReactionRole messages on this server with their message ids. Clicking/Tapping message ids will send you to that message." - args: + ex: + - "971276352684691466 😊 gamer" + - "971276352684691466 😢 emo 1" + - "971276352684691466 🤔 philosopher 5 20" + - "971276352684691466 👨 normie 5 20" + params: + - messageId: + desc: "The ID of the message that users must react to in order to receive the specified role." + emoteStr: + desc: "The emoji used by users to react and trigger the role assignment." + role: + desc: "The role that is assigned to users who react with the specified emoji." + group: + desc: "The number of the exclusivity group." + levelReq: + desc: "The minimum amount of experience points required for a user to be eligible for the assigned role." +rerolist: + desc: Lists all ReactionRole messages on this server with their message ids. Clicking/Tapping message ids will send you to that message. + ex: - "" -reactionrolesremove: - desc: "Remove all reaction roles from message specified by the id" - args: - - "971276352684691466" -reactionrolesdeleteall: - desc: "Deletes all reaction roles on the server. This action is irreversible." - args: + params: + - page: + desc: "The number of the page to retrieve, starting from 1." +reroremove: + desc: Remove all reaction roles from message specified by the id + ex: + - 971276352684691466 + params: + - messageId: + desc: "The ID of a specific message that contains reaction roles to be removed." +rerodeleteall: + desc: Deletes all reaction roles on the server. This action is irreversible. + ex: - "" -reactionrolestransfer: - desc: "Transfers reaction roles from one message to another by specifying their ids. If the target message has reaction roles specified already, the reaction roles will be MERGED, not overwritten." - args: - - "971276352684691466 971427748448964628" + params: + - {} +rerotransfer: + desc: Transfers reaction roles from one message to another by specifying their ids. If the target message has reaction roles specified already, the reaction roles will be MERGED, not overwritten. + ex: + - 971276352684691466 971427748448964628 + params: + - fromMessageId: + desc: "The ID of the original message containing the reaction roles to be transferred." + toMessageId: + desc: "The ID of the target message where the reaction roles should be transferred." blackjack: - desc: "Start or join a blackjack game. You must specify the amount you're betting. Use `{0}hit`, `{0}stand` and `{0}double` commands to play. Game is played with 4 decks. Dealer hits on soft 17 and wins draws." - args: - - "50" + desc: Start or join a blackjack game. You must specify the amount you're betting. Use `{0}hit`, `{0}stand` and `{0}double` commands to play. Game is played with 4 decks. Dealer hits on soft 17 and wins draws. + ex: + - 50 + params: + - amount: + desc: "The minimum bet required to participate in the game." hit: - desc: "In the blackjack game, ask the dealer for an extra card." - args: + desc: In the blackjack game, ask the dealer for an extra card. + ex: - "" + params: + - {} stand: - desc: "Finish your turn in the blackjack game." - args: + desc: Finish your turn in the blackjack game. + ex: - "" + params: + - {} double: - desc: "In the blackjack game, double your bet in order to receive exactly one more card, and your turn ends." - args: + desc: In the blackjack game, double your bet in order to receive exactly one more card, and your turn ends. + ex: - "" + params: + - {} xpreset: - desc: "Resets specified user's XP, or the XP of all users in the server. You can't reverse this action." - args: + desc: Resets specified user's XP, or the XP of all users in the server. You can't reverse this action. + ex: - "@Someone" - "" + params: + - user: + desc: "The ID of a specific guild member whose XP is being reset." + - userId: + desc: "The ID of a specific player whose experience points are being reset." + - {} xpshop: - desc: "Access the xp shop (if enabled). You can purchase either xp card frames or backgrounds. You can optionally provide a page number" - args: - - "bgs" - - "frames" - - "bgs 3" + desc: Access the xp shop (if enabled). You can purchase either xp card frames or backgrounds. You can optionally provide a page number + ex: + - bgs + - frames + - bgs 3 + params: + - {} + - type: + desc: "The type of item to be purchased, such as an XP card frame or background." + page: + desc: "The page number to display in the XP shop." xpshopbuy: - desc: "Buy an item from the xp shop by specifying the type and the key of the item." - args: - - "bg open_sea" - - "fr gold" + desc: Buy an item from the xp shop by specifying the type and the key of the item. + ex: + - bg open_sea + - fr gold + params: + - type: + desc: "The type of item to purchase, such as a skill or a cosmetic." + key: + desc: "The unique identifier for the item being purchased." xpshopuse: - desc: "Use a previously purchased item from the xp shop by specifying the type and the key of the item." - args: - - "bg synth" - - "fr default" + desc: Use a previously purchased item from the xp shop by specifying the type and the key of the item. + ex: + - bg synth + - fr default + params: + - type: + desc: "The type of item to be used, such as an experience point or a skill upgrade." + key: + desc: "The unique identifier for the item in the XP shop that you want to use." bible: - desc: "Shows bible verse. You need to supply book name and chapter:verse" - args: - - "genesis 3:19" + desc: Shows bible verse. You need to supply book name and chapter:verse + ex: + - genesis 3:19 + params: + - book: + desc: "The name of the biblical book being referenced." + chapterAndVerse: + desc: "The reference to a specific passage in the Bible, such as 'Genesis 3:15'" edit: - desc: "Edits bot's message, you have to specify message ID and new text. You can optionally specify target channel. Supports embeds." - args: - - "7479498384 Hi :^)" + desc: Edits bot's message, you have to specify message ID and new text. You can optionally specify target channel. Supports embeds. + ex: + - 7479498384 Hi :^) - "#other-channel 771562360594628608 New message!" - '#other-channel 771562360594628608 {{"description":"hello"}}' + params: + - messageId: + desc: "The unique identifier of the message being edited." + text: + desc: "The new text content of the edited message." + - channel: + desc: "The target channel where the edited message will be sent or updated in." + messageId: + desc: "The unique identifier of the message being edited." + text: + desc: "The new text content of the edited message." delete: - desc: "Deletes a single message given the channel and message ID. If channel is ommited, message will be searched for in the current channel. You can also specify time parameter after which the message will be deleted (up to 7 days). This timer won't persist through bot restarts." - args: + desc: Deletes a single message given the channel and message ID. If channel is ommited, message will be searched for in the current channel. You can also specify time parameter after which the message will be deleted (up to 7 days). This timer won't persist through bot restarts. + ex: - "#chat 771562360594628608" - - "771562360594628608" - - "771562360594628608 5m" + - 771562360594628608 + - 771562360594628608 5m + params: + - messageId: + desc: "The unique identifier of a specific message within a channel, used to target the deletion operation." + time: + desc: "The duration after which the message should be automatically deleted." + - channel: + desc: "The channel where the message is located or should be searched for." + messageId: + desc: "The unique identifier of a specific message within a channel, used to target the deletion operation." + time: + desc: "The duration after which the message should be automatically deleted." roleid: - desc: "Shows the id of the specified role." - args: - - "Some Role" -nsfwtoggle: - desc: "Toggles the NSFW parameter of the current text channel." - args: + desc: Shows the id of the specified role. + ex: + - Some Role + params: + - role: + desc: "The role that is being referenced." +agerestricttoggle: + desc: Toggles whether the current channel is age-restricted. + ex: - "" + params: + - {} economy: - desc: "Breakdown of the current state of the bot's economy. Updates every 3 minutes." - args: + desc: Breakdown of the current state of the bot's economy. Updates every 3 minutes. + ex: - "" + params: + - {} purgeuser: - desc: "Purge user from the database completely. This includes currency, xp, clubs that user owns, waifu info" - args: + desc: Purge user from the database completely. This includes currency, xp, clubs that user owns, waifu info + ex: - "@Oblivion" + params: + - userId: + desc: "The ID of the user to be purged from the system." + - user: + desc: "The user account being purged." imageonlychannel: - desc: |- - Toggles whether the channel only allows images. - Users who send more than a few non-image messages will be banned from using the channel. - args: + desc: "Toggles whether the channel only allows images.\nUsers who send more than a few non-image messages will be banned from using the channel. " + ex: - "" + params: + - time: + desc: "The amount of time before banning users for sending non-image messages." linkonlychannel: - desc: |- - Toggles whether the channel only allows links. - Users who send more than a few non-link messages will be banned from using the channel. - args: + desc: "Toggles whether the channel only allows links.\nUsers who send more than a few non-link messages will be banned from using the channel. " + ex: - "" + params: + - time: + desc: "The amount of time before a user is banned for sending non-link messages." coordreload: - desc: "Reloads coordinator config" - args: + desc: Reloads coordinator config + ex: - "" + params: + - {} showembed: - desc: "Prints the json equivalent of the embed of the message specified by its Id." - args: - - "820022733172121600" + desc: Prints the json equivalent of the embed of the message specified by its Id. + ex: + - 820022733172121600 - "#some-channel 820022733172121600" + params: + - messageId: + desc: "The ID of a message that contains an embed to be displayed as JSON." + - ch: + desc: "The channel where the message is located that will be used for the embed to be printed as JSON." + messageId: + desc: "The ID of a message that contains an embed to be displayed as JSON." deleteemptyservers: - desc: "Deletes all servers in which the bot is the only member." - args: + desc: Deletes all servers in which the bot is the only member. + ex: - "" + params: + - {} marmaladeload: desc: |- Loads a marmalade with the specified name from the data/marmalades/ folder. Provide no name to see the list of loadable marmalades. Read about the marmalade system [here](https://docs.elliebot.net/v4/) - args: - - "mycoolmarmalade" + ex: + - mycoolmarmalade - "" + params: + - name: + desc: "The name of a pre-existing marmalade to load." marmaladeunload: desc: |- Unloads the previously loaded marmalade. Provide no name to see the list of unloadable marmalades. Read about the marmalade system [here](https://docs.elliebot.net/v4/) - args: - - "mycoolmarmalade" + ex: + - mycoolmarmalade - "" + params: + - name: + desc: "The name of a specific marmalade to be unloaded." marmaladeinfo: desc: |- Shows information about the specified marmalade such as the author, name, description, list of sneks, number of commands etc. Provide no name to see the basic information about all loaded marmalades. Read about the marmalade system [here](https://docs.elliebot.net/v4/) - args: - - "mycoolmarmalade" + ex: + - mycoolmarmalade - "" + params: + - name: + desc: "The author of the specified marmalade." marmaladelist: desc: |- Lists all loaded and unloaded marmalades. Read about the marmalade system [here](https://docs.elliebot.net/v4/) - args: + ex: - "" + params: + - {} +marmaladesearch: + desc: Searches for marmalades online given the search term + ex: + - shrine + params: + - {} bankdeposit: - desc: "Deposits the specified amount of currency into the bank for later use." - args: - - "50" + desc: Deposits the specified amount of currency into the bank for later use. + ex: + - 50 + params: + - amount: + desc: "The total value of funds being transferred or added to the account." bankwithdraw: - desc: "Withdraws the specified amount of currency from the bank if available." - args: - - "49" + desc: Withdraws the specified amount of currency from the bank if available. + ex: + - 49 + params: + - amount: + desc: "The amount of money to be withdrawn." bankbalance: - desc: "Shows your current bank balance available for withdrawal." - args: + desc: Shows your current bank balance available for withdrawal. + ex: - "" + params: + - {} banktake: - desc: "Takes the specified amount of currency from a user's bank" - args: - - "500 @MoniLaunder" + desc: Takes the specified amount of currency from a user's bank + ex: + - 500 @MoniLaunder + params: + - amount: + desc: "The total value of funds being withdrawn." + user: + desc: "The account holder whose funds are being accessed." + - amount: + desc: "The total value of funds being withdrawn." + userId: + desc: "The unique identifier for the user whose account is being accessed." bankaward: - desc: "Award the specified amount of currency to a user's bank" - args: - - "99999 @Bestie" + desc: Award the specified amount of currency to a user's bank + ex: + - 99999 @Bestie + params: + - amount: + desc: "The total value of the award being given." + user: + desc: "The user who is receiving the award." patron: - desc: "Check your patronage status and command usage quota. Bot owners can check targeted user's patronage status." - args: + desc: Check your patronage status and command usage quota. Bot owners can check targeted user's patronage status. + ex: - "" + params: + - {} + - user: + desc: "The ID or handle of the user whose patronage status is being checked." patronmessage: - desc: "Sends a message to all patrons of the specified tier and higher. Supports embeds." - args: - - "x hello" + desc: Sends a message to all patrons of the specified tier and higher. Supports embeds. + ex: + - x hello + params: + - tierAndHigher: + desc: "The tier or level of membership that determines which patrons receive the message." + message: + desc: "The text content of the message being sent to patrons." eval: desc: |- Execute arbitrary C# code and (optionally) return a result. Several namespaces are included by default. @@ -2325,47 +4208,214 @@ eval: `user` - User executing the command `ctx` - Discord.Net command context `services` - Ellie's IServiceProvider - args: - - "123 / 4.5f" - - "await ctx.OkAsync();" - - 'await ctx.SendConfirmAsync("uwu");' + ex: + - 123 / 4.5f + - await ctx.OkAsync(); + - await ctx.Response().Confirm("uwu").SendAsync(); + params: + - scriptText: + desc: "The code to be executed by the function." betdraw: desc: |- Bet on the card value and/or color. Specify the amount followed by your guess. You can specify `r` or `b` for red or black, and `h` or `l` for high or low. You can specify only h/l or only r/b or both. Returns are high but **7 always loses**. - args: - - "50 r" - - "200 b l" - - "1000 h" - - "38 hi black" + ex: + - 50 r + - 200 b l + - 1000 h + - 38 hi black + params: + - amount: + desc: "The stake to be wagered on the bet." + val: + desc: "The value of the card to be guessed." + col: + desc: "The color of the card to be guessed, either red or black." + - amount: + desc: "The stake to be wagered on the bet." + col: + desc: "The color to bet on, either red or black." + val: + desc: "The value of the card to be guessed." bettest: desc: |- Tests a betting command by specifying the name followed by the number of tests. Some have multiple variations. See the list of all tests by specifying no parameters. - args: + ex: - "" - - "betflip 1000" - - "slot 2000" + - betflip 1000 + - slot 2000 + params: + - {} + - target: + desc: "The type of game or wager being tested." + tests: + desc: "The number of times to repeat the test." threadcreate: - desc: "Create a public thread with the specified title. You may optionally reply to a message to have it as a starting point." - args: - - "Q&A" + desc: Create a public thread with the specified title. You may optionally reply to a message to have it as a starting point. + ex: + - Q&A + params: + - name: + desc: "The title of the new thread, which will be displayed in the forum's list of threads." threaddelete: - desc: "Delete a thread with the specified name in this channel. Case insensitive." - args: - - "Q&A" + desc: Delete a thread with the specified name in this channel. Case insensitive. + ex: + - Q&A + params: + - name: + desc: "The name of the thread to delete, allowing users to specify which specific conversation they want to remove from the channel." autopublish: - desc: "Make the bot automatically publish all messages posted in the news channel this command was executed in." - args: + desc: Make the bot automatically publish all messages posted in the news channel this command was executed in. + ex: - "" + params: + - {} doas: - desc: "Execute the command as if you were the target user. Requires bot ownership and server administrator permission." - args: + desc: Execute the command as if you were the target user. Requires bot ownership and server administrator permission. + ex: - "@Thief .give all @Admin" + params: + - user: + desc: "The user whose identity is being impersonated for the command execution." + message: + desc: "The command or script to execute as the target user." +clubrename: + desc: Renames your club. Requires you club ownership or club-admin status. + ex: + - New cool club name + params: + - clubName: + desc: "The name of the new club that will replace the current one." cacheusers: desc: Caches users of a Discord server and saves them to the database. - args: + ex: - "" - - "serverId" + - serverId + params: + - {} + - guild: + desc: "The guild for which user data is being cached." +stickyroles: + desc: Toggles whether the bot will save the leaving users' roles, and reapply them once they re-join. The roles will be stored for up to 30 days. + ex: + - "" + params: + - {} +giveawaystart: + desc: Starts a giveaway. Specify the duration (between 1 minute and 30 days) followed by the prize. + ex: + - 12h We are giving away one copy of our latest album! + - 15m Quick giveaway for a free course! + - 1d Join to win 1000$! + params: + - duration: + desc: "The length of time for which the giveaway will be active." + message: + desc: "The description of the prize being awarded in this giveaway." +giveawayend: + desc: Prematurely ends a giveaway and selects a winner. Specify the ID of the giveaway to end. + ex: + - ab3 + params: + - id: + desc: "The identifier for the giveaway that is being terminated." +giveawaycancel: + desc: Cancels a giveaway. Specify the ID of the giveaway to cancel. The winner will not be chosen. + ex: + - ab3 + params: + - id: + desc: "The identifier for the giveaway being cancelled." +giveawayreroll: + desc: Rerolls a giveaway. Specify the ID of the giveaway to reroll. This is only active within 24h after the giveaway has ended or until the bot restarts. + ex: + - cd3 + params: + - id: + desc: "The identifier for the specific giveaway being rerolled." +giveawaylist: + desc: Lists all active giveaways. + ex: + - "" + params: + - {} +todolist: + desc: Lists all todos. + ex: + - "" + params: + - {} +todoadd: + desc: Adds a new todo. + ex: + - I need to do this + params: + - todo: + desc: "The description or title of the new todo item." +todoedit: + desc: Edits a todo with the specified ID. + ex: + - abc This is an updated entry + params: + - todoId: + desc: "The unique identifier for the todo item being edited." + newMessage: + desc: "The text of a new task description or update to an existing one." +todocomplete: + desc: Marks a todo with the specified ID as done. + ex: + - 4a + params: + - todoId: + desc: "The unique identifier for the todo item being marked as completed." +tododelete: + desc: Deletes a todo with the specified ID. + ex: + - abc + params: + - todoId: + desc: "The unique identifier for the todo item being deleted." +todoclear: + desc: Deletes all unarchived todos. + ex: + - "" + params: + - {} +todoarchiveadd: + desc: Creates a new archive with the specified name using current todos. + ex: + - Day 1 + params: + - name: + desc: "The name of the archive to be created." +todoarchivelist: + desc: Lists all archived todo lists. + ex: + - "" + params: + - page: + desc: "The number of the page to retrieve from the list of archived todo lists." +todoarchiveshow: + desc: Shows the archived todo list with the specified ID. + ex: + - 3c + params: + - todoId: + desc: "The identifier for a specific task or project that has been archived and is being retrieved." +todoshow: + desc: Shows the text of the todo with the specified ID. + ex: + - 4a + params: + - todoId: + desc: "The unique identifier for the todo item being displayed." +todoarchivedelete: + desc: Deletes the archived todo list with the specified ID. + ex: + - 99 + params: + - todoId: + desc: "The identifier for the archived todo item to be deleted." diff --git a/src/EllieBot/data/strings/responses/responses.en-US.json b/src/EllieBot/data/strings/responses/responses.en-US.json index be21798..0487021 100644 --- a/src/EllieBot/data/strings/responses/responses.en-US.json +++ b/src/EllieBot/data/strings/responses/responses.en-US.json @@ -35,6 +35,9 @@ "banned_user": "User Banned", "ban_prune_disabled": "Banned user's messages will no longer be deleted.", "ban_prune": "Bot will prune up to {0} day(s) worth of messages from banned user.", + "prune_cancelled": "Pruning was cancelled.", + "prune_not_found": "No active prune was found on this server.", + "prune_progress": "Pruning... {0}/{1} messages deleted.", "timeoutdm": "You have been timed out in {0} server.\nReason: {1}", "timedout_user": "User Timed Out", "remove_roles_pl": "have had their roles removed", @@ -184,6 +187,7 @@ "setrole": "Successfully added role {0} to user {1}", "setrole_err": "Failed to add role. I have insufficient permissions.", "set_avatar": "New avatar set!", + "set_banner": "New banner set!", "set_channel_name": "New channel name set.", "set_game": "New game set!", "set_stream": "New stream set!", @@ -376,6 +380,7 @@ "id": "Id", "now_playing": "Now playing", "no_player": "No active music player.", + "music_fairplay": "Music queue has been fairly reordered.", "no_search_results": "No search results.", "player_queue": "Player queue - Page {0}/{1}", "playing_track": "Playing track #{0}", @@ -709,6 +714,7 @@ "shop_item_add": "Shop item added", "shop_none": "No shop items found on this page.", "shop_role": "You will get {0} role.", + "shop_command_invalid_context": "Unable to retrieve user, channel or server in order to execute the command.", "type": "Type", "gvc_disabled": "Game Voice Channel feature has been disabled on this server.", "gvc_enabled": "{0} is a Game Voice Channel now.", @@ -838,10 +844,9 @@ "server_leaderboard": "Server XP Leaderboard", "global_leaderboard": "Global XP Leaderboard", "modified": "Modified server XP of the user {0} by {1}", - "club_insuff_lvl": "You're insufficient level to join that club.", + "club_already_applied": "You've already applied to that club.", "club_join_banned": "You're banned from that club.", "club_already_in": "You are already a member of a club.", - "club_create_error_name": "Failed creating the club. A club with that name already exists.", "club_name_too_long": "Club name is too long.", "club_created": "Club {0} successfully created!", "club_create_insuff_lvl": "You don't meet the minimum level requirements in order to create a club.", @@ -876,6 +881,8 @@ "club_apps_for": "Applicants for {0} club", "club_leaderboard": "Club leaderboard - page {0}", "club_kick_hierarchy": "Only club owner can kick club admins. Owner can't be kicked.", + "club_renamed": "Club has been renamed to {0}", + "club_name_taken": "A club with that name already exists.", "template_reloaded": "Xp template has been reloaded.", "expr_edited": "Expression Edited", "self_assign_are_exclusive": "You can only choose 1 role from each group.", @@ -982,7 +989,7 @@ "module_description_gambling": "Bet on dice rolls, blackjack, slots, coinflips and others", "module_description_games": "Play trivia, nunchi, hangman, connect4 and other games", "module_description_nsfw": "NSFW commands.", - "module_description_music": "Play music from youtube, local files soundcloud and radio streams", + "module_description_music": "Play music from youtube, local files and radio streams", "module_description_utility": "Manage custom quotes, repeating messages and check facts about the server", "module_description_administration": "Moderation, punish users, setup self assignable roles and greet messages", "module_description_expressions": "Setup custom bot responses to certain words or phrases", @@ -990,6 +997,7 @@ "module_description_searches": "Search for jokes, images of animals, anime and manga", "module_description_xp": "Gain xp based on chat activity, check users' xp cards", "module_description_marmalade": "**Bot Owner only.** Load, unload and handle dynamic modules. Read more [here](https://docs.elliebot.net/v4/)", + "module_description_patronage": "Commands related to supporting the bot", "module_description_missing": "Description is missing for this module.", "purge_user_confirm": "Are you sure that you want to purge {0} from the database?", "expr_import_no_input": "Invalid input. No valid file upload or input text found.", @@ -1016,7 +1024,7 @@ "commands_count": "Commands ({0})", "no_marmalade_loaded": "There are no loaded marmalades.", "no_marmalade_available": "No marmalade available.", - "loaded_marmalades": "Loaded Marmaladee", + "loaded_marmalades": "Loaded Marmalades", "marmalade_not_loaded": "Marmalade with that name is not loaded.", "marmalade_possibly_cant_unload": "Marmalade is probably not fully unloaded. Please restart the bot if issues arise.", "marmalade_loaded": "Marmalade {0} has been loaded.", @@ -1060,5 +1068,31 @@ "thread_created": "Thread Created", "supported_languages": "Supported Languages", "cache_users_pending": "Updating users, please wait...", - "cache_users_done": "{0} users were added and {1} users were updated." + "cache_users_done": "{0} users were added and {1} users were updated.", + "sticky_roles_enabled": "Sticky roles enabled. Leaving users' roles will be restored when they rejoin the server.", + "sticky_roles_disabled": "Sticky roles disabled.", + "giveaway_duration_invalid": "Giveaway may not be shorter than 1 minute or longer than 30 days", + "giveaway_started": "Giveaway Started!", + "giveaway_max_amount_reached": "You've reached the maximum amount of giveaways you can have on this server.", + "giveaway_not_found": "Giveaway not found.", + "giveaway_ended": "Giveaway ended", + "no_givaways": "There are no active giveaways on this server.", + "giveaway_cancelled": "Giveaway cancelled.", + "giveaway_starting": "Starting giveaway...", + "winner": "Winner", + "giveaway_list": "List of active giveways", + "todo_list_empty": "Your todo list is empty.", + "todo_list": "Todo List", + "todo_stats": "{0} items | {1} completed | {2} remaining", + "todo_add_max_limit": "You'reached the maximum amount of todos you can have.", + "todo_not_found": "Todo not found.", + "todo_cleared": "All unarchived todos have been cleared.", + "todo_no_todos": "There are no todos in your todo list.", + "todo_archive_max_limit": "You've reached the maximum amount of archived todos you can have.", + "todo_archive_empty": "You have no archived todos.", + "todo_archive_list": "Archived Todo Lists", + "todo_archive_not_found": "Archived todo list not found.", + "todo_archived_list": "Archived Todo List", + "search_results": "Search results", + "queue_search_results": "Type the number of the search result to queue up that track." } \ No newline at end of file diff --git a/src/EllieBot/data/units.json b/src/EllieBot/data/units.json index 9de5921..e09dcc1 100644 --- a/src/EllieBot/data/units.json +++ b/src/EllieBot/data/units.json @@ -719,6 +719,17 @@ "UnitType": "time", "Modifier": 60.0 }, + { + "Triggers": [ + "second", + "seconds", + "sec", + "secs", + "s" + ], + "UnitType": "time", + "Modifier": 1 + }, { "Triggers": [ "year", -- 2.43.0 From e58268e3391d3e0c9ce5382cff80f612cc556721 Mon Sep 17 00:00:00 2001 From: Toastie Date: Thu, 16 May 2024 23:14:41 +1200 Subject: [PATCH 011/340] Fixed a few bugs and updated some dependencies --- src/Ellie.Marmalade/Ellie.Marmalade.csproj | 2 +- src/EllieBot.Coordinator/EllieBot.Coordinator.csproj | 2 +- src/EllieBot.Coordinator/Services/CoordinatorRunner.cs | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Ellie.Marmalade/Ellie.Marmalade.csproj b/src/Ellie.Marmalade/Ellie.Marmalade.csproj index b01a911..db33025 100644 --- a/src/Ellie.Marmalade/Ellie.Marmalade.csproj +++ b/src/Ellie.Marmalade/Ellie.Marmalade.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj index af90db3..b4f964d 100644 --- a/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj +++ b/src/EllieBot.Coordinator/EllieBot.Coordinator.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs index de870ca..780afc4 100644 --- a/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs +++ b/src/EllieBot.Coordinator/Services/CoordinatorRunner.cs @@ -417,8 +417,7 @@ namespace EllieBot.Coordinator { lock (locker) { - if (shardId >= _shardStatuses.Length) - throw new ArgumentOutOfRangeException(nameof(shardId)); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(shardId, _shardStatuses.Length); return _shardStatuses[shardId]; } -- 2.43.0 From 97e81ac0f4b72f1fb17c24f7d9025a7254904934 Mon Sep 17 00:00:00 2001 From: Toastie Date: Sat, 18 May 2024 00:08:52 +1200 Subject: [PATCH 012/340] Added Ellie Common/Abstractions --- .../_common/Abstractions/AsyncLazy.cs | 19 +++ .../Abstractions/Cache/BotCacheExtensions.cs | 46 ++++++ .../_common/Abstractions/Cache/IBotCache.cs | 47 ++++++ .../Abstractions/Cache/MemoryBotCache.cs | 71 +++++++++ .../Collections/ConcurrentHashSet.cs | 88 ++++++++++ .../Collections/IndexedCollections.cs | 148 +++++++++++++++++ .../_common/Abstractions/EllieRandom.cs | 66 ++++++++ .../Extensions/ArrayExtensions.cs | 62 ++++++++ .../Extensions/EnumerableExtensions.cs | 97 +++++++++++ .../Abstractions/Extensions/Extensions.cs | 7 + .../Extensions/HttpClientExtensions.cs | 35 ++++ .../Extensions/OneOfExtensions.cs | 10 ++ .../Abstractions/Extensions/PipeExtensions.cs | 22 +++ .../Extensions/StringExtensions.cs | 150 ++++++++++++++++++ .../_common/Abstractions/Helpers/LogSetup.cs | 35 ++++ .../Helpers/StandardConversions.cs | 7 + src/EllieBot/_common/Abstractions/Kwum.cs | 100 ++++++++++++ .../Abstractions/PubSub/EventPubSub.cs | 80 ++++++++++ .../_common/Abstractions/PubSub/IPubSub.cs | 10 ++ .../_common/Abstractions/PubSub/ISeria.cs | 7 + .../_common/Abstractions/QueueRunner.cs | 61 +++++++ src/EllieBot/_common/Abstractions/TypedKey.cs | 30 ++++ .../_common/Abstractions/YamlHelper.cs | 48 ++++++ .../Abstractions/creds/IBotCredentials.cs | 78 +++++++++ .../Abstractions/creds/IBotCredsProvider.cs | 8 + .../Abstractions/strings/CommandStrings.cs | 35 ++++ .../Abstractions/strings/IBotStrings.cs | 16 ++ .../strings/IBotStringsExtensions.cs | 17 ++ .../strings/IBotStringsProvider.cs | 28 ++++ .../Abstractions/strings/IStringsSource.cs | 17 ++ .../_common/Abstractions/strings/LocStr.cs | 13 ++ 31 files changed, 1458 insertions(+) create mode 100644 src/EllieBot/_common/Abstractions/AsyncLazy.cs create mode 100644 src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Cache/IBotCache.cs create mode 100644 src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs create mode 100644 src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs create mode 100644 src/EllieBot/_common/Abstractions/Collections/IndexedCollections.cs create mode 100644 src/EllieBot/_common/Abstractions/EllieRandom.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/Extensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs create mode 100644 src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs create mode 100644 src/EllieBot/_common/Abstractions/Kwum.cs create mode 100644 src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs create mode 100644 src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs create mode 100644 src/EllieBot/_common/Abstractions/PubSub/ISeria.cs create mode 100644 src/EllieBot/_common/Abstractions/QueueRunner.cs create mode 100644 src/EllieBot/_common/Abstractions/TypedKey.cs create mode 100644 src/EllieBot/_common/Abstractions/YamlHelper.cs create mode 100644 src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs create mode 100644 src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/CommandStrings.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStrings.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/IStringsSource.cs create mode 100644 src/EllieBot/_common/Abstractions/strings/LocStr.cs diff --git a/src/EllieBot/_common/Abstractions/AsyncLazy.cs b/src/EllieBot/_common/Abstractions/AsyncLazy.cs new file mode 100644 index 0000000..6c86693 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/AsyncLazy.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common; + +public class AsyncLazy : Lazy> +{ + public AsyncLazy(Func valueFactory) + : base(() => Task.Run(valueFactory)) + { + } + + public AsyncLazy(Func> taskFactory) + : base(() => Task.Run(taskFactory)) + { + } + + public TaskAwaiter GetAwaiter() + => Value.GetAwaiter(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs new file mode 100644 index 0000000..39d5e82 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs @@ -0,0 +1,46 @@ +using OneOf; +using OneOf.Types; + +namespace Ellie.Common; + +public static class BotCacheExtensions +{ + public static async ValueTask GetOrDefaultAsync(this IBotCache cache, TypedKey key) + { + var result = await cache.GetAsync(key); + if (result.TryGetValue(out var val)) + return val; + + return default; + } + + private static TypedKey GetImgKey(Uri uri) + => new($"image:{uri}"); + + public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data) + => c.SetImageDataAsync(new Uri(key), data); + public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data) + => await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48)); + + public static async ValueTask> GetImageDataAsync(this IBotCache c, Uri key) + => await c.GetAsync(GetImgKey(key)); + + public static async Task GetRatelimitAsync( + this IBotCache c, + TypedKey key, + TimeSpan length) + { + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + var cachedValue = await c.GetOrAddAsync(key, + () => Task.FromResult(now.ToBinary()), + expiry: length); + + if (cachedValue == nowB) + return null; + + var diff = now - DateTime.FromBinary(cachedValue); + return length - diff; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs new file mode 100644 index 0000000..5622c5f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs @@ -0,0 +1,47 @@ +using OneOf; +using OneOf.Types; + +namespace Ellie.Common; + +public interface IBotCache +{ + /// + /// Adds an item to the cache + /// + /// Key to add + /// Value to add to the cache + /// Optional expiry + /// Whether old value should be overwritten + /// Type of the value + /// Returns whether add was sucessful. Always true unless ovewrite = false + ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true); + + /// + /// Get an element from the cache + /// + /// Key + /// Type of the value + /// Either a value or + ValueTask> GetAsync(TypedKey key); + + /// + /// Remove a key from the cache + /// + /// Key to remove + /// Type of the value + /// Whether there was item + ValueTask RemoveAsync(TypedKey key); + + /// + /// Get the key if it exists or add a new one + /// + /// Key to get and potentially add + /// Value creation factory + /// Optional expiry + /// Type of the value + /// The retrieved or newly added value + ValueTask GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs new file mode 100644 index 0000000..caac44f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using OneOf; +using OneOf.Types; + +// ReSharper disable InconsistentlySynchronizedField + +namespace Ellie.Common; + +public sealed class MemoryBotCache : IBotCache +{ + // needed for overwrites and Delete return value + private readonly object _cacheLock = new object(); + private readonly MemoryCache _cache; + + public MemoryBotCache() + { + _cache = new MemoryCache(new MemoryCacheOptions()); + } + + public ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true) + { + if (overwrite) + { + using var item = _cache.CreateEntry(key.Key); + item.Value = value; + item.AbsoluteExpirationRelativeToNow = expiry; + return new(true); + } + + lock (_cacheLock) + { + if (_cache.TryGetValue(key.Key, out var old) && old is not null) + return new(false); + + using var item = _cache.CreateEntry(key.Key); + item.Value = value; + item.AbsoluteExpirationRelativeToNow = expiry; + return new(true); + } + } + + public async ValueTask GetOrAddAsync( + TypedKey key, + Func> createFactory, + TimeSpan? expiry = null) + => await _cache.GetOrCreateAsync(key.Key, + async ce => + { + ce.AbsoluteExpirationRelativeToNow = expiry; + var val = await createFactory(); + return val; + }); + + public ValueTask> GetAsync(TypedKey key) + { + if (!_cache.TryGetValue(key.Key, out var val) || val is null) + return new(new None()); + + return new((T)val); + } + + public ValueTask RemoveAsync(TypedKey key) + { + lock (_cacheLock) + { + var toReturn = _cache.TryGetValue(key.Key, out var old) && old is not null; + _cache.Remove(key.Key); + return new(toReturn); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs new file mode 100644 index 0000000..19986be --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; + +namespace System.Collections.Generic; + +[DebuggerDisplay("{_backingStore.Count}")] +public sealed class ConcurrentHashSet : IReadOnlyCollection, ICollection where T : notnull +{ + private readonly ConcurrentDictionary _backingStore; + + public ConcurrentHashSet() + => _backingStore = new(); + + public ConcurrentHashSet(IEnumerable values, IEqualityComparer? comparer = null) + => _backingStore = new(values.Select(x => new KeyValuePair(x, true)), comparer); + + public IEnumerator GetEnumerator() + => _backingStore.Keys.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + /// Adds the specified item to the . + /// + /// The item to add. + /// + /// true if the items was added to the + /// successfully; false if it already exists. + /// + /// + /// The + /// contains too many items. + /// + public bool Add(T item) + => _backingStore.TryAdd(item, true); + + void ICollection.Add(T item) + => Add(item); + + public void Clear() + => _backingStore.Clear(); + + public bool Contains(T item) + => _backingStore.ContainsKey(item); + + public void CopyTo(T[] array, int arrayIndex) + { + ArgumentNullException.ThrowIfNull(array); + + if (arrayIndex < 0) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + + if (arrayIndex >= array.Length) + throw new ArgumentOutOfRangeException(nameof(arrayIndex)); + + CopyToInternal(array, arrayIndex); + } + + private void CopyToInternal(T[] array, int arrayIndex) + { + var len = array.Length; + foreach (var (k, _) in _backingStore) + { + if (arrayIndex >= len) + throw new IndexOutOfRangeException(nameof(arrayIndex)); + + array[arrayIndex++] = k; + } + } + + bool ICollection.Remove(T item) + => TryRemove(item); + + public bool TryRemove(T item) + => _backingStore.TryRemove(item, out _); + + public void RemoveWhere(Func predicate) + { + foreach (var elem in this.Where(predicate)) + TryRemove(elem); + } + + public int Count + => _backingStore.Count; + + public bool IsReadOnly + => false; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Collections/IndexedCollections.cs b/src/EllieBot/_common/Abstractions/Collections/IndexedCollections.cs new file mode 100644 index 0000000..fe8e2c1 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Collections/IndexedCollections.cs @@ -0,0 +1,148 @@ +using System.Collections; + +namespace Ellie.Common; + +public interface IIndexed +{ + int Index { get; set; } +} + +public class IndexedCollection : IList + where T : class, IIndexed +{ + public List Source { get; } + + public int Count + => Source.Count; + + public bool IsReadOnly + => false; + + public virtual T this[int index] + { + get => Source[index]; + set + { + lock (_locker) + { + value.Index = index; + Source[index] = value; + } + } + } + + private readonly object _locker = new(); + + public IndexedCollection() + => Source = new(); + + public IndexedCollection(IEnumerable source) + { + lock (_locker) + { + Source = source.OrderBy(x => x.Index).ToList(); + UpdateIndexes(); + } + } + + public int IndexOf(T item) + => item?.Index ?? -1; + + public IEnumerator GetEnumerator() + => Source.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => Source.GetEnumerator(); + + public void Add(T item) + { + ArgumentNullException.ThrowIfNull(item); + + lock (_locker) + { + item.Index = Source.Count; + Source.Add(item); + } + } + + public virtual void Clear() + { + lock (_locker) + { + Source.Clear(); + } + } + + public bool Contains(T item) + { + lock (_locker) + { + return Source.Contains(item); + } + } + + public void CopyTo(T[] array, int arrayIndex) + { + lock (_locker) + { + Source.CopyTo(array, arrayIndex); + } + } + + public virtual bool Remove(T item) + { + lock (_locker) + { + if (Source.Remove(item)) + { + for (var i = 0; i < Source.Count; i++) + { + if (Source[i].Index != i) + Source[i].Index = i; + } + + return true; + } + } + + return false; + } + + public virtual void Insert(int index, T item) + { + lock (_locker) + { + Source.Insert(index, item); + for (var i = index; i < Source.Count; i++) + Source[i].Index = i; + } + } + + public virtual void RemoveAt(int index) + { + lock (_locker) + { + Source.RemoveAt(index); + for (var i = index; i < Source.Count; i++) + Source[i].Index = i; + } + } + + public void UpdateIndexes() + { + lock (_locker) + { + for (var i = 0; i < Source.Count; i++) + { + if (Source[i].Index != i) + Source[i].Index = i; + } + } + } + + public static implicit operator List(IndexedCollection x) + => x.Source; + + public List ToList() + => Source.ToList(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/EllieRandom.cs b/src/EllieBot/_common/Abstractions/EllieRandom.cs new file mode 100644 index 0000000..0ece259 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/EllieRandom.cs @@ -0,0 +1,66 @@ +#nullable disable +using System.Security.Cryptography; + +namespace Ellie.Common; + +public sealed class EllieRandom : Random +{ + private readonly RandomNumberGenerator _rng; + + public EllieRandom() + => _rng = RandomNumberGenerator.Create(); + + public override int Next() + { + var bytes = new byte[sizeof(int)]; + _rng.GetBytes(bytes); + return Math.Abs(BitConverter.ToInt32(bytes, 0)); + } + + /// + /// Generates a random integer between 0 (inclusive) and + /// a specified exclusive upper bound using a cryptographically strong random number generator. + /// + /// Exclusive max value + /// A random number + public override int Next(int maxValue) + => RandomNumberGenerator.GetInt32(maxValue); + + /// + /// Generates a random integer between a specified inclusive lower bound and a + /// specified exclusive upper bound using a cryptographically strong random number generator. + /// + /// Inclusive min value + /// Exclusive max value + /// A random number + public override int Next(int minValue, int maxValue) + => RandomNumberGenerator.GetInt32(minValue, maxValue); + + public long NextLong(long minValue, long maxValue) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue); + if (minValue == maxValue) + return minValue; + var bytes = new byte[sizeof(long)]; + _rng.GetBytes(bytes); + var sign = Math.Sign(BitConverter.ToInt64(bytes, 0)); + return (sign * BitConverter.ToInt64(bytes, 0) % (maxValue - minValue)) + minValue; + } + + public override void NextBytes(byte[] buffer) + => _rng.GetBytes(buffer); + + protected override double Sample() + { + var bytes = new byte[sizeof(double)]; + _rng.GetBytes(bytes); + return Math.Abs((BitConverter.ToDouble(bytes, 0) / double.MaxValue) + 1); + } + + public override double NextDouble() + { + var bytes = new byte[sizeof(double)]; + _rng.GetBytes(bytes); + return BitConverter.ToDouble(bytes, 0); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs new file mode 100644 index 0000000..dbe2d93 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs @@ -0,0 +1,62 @@ +using System.Security.Cryptography; + +namespace Ellie.Common; + +// made for expressions because they almost never get added +// and they get looped through constantly +public static class ArrayExtensions +{ + /// + /// Create a new array from the old array + new element at the end + /// + /// Input array + /// Item to add to the end of the output array + /// Type of the array + /// A new array with the new element at the end + public static T[] With(this T[] input, T added) + { + var newExprs = new T[input.Length + 1]; + Array.Copy(input, 0, newExprs, 0, input.Length); + newExprs[input.Length] = added; + return newExprs; + } + + /// + /// Creates a new array by applying the specified function to every element in the input array + /// + /// Array to modify + /// Function to apply + /// Orignal type of the elements in the array + /// Output type of the elements of the array + /// New array with updated elements + public static TOut[] Map(this TIn[] arr, Func f) + => Array.ConvertAll(arr, x => f(x)); + + /// + /// Creates a new array by applying the specified function to every element in the input array + /// + /// Array to modify + /// Function to apply + /// Orignal type of the elements in the array + /// Output type of the elements of the array + /// New array with updated elements + public static TOut[] Map(this IReadOnlyCollection col, Func f) + { + var toReturn = new TOut[col.Count]; + + var i = 0; + foreach (var item in col) + toReturn[i++] = f(item); + + return toReturn; + } + + public static T? RandomOrDefault(this T[] data) + { + if (data.Length == 0) + return default; + + var index = RandomNumberGenerator.GetInt32(0, data.Length); + return data[index]; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..4e4c786 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; + +namespace Ellie.Common; + +public static class EnumerableExtensions +{ + /// + /// Concatenates the members of a collection, using the specified separator between each member. + /// + /// Collection to join + /// + /// The character to use as a separator. separator is included in the returned string only if + /// values has more than one element. + /// + /// Optional transformation to apply to each element before concatenation. + /// The type of the members of values. + /// + /// A string that consists of the members of values delimited by the separator character. -or- Empty if values has + /// no elements. + /// + public static string Join(this IEnumerable data, char separator, Func? func = null) + => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty))); + + /// + /// Concatenates the members of a collection, using the specified separator between each member. + /// + /// Collection to join + /// + /// The string to use as a separator.separator is included in the returned string only if values + /// has more than one element. + /// + /// Optional transformation to apply to each element before concatenation. + /// The type of the members of values. + /// + /// A string that consists of the members of values delimited by the separator character. -or- Empty if values has + /// no elements. + /// + public static string Join(this IEnumerable data, string separator, Func? func = null) + => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty))); + + /// + /// Randomize element order by performing the Fisher-Yates shuffle + /// + /// Item type + /// Items to shuffle + public static IReadOnlyList Shuffle(this IEnumerable items) + { + var list = items.ToArray(); + var n = list.Length; + while (n-- > 1) + { + var k = RandomNumberGenerator.GetInt32(n); + (list[k], list[n]) = (list[n], list[k]); + } + + return list; + } + + /// + /// Initializes a new instance of the class + /// that contains elements copied from the specified + /// has the default concurrency level, has the default initial capacity, + /// and uses the default comparer for the key type. + /// + /// + /// The whose elements are copied to the new + /// . + /// + /// A new instance of the class + public static ConcurrentDictionary ToConcurrent( + this IEnumerable> dict) + where TKey : notnull + => new(dict); + + public static IndexedCollection ToIndexed(this IEnumerable enumerable) + where T : class, IIndexed + => new(enumerable); + + /// + /// Creates a task that will complete when all of the objects in an enumerable + /// collection have completed + /// + /// The tasks to wait on for completion. + /// The type of the completed task. + /// A task that represents the completion of all of the supplied tasks. + public static Task WhenAll(this IEnumerable> tasks) + => Task.WhenAll(tasks); + + /// + /// Creates a task that will complete when all of the objects in an enumerable + /// collection have completed + /// + /// The tasks to wait on for completion. + /// A task that represents the completion of all of the supplied tasks. + public static Task WhenAll(this IEnumerable tasks) + => Task.WhenAll(tasks); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs new file mode 100644 index 0000000..487afe7 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public static class Extensions +{ + public static long ToTimestamp(this in DateTime value) + => (value.Ticks - 621355968000000000) / 10000000; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..38e6396 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Headers; + +namespace Ellie.Common; + +public static class HttpClientExtensions +{ + public static HttpClient AddFakeHeaders(this HttpClient http) + { + AddFakeHeaders(http.DefaultRequestHeaders); + return http; + } + + public static void AddFakeHeaders(this HttpHeaders dict) + { + dict.Clear(); + dict.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"); + dict.Add("User-Agent", + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/14.0.835.202 Safari/535.1"); + } + + public static bool IsImage(this HttpResponseMessage msg) + => IsImage(msg, out _); + + public static bool IsImage(this HttpResponseMessage msg, out string? mimeType) + { + mimeType = msg.Content.Headers.ContentType?.MediaType; + if (mimeType is "image/png" or "image/jpeg" or "image/gif") + return true; + + return false; + } + + public static long GetContentLength(this HttpResponseMessage msg) + => msg.Content.Headers.ContentLength ?? long.MaxValue; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs new file mode 100644 index 0000000..f9c4cde --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs @@ -0,0 +1,10 @@ +using OneOf.Types; +using OneOf; + +namespace Ellie.Common; + +public static class OneOfExtensions +{ + public static bool TryGetValue(this OneOf oneOf, out T value) + => oneOf.TryPickT0(out value, out _); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs new file mode 100644 index 0000000..215a829 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs @@ -0,0 +1,22 @@ +namespace Ellie.Common; + +public delegate TOut PipeFunc(in TIn a); +public delegate TOut PipeFunc(in TIn1 a, in TIn2 b); + +public static class PipeExtensions +{ + public static TOut Pipe(this TIn a, Func fn) + => fn(a); + + public static TOut Pipe(this TIn a, PipeFunc fn) + => fn(a); + + public static TOut Pipe(this (TIn1, TIn2) a, PipeFunc fn) + => fn(a.Item1, a.Item2); + + public static (TIn, TExtra) With(this TIn a, TExtra b) + => (a, b); + + public static async Task Pipe(this Task a, Func fn) + => fn(await a); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs new file mode 100644 index 0000000..95baadb --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs @@ -0,0 +1,150 @@ +using EllieBot.Common.Yml; +using System.Text; +using System.Text.RegularExpressions; + +namespace EllieBot.Extensions; + +public static class StringExtensions +{ + private static readonly HashSet _lettersAndDigits = + [ + ..Enumerable.Range(48, 10) + .Concat(Enumerable.Range(65, 26)) + .Concat(Enumerable.Range(97, 26)) + .Select(x => (char)x) + ]; + + private static readonly Regex _filterRegex = new(@"discord(?:\.gg|\.io|\.me|\.li|(?:app)?\.com\/invite)\/(\w+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex _codePointRegex = + new(@"(\\U(?[a-zA-Z0-9]{8})|\\u(?[a-zA-Z0-9]{4})|\\x(?[a-zA-Z0-9]{2}))", + RegexOptions.Compiled); + + public static string PadBoth(this string str, int length) + { + var spaces = length - str.Length; + var padLeft = (spaces / 2) + str.Length; + return str.PadLeft(padLeft, ' ').PadRight(length, ' '); + } + + public static string StripHtml(this string input) + => Regex.Replace(input, "<.*?>", string.Empty); + + public static string? TrimTo(this string? str, int maxLength, bool hideDots = false) + { + if (hideDots) + { + return str?.Substring(0, Math.Min(str?.Length ?? 0, maxLength)); + } + + if (str is null || str.Length <= maxLength) + return str; + + return string.Concat(str.AsSpan(0, maxLength - 1), "…"); + } + + public static string ToTitleCase(this string str) + { + var tokens = str.Split([" "], StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokens.Length; i++) + { + var token = tokens[i]; + tokens[i] = token[..1].ToUpperInvariant() + token[1..]; + } + + return tokens.Join(" ").Replace(" Of ", " of ").Replace(" The ", " the "); + } + + //http://www.dotnetperls.com/levenshtein + public static int LevenshteinDistance(this string s, string t) + { + var n = s.Length; + var m = t.Length; + var d = new int[n + 1, m + 1]; + + // Step 1 + if (n == 0) + return m; + + if (m == 0) + return n; + + // Step 2 + for (var i = 0; i <= n; d[i, 0] = i++) + { + } + + for (var j = 0; j <= m; d[0, j] = j++) + { + } + + // Step 3 + for (var i = 1; i <= n; i++) + //Step 4 + for (var j = 1; j <= m; j++) + { + // Step 5 + var cost = t[j - 1] == s[i - 1] ? 0 : 1; + + // Step 6 + d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost); + } + + // Step 7 + return d[n, m]; + } + + public static async Task ToStream(this string str) + { + var ms = new MemoryStream(); + var sw = new StreamWriter(ms); + await sw.WriteAsync(str); + await sw.FlushAsync(); + ms.Position = 0; + return ms; + } + + public static bool IsDiscordInvite(this string str) + => _filterRegex.IsMatch(str); + + public static string Unmention(this string str) + => str.Replace("@", "ම", StringComparison.InvariantCulture); + + public static string SanitizeMentions(this string str, bool sanitizeRoleMentions = false) + { + str = str.Replace("@everyone", "@everyοne", StringComparison.InvariantCultureIgnoreCase) + .Replace("@here", "@һere", StringComparison.InvariantCultureIgnoreCase); + if (sanitizeRoleMentions) + str = str.SanitizeRoleMentions(); + + return str; + } + + public static string SanitizeRoleMentions(this string str) + => str.Replace("<@&", "<ම&", StringComparison.InvariantCultureIgnoreCase); + + public static string SanitizeAllMentions(this string str) + => str.SanitizeMentions().SanitizeRoleMentions(); + + public static string ToBase64(this string plainText) + { + var plainTextBytes = Encoding.UTF8.GetBytes(plainText); + return Convert.ToBase64String(plainTextBytes); + } + + public static string GetInitials(this string txt, string glue = "") + => txt.Split(' ').Select(x => x.FirstOrDefault()).Join(glue); + + public static bool IsAlphaNumeric(this string txt) + => txt.All(c => _lettersAndDigits.Contains(c)); + + public static string UnescapeUnicodeCodePoints(this string input) + => _codePointRegex.Replace(input, + me => + { + var str = me.Groups["code"].Value; + var newString = str.UnescapeUnicodeCodePoint(); + return newString; + }); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs new file mode 100644 index 0000000..8983740 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs @@ -0,0 +1,35 @@ +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; +using System.Text; + +namespace Ellie.Common; + +public static class LogSetup +{ + public static void SetupLogger(object source) + { + Log.Logger = new LoggerConfiguration().MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("System", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.Console(LogEventLevel.Information, + theme: GetTheme(), + outputTemplate: + "[{Timestamp:HH:mm:ss} {Level:u3}] | #{LogSource} | {Message:lj}{NewLine}{Exception}") + .Enrich.WithProperty("LogSource", source) + .CreateLogger(); + + Console.OutputEncoding = Encoding.UTF8; + } + + private static ConsoleTheme GetTheme() + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + return AnsiConsoleTheme.Code; +#if DEBUG + return AnsiConsoleTheme.Code; +#else + return ConsoleTheme.None; +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs new file mode 100644 index 0000000..143c149 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public static class StandardConversions +{ + public static double CelsiusToFahrenheit(double cel) + => (cel * 1.8f) + 32; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/Kwum.cs b/src/EllieBot/_common/Abstractions/Kwum.cs new file mode 100644 index 0000000..267d38b --- /dev/null +++ b/src/EllieBot/_common/Abstractions/Kwum.cs @@ -0,0 +1,100 @@ +using System.Runtime.CompilerServices; + +namespace Ellie.Common; + +// needs proper invalid input check (character array input out of range) +// needs negative number support +// ReSharper disable once InconsistentNaming +#pragma warning disable CS8981 +public readonly struct kwum : IEquatable +#pragma warning restore CS8981 +{ + private const string VALID_CHARACTERS = "23456789abcdefghijkmnpqrstuvwxyz"; + private readonly int _value; + + public kwum(int num) + => _value = num; + + public kwum(in char c) + { + if (!IsValidChar(c)) + throw new ArgumentException("Character needs to be a valid kwum character.", nameof(c)); + + _value = InternalCharToValue(c); + } + + public kwum(in ReadOnlySpan input) + { + _value = 0; + for (var index = 0; index < input.Length; index++) + { + var c = input[index]; + if (!IsValidChar(c)) + throw new ArgumentException("All characters need to be a valid kwum characters.", nameof(input)); + + _value += VALID_CHARACTERS.IndexOf(c) * (int)Math.Pow(VALID_CHARACTERS.Length, input.Length - index - 1); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int InternalCharToValue(in char c) + => VALID_CHARACTERS.IndexOf(c); + + public static bool TryParse(in ReadOnlySpan input, out kwum value) + { + value = default; + foreach (var c in input) + { + if (!IsValidChar(c)) + return false; + } + + value = new(input); + return true; + } + + public static kwum operator +(kwum left, kwum right) + => new(left._value + right._value); + + public static bool operator ==(kwum left, kwum right) + => left._value == right._value; + + public static bool operator !=(kwum left, kwum right) + => !(left == right); + + public static implicit operator long(kwum kwum) + => kwum._value; + + public static implicit operator int(kwum kwum) + => kwum._value; + + public static implicit operator kwum(int num) + => new(num); + + public static bool IsValidChar(char c) + => VALID_CHARACTERS.Contains(c); + + public override string ToString() + { + var count = VALID_CHARACTERS.Length; + var localValue = _value; + var arrSize = (int)Math.Log(localValue, count) + 1; + Span chars = new char[arrSize]; + while (localValue > 0) + { + localValue = Math.DivRem(localValue, count, out var rem); + chars[--arrSize] = VALID_CHARACTERS[rem]; + } + + return new(chars); + } + + public override bool Equals(object? obj) + => obj is kwum kw && kw == this; + + public bool Equals(kwum other) + => other == this; + + public override int GetHashCode() + => _value.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs new file mode 100644 index 0000000..a3d753a --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs @@ -0,0 +1,80 @@ +namespace Ellie.Common; + +public class EventPubSub : IPubSub +{ + private readonly Dictionary>>> _actions = new(); + private readonly object _locker = new(); + + public Task Sub(in TypedKey key, Func action) + where TData : notnull + { + Func localAction = obj => action((TData)obj); + lock (_locker) + { + if (!_actions.TryGetValue(key.Key, out var keyActions)) + { + keyActions = new(); + _actions[key.Key] = keyActions; + } + + if (!keyActions.TryGetValue(action, out var sameActions)) + { + sameActions = new(); + keyActions[action] = sameActions; + } + + sameActions.Add(localAction); + + return Task.CompletedTask; + } + } + + public Task Pub(in TypedKey key, TData data) + where TData : notnull + { + lock (_locker) + { + if (_actions.TryGetValue(key.Key, out var actions)) + // if this class ever gets used, this needs to be properly implemented + // 1. ignore all valuetasks which are completed + // 2. run all other tasks in parallel + return actions.SelectMany(kvp => kvp.Value).Select(action => action(data).AsTask()).WhenAll(); + + return Task.CompletedTask; + } + } + + public Task Unsub(in TypedKey key, Func action) + { + lock (_locker) + { + // get subscriptions for this action + if (_actions.TryGetValue(key.Key, out var actions)) + // get subscriptions which have the same action hash code + // note: having this as a list allows for multiple subscriptions of + // the same insance's/static method + { + if (actions.TryGetValue(action, out var sameActions)) + { + // remove last subscription + sameActions.RemoveAt(sameActions.Count - 1); + + // if the last subscription was the only subscription + // we can safely remove this action's dictionary entry + if (sameActions.Count == 0) + { + actions.Remove(action); + + // if our dictionary has no more elements after + // removing the entry + // it's safe to remove it from the key's subscriptions + if (actions.Count == 0) + _actions.Remove(key.Key); + } + } + } + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs new file mode 100644 index 0000000..ea84b6b --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs @@ -0,0 +1,10 @@ +namespace Ellie.Common; + +public interface IPubSub +{ + public Task Pub(in TypedKey key, TData data) + where TData : notnull; + + public Task Sub(in TypedKey key, Func action) + where TData : notnull; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs new file mode 100644 index 0000000..76094f4 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs @@ -0,0 +1,7 @@ +namespace Ellie.Common; + +public interface ISeria +{ + byte[] Serialize(T data); + T? Deserialize(byte[]? data); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/QueueRunner.cs b/src/EllieBot/_common/Abstractions/QueueRunner.cs new file mode 100644 index 0000000..6e582cc --- /dev/null +++ b/src/EllieBot/_common/Abstractions/QueueRunner.cs @@ -0,0 +1,61 @@ +using System.Threading.Channels; + +namespace Ellie.Common; + +public sealed class QueueRunner +{ + private readonly Channel> _channel; + private readonly int _delayMs; + + public QueueRunner(int delayMs = 0, int maxCapacity = -1) + { + ArgumentOutOfRangeException.ThrowIfNegative(delayMs); + + _delayMs = delayMs; + _channel = maxCapacity switch + { + 0 or < -1 => throw new ArgumentOutOfRangeException(nameof(maxCapacity)), + -1 => Channel.CreateUnbounded>(new UnboundedChannelOptions() + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = true, + }), + _ => Channel.CreateBounded>(new BoundedChannelOptions(maxCapacity) + { + Capacity = maxCapacity, + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = true + }) + }; + } + + public async Task RunAsync(CancellationToken cancel = default) + { + while (true) + { + var func = await _channel.Reader.ReadAsync(cancel); + + try + { + await func(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception executing a staggered func: {ErrorMessage}", ex.Message); + } + finally + { + if (_delayMs != 0) + { + await Task.Delay(_delayMs, cancel); + } + } + } + } + + public ValueTask EnqueueAsync(Func action) + => _channel.Writer.WriteAsync(action); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/TypedKey.cs b/src/EllieBot/_common/Abstractions/TypedKey.cs new file mode 100644 index 0000000..ea3103c --- /dev/null +++ b/src/EllieBot/_common/Abstractions/TypedKey.cs @@ -0,0 +1,30 @@ +namespace Ellie.Common; + +public readonly struct TypedKey +{ + public string Key { get; } + + public TypedKey(in string key) + => Key = key; + + public static implicit operator TypedKey(in string input) + => new(input); + + public static implicit operator string(in TypedKey input) + => input.Key; + + public static bool operator ==(in TypedKey left, in TypedKey right) + => left.Key == right.Key; + + public static bool operator !=(in TypedKey left, in TypedKey right) + => !(left == right); + + public override bool Equals(object? obj) + => obj is TypedKey o && o == this; + + public override int GetHashCode() + => Key?.GetHashCode() ?? 0; + + public override string ToString() + => Key; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/YamlHelper.cs b/src/EllieBot/_common/Abstractions/YamlHelper.cs new file mode 100644 index 0000000..e3c39f0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/YamlHelper.cs @@ -0,0 +1,48 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +public static class YamlHelper +{ + // https://github.com/aaubry/YamlDotNet/blob/0f4cc205e8b2dd8ef6589d96de32bf608a687c6f/YamlDotNet/Core/Scanner.cs#L1687 + /// + /// This is modified code from yamldotnet's repo which handles parsing unicode code points + /// it is needed as yamldotnet doesn't support unescaped unicode characters + /// + /// Unicode code point + /// Actual character + public static string UnescapeUnicodeCodePoint(this string point) + { + var character = 0; + + // Scan the character value. + + foreach (var c in point) + { + if (!IsHex(c)) + return point; + + character = (character << 4) + AsHex(c); + } + + // Check the value and write the character. + + if (character is (>= 0xD800 and <= 0xDFFF) or > 0x10FFFF) + return point; + + return char.ConvertFromUtf32(character); + } + + public static bool IsHex(char c) + => c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f'); + + public static int AsHex(char c) + { + if (c <= '9') + return c - '0'; + + if (c <= 'F') + return c - 'A' + 10; + + return c - 'a' + 10; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs new file mode 100644 index 0000000..87db412 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs @@ -0,0 +1,78 @@ +#nullable disable +namespace EllieBot; + +public interface IBotCredentials +{ + string Token { get; } + string GoogleApiKey { get; } + ICollection OwnerIds { get; set; } + bool UsePrivilegedIntents { get; } + string RapidApiKey { get; } + + Creds.DbOptions Db { get; } + string OsuApiKey { get; } + int TotalShards { get; } + Creds.PatreonSettings Patreon { get; } + string CleverbotApiKey { get; } + string Gpt3ApiKey { get; } + RestartConfig RestartCommand { get; } + Creds.VotesSettings Votes { get; } + string BotListToken { get; } + string RedisOptions { get; } + string LocationIqApiKey { get; } + string TimezoneDbApiKey { get; } + string CoinmarketcapApiKey { get; } + string TrovoClientId { get; } + string CoordinatorUrl { get; set; } + string TwitchClientId { get; set; } + string TwitchClientSecret { get; set; } + GoogleApiConfig Google { get; set; } + BotCacheImplemenation BotCache { get; set; } +} + +public interface IVotesSettings +{ + string TopggServiceUrl { get; set; } + string TopggKey { get; set; } + string DiscordsServiceUrl { get; set; } + string DiscordsKey { get; set; } +} + +public interface IPatreonSettings +{ + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + public string CampaignId { get; set; } +} + +public interface IRestartConfig +{ + string Cmd { get; set; } + string Args { get; set; } +} + +public class RestartConfig : IRestartConfig +{ + public string Cmd { get; set; } + public string Args { get; set; } +} + +public enum BotCacheImplemenation +{ + Memory, + Redis +} + +public interface IDbOptions +{ + string Type { get; set; } + string ConnectionString { get; set; } +} + +public interface IGoogleApiConfig +{ + string SearchId { get; init; } + string ImageSearchId { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs new file mode 100644 index 0000000..ecc90f0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs @@ -0,0 +1,8 @@ +namespace EllieBot; + +public interface IBotCredsProvider +{ + public void Reload(); + public IBotCredentials GetCreds(); + public void ModifyCredsFile(Action func); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs new file mode 100644 index 0000000..6f72ab0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs @@ -0,0 +1,35 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Services; + +// public sealed record class CommandStrings +// { +// [YamlMember(Alias = "desc")] +// public string Desc { get; set; } +// +// [YamlMember(Alias = "args")] +// public string[] Args { get; set; } +// } + +public sealed record class CommandStrings +{ + [YamlMember(Alias = "desc")] + public string Desc { get; set; } + + [YamlMember(Alias = "ex")] + public string[] Examples { get; set; } + + [YamlMember(Alias = "params")] + public Dictionary[] Params { get; set; } +} + +public sealed record class CommandStringParam +{ + // [YamlMember(Alias = "type", ScalarStyle = ScalarStyle.DoubleQuoted)] + // public string Type { get; set; } + + [YamlMember(Alias = "desc", ScalarStyle = ScalarStyle.DoubleQuoted)] + public string Desc { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs new file mode 100644 index 0000000..2ded833 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs @@ -0,0 +1,16 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Common; + +/// +/// Defines methods to retrieve and reload bot strings +/// +public interface IBotStrings +{ + string GetText(string key, ulong? guildId = null, params object[] data); + string GetText(string key, CultureInfo locale, params object[] data); + void Reload(); + CommandStrings GetCommandStrings(string commandName, ulong? guildId = null); + CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs new file mode 100644 index 0000000..17f9377 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs @@ -0,0 +1,17 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Common; + +public static class BotStringsExtensions +{ + // this one is for pipe fun, see PipeExtensions.cs + public static string GetText(this IBotStrings strings, in LocStr str, in ulong guildId) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, ulong? guildId = null) + => strings.GetText(str.Key, guildId, str.Params); + + public static string GetText(this IBotStrings strings, in LocStr str, CultureInfo culture) + => strings.GetText(str.Key, culture, str.Params); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs new file mode 100644 index 0000000..87cab9f --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs @@ -0,0 +1,28 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Implemented by classes which provide localized strings in their own ways +/// +public interface IBotStringsProvider +{ + /// + /// Gets localized string + /// + /// Language name + /// String key + /// Localized string + string GetText(string localeName, string key); + + /// + /// Reloads string cache + /// + void Reload(); + + /// + /// Gets command arg examples and description + /// + /// Language name + /// Command name + CommandStrings GetCommandStrings(string localeName, string commandName); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs new file mode 100644 index 0000000..a2715f0 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs @@ -0,0 +1,17 @@ +#nullable disable + +namespace EllieBot.Services; + +/// +/// Basic interface used for classes implementing strings loading mechanism +/// +public interface IStringsSource +{ + /// + /// Gets all response strings + /// + /// Dictionary(localename, Dictionary(key, response)) + Dictionary> GetResponseStrings(); + + Dictionary> GetCommandStrings(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Abstractions/strings/LocStr.cs b/src/EllieBot/_common/Abstractions/strings/LocStr.cs new file mode 100644 index 0000000..78a8bb7 --- /dev/null +++ b/src/EllieBot/_common/Abstractions/strings/LocStr.cs @@ -0,0 +1,13 @@ +namespace EllieBot; + +public readonly struct LocStr +{ + public readonly string Key; + public readonly object[] Params; + + public LocStr(string key, params object[] data) + { + Key = key; + Params = data; + } +} \ No newline at end of file -- 2.43.0 From 29c0b4acfc1cfdf986e5bf79d1af475673976373 Mon Sep 17 00:00:00 2001 From: Toastie Date: Fri, 14 Jun 2024 00:20:21 +1200 Subject: [PATCH 013/340] Updated Db models I hate working on database stuff --- src/EllieBot/Db/EllieContext.cs | 243 +++++++++++++++--- src/EllieBot/Db/Extensions/ClubExtensions.cs | 2 +- .../Db/Extensions/DiscordUserExtensions.cs | 110 ++++---- .../Db/Extensions/GuildConfigExtensions.cs | 9 +- src/EllieBot/Db/Extensions/QuoteExtensions.cs | 2 +- .../Db/Extensions/UserXpExtensions.cs | 61 +++-- .../Db/Extensions/WarningExtensions.cs | 3 +- src/EllieBot/Db/Helpers/ActivityType.cs | 2 +- src/EllieBot/Db/Helpers/GuildPerm.cs | 4 +- src/EllieBot/Db/LevelStats.cs | 4 +- src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs | 2 + src/EllieBot/Db/Models/DiscordUser.cs | 2 +- src/EllieBot/Db/Models/FeedSub.cs | 2 +- src/EllieBot/Db/Models/FollowedStream.cs | 2 +- src/EllieBot/Db/Models/GuildConfig.cs | 1 + .../Db/Models/IgnoredVoicePresenceChannel.cs | 8 - src/EllieBot/Db/Models/LogSetting.cs | 8 +- src/EllieBot/Db/Models/Permission.cs | 5 +- src/EllieBot/Db/Models/ShopEntry.cs | 2 +- src/EllieBot/Db/Models/StreamRoleSettings.cs | 6 + src/EllieBot/Db/Models/Waifu.cs | 4 +- src/EllieBot/Db/Models/anti/AntiAltSetting.cs | 3 +- .../Db/Models/anti/AntiRaidSetting.cs | 5 +- .../Db/Models/anti/AntiSpamSetting.cs | 3 +- src/EllieBot/Db/Models/club/ClubInfo.cs | 2 +- .../Db/Models/filter/FilterChannelId.cs | 14 - .../Db/Models/filter/FilterWordsChannelId.cs | 17 ++ src/EllieBot/Db/Models/support/PatronQuota.cs | 6 +- src/EllieBot/Db/Models/xp/XpSettings.cs | 2 + src/EllieBot/Db/MysqlContext.cs | 2 +- src/EllieBot/Db/PostgreSqlContext.cs | 4 +- src/EllieBot/Db/SqliteContext.cs | 2 +- 32 files changed, 357 insertions(+), 185 deletions(-) delete mode 100644 src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs create mode 100644 src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs diff --git a/src/EllieBot/Db/EllieContext.cs b/src/EllieBot/Db/EllieContext.cs index d61d2f7..263963b 100644 --- a/src/EllieBot/Db/EllieContext.cs +++ b/src/EllieBot/Db/EllieContext.cs @@ -28,7 +28,6 @@ public abstract class EllieContext : DbContext //logging public DbSet LogSettings { get; set; } - public DbSet IgnoredVoicePresenceCHannels { get; set; } public DbSet IgnoredLogChannels { get; set; } public DbSet RotatingStatus { get; set; } @@ -86,15 +85,84 @@ public abstract class EllieContext : DbContext #region GuildConfig var configEntity = modelBuilder.Entity(); + configEntity.HasIndex(c => c.GuildId) .IsUnique(); configEntity.Property(x => x.VerboseErrors) .HasDefaultValue(true); - modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiSpamSetting); + modelBuilder.Entity() + .HasMany(x => x.DelMsgOnCmdChannels) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.AntiRaidSetting); + modelBuilder.Entity() + .HasMany(x => x.FollowedStreams) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.GenerateCurrencyChannelIds) + .WithOne(x => x.GuildConfig) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Permissions) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CommandCooldowns) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterInvitesChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterLinksChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilteredWords) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FilterWordsChannelIds) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.MutedUsers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(x => x.AntiRaidSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + // start antispam + + modelBuilder.Entity() + .HasOne(x => x.AntiSpamSetting) + .WithOne() + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.IgnoredChannels) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // end antispam modelBuilder.Entity() .HasOne(x => x.AntiAltSetting) @@ -102,6 +170,98 @@ public abstract class EllieContext : DbContext .HasForeignKey(x => x.GuildConfigId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasMany(x => x.UnmuteTimers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.UnbanTimer) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.UnroleTimer) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.VcRoleInfos) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CommandAliases) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.WarnPunishments) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SlowmodeIgnoredRoles) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SlowmodeIgnoredUsers) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // start shop + modelBuilder.Entity() + .HasMany(x => x.ShopEntries) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Items) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + + // end shop + + // start streamrole + + modelBuilder.Entity() + .HasOne(x => x.StreamRole) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Whitelist) + .WithOne(x => x.StreamRoleSettings) + .HasForeignKey(x => x.StreamRoleSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.Blacklist) + .WithOne(x => x.StreamRoleSettings) + .HasForeignKey(x => x.StreamRoleSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + // end streamrole + + modelBuilder.Entity() + .HasOne(x => x.XpSettings) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.FeedSubs) + .WithOne(x => x.GuildConfig) + .HasForeignKey(x => x.GuildConfigId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.SelfAssignableRoleGroupNames) + .WithOne() + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() .HasAlternateKey(x => new { @@ -117,11 +277,6 @@ public abstract class EllieContext : DbContext #endregion - #region streamrole - - modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.StreamRole); - - #endregion #region Self Assignable Roles @@ -217,12 +372,6 @@ public abstract class EllieContext : DbContext #endregion - #region XpSettings - - modelBuilder.Entity().HasOne(x => x.GuildConfig).WithOne(x => x.XpSettings); - - #endregion - #region XpRoleReward modelBuilder.Entity() @@ -233,6 +382,21 @@ public abstract class EllieContext : DbContext }) .IsUnique(); + modelBuilder.Entity() + .HasMany(x => x.RoleRewards) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.CurrencyRewards) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(x => x.ExclusionList) + .WithOne(x => x.XpSettings) + .OnDelete(DeleteBehavior.Cascade); + #endregion #region Club @@ -331,9 +495,9 @@ public abstract class EllieContext : DbContext modelBuilder.Entity().HasIndex(x => x.GuildId).IsUnique(); modelBuilder.Entity() - .Property(x => x.PruneDays) - .HasDefaultValue(null) - .IsRequired(false); + .Property(x => x.PruneDays) + .HasDefaultValue(null) + .IsRequired(false); #endregion @@ -458,7 +622,7 @@ public abstract class EllieContext : DbContext model.ItemType, model.ItemKey }) - .IsUnique(); + .IsUnique(); }); #endregion @@ -466,16 +630,16 @@ public abstract class EllieContext : DbContext #region AutoPublish modelBuilder.Entity(apc => apc - .HasIndex(x => x.GuildId) - .IsUnique()); + .HasIndex(x => x.GuildId) + .IsUnique()); #endregion #region GamblingStats modelBuilder.Entity(gs => gs - .HasIndex(x => x.Feature) - .IsUnique()); + .HasIndex(x => x.Feature) + .IsUnique()); #endregion @@ -485,7 +649,8 @@ public abstract class EllieContext : DbContext { x.GuildId, x.UserId - }).IsUnique()); + }) + .IsUnique()); #endregion @@ -493,18 +658,18 @@ public abstract class EllieContext : DbContext #region Giveaway modelBuilder.Entity() - .HasMany(x => x.Participants) - .WithOne() - .HasForeignKey(x => x.GiveawayId) - .OnDelete(DeleteBehavior.Cascade); + .HasMany(x => x.Participants) + .WithOne() + .HasForeignKey(x => x.GiveawayId) + .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity(gu => gu - .HasIndex(x => new - { - x.GiveawayId, - x.UserId - }) - .IsUnique()); + .HasIndex(x => new + { + x.GiveawayId, + x.UserId + }) + .IsUnique()); #endregion @@ -514,14 +679,14 @@ public abstract class EllieContext : DbContext .HasKey(x => x.Id); modelBuilder.Entity() - .HasIndex(x => x.UserId) - .IsUnique(false); + .HasIndex(x => x.UserId) + .IsUnique(false); modelBuilder.Entity() - .HasMany(x => x.Items) - .WithOne() - .HasForeignKey(x => x.ArchiveId) - .OnDelete(DeleteBehavior.Cascade); + .HasMany(x => x.Items) + .WithOne() + .HasForeignKey(x => x.ArchiveId) + .OnDelete(DeleteBehavior.Cascade); #endregion } diff --git a/src/EllieBot/Db/Extensions/ClubExtensions.cs b/src/EllieBot/Db/Extensions/ClubExtensions.cs index d8183fc..88a851b 100644 --- a/src/EllieBot/Db/Extensions/ClubExtensions.cs +++ b/src/EllieBot/Db/Extensions/ClubExtensions.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable using Microsoft.EntityFrameworkCore; using EllieBot.Db.Models; diff --git a/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs index 6d74316..bdf3c05 100644 --- a/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs +++ b/src/EllieBot/Db/Extensions/DiscordUserExtensions.cs @@ -20,48 +20,48 @@ public static class DiscordUserExtensions string discrim, string avatarId) => ctx.GetTable() - .InsertOrUpdate( - () => new() - { - UserId = userId, - Username = username, - Discriminator = discrim, - AvatarId = avatarId, - TotalXp = 0, - CurrencyAmount = 0 - }, - old => new() - { - Username = username, - Discriminator = discrim, - AvatarId = avatarId - }, - () => new() - { - UserId = userId - }); + .InsertOrUpdate( + () => new() + { + UserId = userId, + Username = username, + Discriminator = discrim, + AvatarId = avatarId, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + Username = username, + Discriminator = discrim, + AvatarId = avatarId + }, + () => new() + { + UserId = userId + }); public static Task EnsureUserCreatedAsync( this DbContext ctx, ulong userId) => ctx.GetTable() - .InsertOrUpdateAsync( - () => new() - { - UserId = userId, - Username = "Unknown", - Discriminator = "????", - AvatarId = string.Empty, - TotalXp = 0, - CurrencyAmount = 0 - }, - old => new() - { - }, - () => new() - { - UserId = userId - }); + .InsertOrUpdateAsync( + () => new() + { + UserId = userId, + Username = "Unknown", + Discriminator = "????", + AvatarId = string.Empty, + TotalXp = 0, + CurrencyAmount = 0 + }, + old => new() + { + }, + () => new() + { + UserId = userId + }); //temp is only used in updatecurrencystate, so that i don't overwrite real usernames/discrims with Unknown public static DiscordUser GetOrCreateUser( @@ -83,25 +83,29 @@ public static class DiscordUserExtensions public static int GetUserGlobalRank(this DbSet users, ulong id) => users.AsQueryable() - .Where(x => x.TotalXp - > users.AsQueryable().Where(y => y.UserId == id).Select(y => y.TotalXp).FirstOrDefault()) - .Count() + .Where(x => x.TotalXp + > users.AsQueryable().Where(y => y.UserId == id).Select(y => y.TotalXp).FirstOrDefault()) + .Count() + 1; - public static DiscordUser[] GetUsersXpLeaderboardFor(this DbSet users, int page, int perPage) - => users.AsQueryable().OrderByDescending(x => x.TotalXp).Skip(page * perPage).Take(perPage).AsEnumerable() - .ToArray(); + public static async Task> GetUsersXpLeaderboardFor(this DbSet users, int page, int perPage) + => await users.ToLinqToDBTable() + .OrderByDescending(x => x.TotalXp) + .Skip(page * perPage) + .Take(perPage) + .ToArrayAsyncLinqToDB(); public static Task> GetTopRichest( this DbSet users, ulong botId, - int page = 0, int perPage = 9) + int page = 0, + int perPage = 9) => users.AsQueryable() - .Where(c => c.CurrencyAmount > 0 && botId != c.UserId) - .OrderByDescending(c => c.CurrencyAmount) - .Skip(page * perPage) - .Take(perPage) - .ToListAsyncLinqToDB(); + .Where(c => c.CurrencyAmount > 0 && botId != c.UserId) + .OrderByDescending(c => c.CurrencyAmount) + .Skip(page * perPage) + .Take(perPage) + .ToListAsyncLinqToDB(); public static async Task GetUserCurrencyAsync(this DbSet users, ulong userId) => (await users.FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId))?.CurrencyAmount ?? 0; @@ -118,8 +122,8 @@ public static class DiscordUserExtensions public static decimal GetTopOnePercentCurrency(this DbSet users, ulong botId) => users.AsQueryable() - .Where(x => x.UserId != botId) - .OrderByDescending(x => x.CurrencyAmount) - .Take(users.Count() / 100 == 0 ? 1 : users.Count() / 100) - .Sum(x => x.CurrencyAmount); + .Where(x => x.UserId != botId) + .OrderByDescending(x => x.CurrencyAmount) + .Take(users.Count() / 100 == 0 ? 1 : users.Count() / 100) + .Sum(x => x.CurrencyAmount); } \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs index bffa943..7d16127 100644 --- a/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs +++ b/src/EllieBot/Db/Extensions/GuildConfigExtensions.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable using Microsoft.EntityFrameworkCore; using EllieBot.Db.Models; @@ -7,19 +7,20 @@ namespace EllieBot.Db; public static class GuildConfigExtensions { private static List DefaultWarnPunishments - => new() - { + => + [ new() { Count = 3, Punishment = PunishmentAction.Kick }, + new() { Count = 5, Punishment = PunishmentAction.Ban } - }; + ]; /// /// Gets full stream role settings for the guild with the specified id. diff --git a/src/EllieBot/Db/Extensions/QuoteExtensions.cs b/src/EllieBot/Db/Extensions/QuoteExtensions.cs index 67698e9..a213e6d 100644 --- a/src/EllieBot/Db/Extensions/QuoteExtensions.cs +++ b/src/EllieBot/Db/Extensions/QuoteExtensions.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable using Microsoft.EntityFrameworkCore; using EllieBot.Db.Models; diff --git a/src/EllieBot/Db/Extensions/UserXpExtensions.cs b/src/EllieBot/Db/Extensions/UserXpExtensions.cs index c7e0f8c..ab3e8bb 100644 --- a/src/EllieBot/Db/Extensions/UserXpExtensions.cs +++ b/src/EllieBot/Db/Extensions/UserXpExtensions.cs @@ -26,33 +26,33 @@ public static class UserXpExtensions return usr; } - public static List GetUsersFor(this DbSet xps, ulong guildId, int page) - => xps.AsQueryable() - .AsNoTracking() - .Where(x => x.GuildId == guildId) - .OrderByDescending(x => x.Xp + x.AwardedXp) - .Skip(page * 9) - .Take(9) - .ToList(); + public static async Task> GetUsersFor( + this DbSet xps, + ulong guildId, + int page) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Skip(page * 9) + .Take(9) + .ToArrayAsyncLinqToDB(); - public static List GetTopUserXps(this DbSet xps, ulong guildId, int count) - => xps.AsQueryable() - .AsNoTracking() - .Where(x => x.GuildId == guildId) - .OrderByDescending(x => x.Xp + x.AwardedXp) - .Take(count) - .ToList(); + public static async Task> GetTopUserXps(this DbSet xps, ulong guildId, int count) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId) + .OrderByDescending(x => x.Xp + x.AwardedXp) + .Take(count) + .ToListAsyncLinqToDB(); - public static int GetUserGuildRanking(this DbSet xps, ulong userId, ulong guildId) - => xps.AsQueryable() - .AsNoTracking() - .Where(x => x.GuildId == guildId - && x.Xp + x.AwardedXp - > xps.AsQueryable() - .Where(y => y.UserId == userId && y.GuildId == guildId) - .Select(y => y.Xp + y.AwardedXp) - .FirstOrDefault()) - .Count() + public static async Task GetUserGuildRanking(this DbSet xps, ulong userId, ulong guildId) + => await xps.ToLinqToDBTable() + .Where(x => x.GuildId == guildId + && x.Xp + x.AwardedXp + > xps.AsQueryable() + .Where(y => y.UserId == userId && y.GuildId == guildId) + .Select(y => y.Xp + y.AwardedXp) + .FirstOrDefault()) + .CountAsyncLinqToDB() + 1; public static void ResetGuildUserXp(this DbSet xps, ulong userId, ulong guildId) @@ -62,10 +62,9 @@ public static class UserXpExtensions => xps.Delete(x => x.GuildId == guildId); public static async Task GetLevelDataFor(this ITable userXp, ulong guildId, ulong userId) - => await userXp - .Where(x => x.GuildId == guildId && x.UserId == userId) - .FirstOrDefaultAsyncLinqToDB() is UserXpStats uxs - ? new(uxs.Xp + uxs.AwardedXp) - : new(0); - + => await userXp + .Where(x => x.GuildId == guildId && x.UserId == userId) + .FirstOrDefaultAsyncLinqToDB() is UserXpStats uxs + ? new(uxs.Xp + uxs.AwardedXp) + : new(0); } \ No newline at end of file diff --git a/src/EllieBot/Db/Extensions/WarningExtensions.cs b/src/EllieBot/Db/Extensions/WarningExtensions.cs index c223f7d..15f1039 100644 --- a/src/EllieBot/Db/Extensions/WarningExtensions.cs +++ b/src/EllieBot/Db/Extensions/WarningExtensions.cs @@ -22,8 +22,7 @@ public static class WarningExtensions string mod, int index) { - if (index < 0) - throw new ArgumentOutOfRangeException(nameof(index)); + ArgumentOutOfRangeException.ThrowIfNegative(index); var warn = warnings.AsQueryable() .Where(x => x.GuildId == guildId && x.UserId == userId) diff --git a/src/EllieBot/Db/Helpers/ActivityType.cs b/src/EllieBot/Db/Helpers/ActivityType.cs index 64bdd73..9c71e4b 100644 --- a/src/EllieBot/Db/Helpers/ActivityType.cs +++ b/src/EllieBot/Db/Helpers/ActivityType.cs @@ -1,4 +1,4 @@ -namespace EllieBot.Db; +namespace EllieBot.Db; public enum DbActivityType { diff --git a/src/EllieBot/Db/Helpers/GuildPerm.cs b/src/EllieBot/Db/Helpers/GuildPerm.cs index 4713afa..60c4cde 100644 --- a/src/EllieBot/Db/Helpers/GuildPerm.cs +++ b/src/EllieBot/Db/Helpers/GuildPerm.cs @@ -1,4 +1,4 @@ -namespace EllieBot.Db; +namespace EllieBot.Db; [Flags] public enum GuildPerm : ulong @@ -44,4 +44,4 @@ public enum GuildPerm : ulong SendMessagesInThreads = 274877906944, // 0x0000004000000000 StartEmbeddedActivities = 549755813888, // 0x0000008000000000 ModerateMembers = 1099511627776, // 0x0000010000000000 -} +} \ No newline at end of file diff --git a/src/EllieBot/Db/LevelStats.cs b/src/EllieBot/Db/LevelStats.cs index fbb2e47..0f3e02c 100644 --- a/src/EllieBot/Db/LevelStats.cs +++ b/src/EllieBot/Db/LevelStats.cs @@ -1,10 +1,10 @@ -#nullable disable +#nullable disable namespace EllieBot.Db; public readonly struct LevelStats { public const int XP_REQUIRED_LVL_1 = 36; - + public long Level { get; } public long LevelXp { get; } public long RequiredXp { get; } diff --git a/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs index 6cbe756..dd70ed6 100644 --- a/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs +++ b/src/EllieBot/Db/Models/DelMsgOnCmdChannel.cs @@ -3,6 +3,8 @@ namespace EllieBot.Db.Models; public class DelMsgOnCmdChannel : DbEntity { + public int GuildConfigId { get; set; } + public ulong ChannelId { get; set; } public bool State { get; set; } diff --git a/src/EllieBot/Db/Models/DiscordUser.cs b/src/EllieBot/Db/Models/DiscordUser.cs index 83bda60..69eecb6 100644 --- a/src/EllieBot/Db/Models/DiscordUser.cs +++ b/src/EllieBot/Db/Models/DiscordUser.cs @@ -29,7 +29,7 @@ public class DiscordUser : DbEntity { if (string.IsNullOrWhiteSpace(Discriminator) || Discriminator == "0000") return Username; - + return Username + "#" + Discriminator; } } \ No newline at end of file diff --git a/src/EllieBot/Db/Models/FeedSub.cs b/src/EllieBot/Db/Models/FeedSub.cs index 66fc6f1..f257f96 100644 --- a/src/EllieBot/Db/Models/FeedSub.cs +++ b/src/EllieBot/Db/Models/FeedSub.cs @@ -8,7 +8,7 @@ public class FeedSub : DbEntity public ulong ChannelId { get; set; } public string Url { get; set; } - + public string Message { get; set; } public override int GetHashCode() diff --git a/src/EllieBot/Db/Models/FollowedStream.cs b/src/EllieBot/Db/Models/FollowedStream.cs index c880a8d..183e0ab 100644 --- a/src/EllieBot/Db/Models/FollowedStream.cs +++ b/src/EllieBot/Db/Models/FollowedStream.cs @@ -29,5 +29,5 @@ public class FollowedStream : DbEntity public override bool Equals(object obj) => obj is FollowedStream fs && Equals(fs); - + } \ No newline at end of file diff --git a/src/EllieBot/Db/Models/GuildConfig.cs b/src/EllieBot/Db/Models/GuildConfig.cs index a7b5ac5..a88d3c0 100644 --- a/src/EllieBot/Db/Models/GuildConfig.cs +++ b/src/EllieBot/Db/Models/GuildConfig.cs @@ -3,6 +3,7 @@ namespace EllieBot.Db.Models; public class GuildConfig : DbEntity { + // public bool Keep { get; set; } public ulong GuildId { get; set; } public string Prefix { get; set; } diff --git a/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs b/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs deleted file mode 100644 index cbbda9e..0000000 --- a/src/EllieBot/Db/Models/IgnoredVoicePresenceChannel.cs +++ /dev/null @@ -1,8 +0,0 @@ -#nullable disable -namespace EllieBot.Db.Models; - -public class IgnoredVoicePresenceChannel : DbEntity -{ - public LogSetting LogSetting { get; set; } - public ulong ChannelId { get; set; } -} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/LogSetting.cs b/src/EllieBot/Db/Models/LogSetting.cs index 677a128..916b1b8 100644 --- a/src/EllieBot/Db/Models/LogSetting.cs +++ b/src/EllieBot/Db/Models/LogSetting.cs @@ -4,7 +4,7 @@ namespace EllieBot.Db.Models; public class LogSetting : DbEntity { public List LogIgnores { get; set; } = new(); - + public ulong GuildId { get; set; } public ulong? LogOtherId { get; set; } public ulong? MessageUpdatedId { get; set; } @@ -19,8 +19,8 @@ public class LogSetting : DbEntity public ulong? ChannelCreatedId { get; set; } public ulong? ChannelDestroyedId { get; set; } public ulong? ChannelUpdatedId { get; set; } - - + + public ulong? ThreadDeletedId { get; set; } public ulong? ThreadCreatedId { get; set; } @@ -32,7 +32,7 @@ public class LogSetting : DbEntity //voicepresence public ulong? LogVoicePresenceId { get; set; } - + public ulong? LogVoicePresenceTTSId { get; set; } public ulong? LogWarnsId { get; set; } } \ No newline at end of file diff --git a/src/EllieBot/Db/Models/Permission.cs b/src/EllieBot/Db/Models/Permission.cs index 5670dd8..b926f92 100644 --- a/src/EllieBot/Db/Models/Permission.cs +++ b/src/EllieBot/Db/Models/Permission.cs @@ -33,10 +33,7 @@ public class Permissionv2 : DbEntity, IIndexed }; public static List GetDefaultPermlist - => new() - { - AllowAllPerm - }; + => [AllowAllPerm]; } public enum PrimaryPermissionType diff --git a/src/EllieBot/Db/Models/ShopEntry.cs b/src/EllieBot/Db/Models/ShopEntry.cs index 34cc8fe..a21c7e6 100644 --- a/src/EllieBot/Db/Models/ShopEntry.cs +++ b/src/EllieBot/Db/Models/ShopEntry.cs @@ -25,7 +25,7 @@ public class ShopEntry : DbEntity, IIndexed //list public HashSet Items { get; set; } = new(); public ulong? RoleRequirement { get; set; } - + // command public string Command { get; set; } } diff --git a/src/EllieBot/Db/Models/StreamRoleSettings.cs b/src/EllieBot/Db/Models/StreamRoleSettings.cs index 02674d5..bd36b4c 100644 --- a/src/EllieBot/Db/Models/StreamRoleSettings.cs +++ b/src/EllieBot/Db/Models/StreamRoleSettings.cs @@ -40,6 +40,9 @@ public class StreamRoleSettings : DbEntity public class StreamRoleBlacklistedUser : DbEntity { + public int StreamRoleSettingsId { get; set; } + public StreamRoleSettings StreamRoleSettings { get; set; } + public ulong UserId { get; set; } public string Username { get; set; } @@ -57,6 +60,9 @@ public class StreamRoleBlacklistedUser : DbEntity public class StreamRoleWhitelistedUser : DbEntity { + public int StreamRoleSettingsId { get; set; } + public StreamRoleSettings StreamRoleSettings { get; set; } + public ulong UserId { get; set; } public string Username { get; set; } diff --git a/src/EllieBot/Db/Models/Waifu.cs b/src/EllieBot/Db/Models/Waifu.cs index 78ca0b3..d9f36d9 100644 --- a/src/EllieBot/Db/Models/Waifu.cs +++ b/src/EllieBot/Db/Models/Waifu.cs @@ -1,13 +1,13 @@ #nullable disable using EllieBot.Db.Models; -namespace EllieBot.Services.Database.Models; +namespace NadekoBot.Services.Database.Models; public class WaifuInfo : DbEntity { public int WaifuId { get; set; } public DiscordUser Waifu { get; set; } - + public int? ClaimerId { get; set; } public DiscordUser Claimer { get; set; } diff --git a/src/EllieBot/Db/Models/anti/AntiAltSetting.cs b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs index b9f9e58..cb0da3c 100644 --- a/src/EllieBot/Db/Models/anti/AntiAltSetting.cs +++ b/src/EllieBot/Db/Models/anti/AntiAltSetting.cs @@ -2,8 +2,9 @@ public class AntiAltSetting { - public int Id { get; set; } public int GuildConfigId { get; set; } + + public int Id { get; set; } public TimeSpan MinAge { get; set; } public PunishmentAction Action { get; set; } public int ActionDurationMinutes { get; set; } diff --git a/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs index aef2658..b5e5f67 100644 --- a/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs +++ b/src/EllieBot/Db/Models/anti/AntiRaidSetting.cs @@ -1,12 +1,13 @@ #nullable disable +using System.ComponentModel.DataAnnotations.Schema; + namespace EllieBot.Db.Models; public class AntiRaidSetting : DbEntity { public int GuildConfigId { get; set; } - public GuildConfig GuildConfig { get; set; } - + public int UserThreshold { get; set; } public int Seconds { get; set; } public PunishmentAction Action { get; set; } diff --git a/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs index 42c2183..7e19253 100644 --- a/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs +++ b/src/EllieBot/Db/Models/anti/AntiSpamSetting.cs @@ -4,8 +4,7 @@ public class AntiSpamSetting : DbEntity { public int GuildConfigId { get; set; } - public GuildConfig GuildConfig { get; set; } - + public PunishmentAction Action { get; set; } public int MessageThreshold { get; set; } = 3; public int MuteTime { get; set; } diff --git a/src/EllieBot/Db/Models/club/ClubInfo.cs b/src/EllieBot/Db/Models/club/ClubInfo.cs index e5b7407..a7bc16f 100644 --- a/src/EllieBot/Db/Models/club/ClubInfo.cs +++ b/src/EllieBot/Db/Models/club/ClubInfo.cs @@ -9,7 +9,7 @@ public class ClubInfo : DbEntity public string Name { get; set; } public string Description { get; set; } public string ImageUrl { get; set; } = string.Empty; - + public int Xp { get; set; } = 0; public int? OwnerId { get; set; } public DiscordUser Owner { get; set; } diff --git a/src/EllieBot/Db/Models/filter/FilterChannelId.cs b/src/EllieBot/Db/Models/filter/FilterChannelId.cs index fe3b97b..eb1d965 100644 --- a/src/EllieBot/Db/Models/filter/FilterChannelId.cs +++ b/src/EllieBot/Db/Models/filter/FilterChannelId.cs @@ -14,17 +14,3 @@ public class FilterChannelId : DbEntity public override int GetHashCode() => ChannelId.GetHashCode(); } - -public class FilterWordsChannelId : DbEntity -{ - public ulong ChannelId { get; set; } - - public bool Equals(FilterWordsChannelId other) - => ChannelId == other.ChannelId; - - public override bool Equals(object obj) - => obj is FilterWordsChannelId fci && Equals(fci); - - public override int GetHashCode() - => ChannelId.GetHashCode(); -} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs b/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs new file mode 100644 index 0000000..6921032 --- /dev/null +++ b/src/EllieBot/Db/Models/filter/FilterWordsChannelId.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class FilterWordsChannelId : DbEntity +{ + public int? GuildConfigId { get; set; } + public ulong ChannelId { get; set; } + + public bool Equals(FilterWordsChannelId other) + => ChannelId == other.ChannelId; + + public override bool Equals(object obj) + => obj is FilterWordsChannelId fci && Equals(fci); + + public override int GetHashCode() + => ChannelId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Db/Models/support/PatronQuota.cs b/src/EllieBot/Db/Models/support/PatronQuota.cs index b87dcbc..1ebd2fd 100644 --- a/src/EllieBot/Db/Models/support/PatronQuota.cs +++ b/src/EllieBot/Db/Models/support/PatronQuota.cs @@ -30,12 +30,12 @@ public class PatronUser public string UniquePlatformUserId { get; set; } public ulong UserId { get; set; } public int AmountCents { get; set; } - + public DateTime LastCharge { get; set; } - + // Date Only component public DateTime ValidThru { get; set; } - + public PatronUser Clone() => new PatronUser() { diff --git a/src/EllieBot/Db/Models/xp/XpSettings.cs b/src/EllieBot/Db/Models/xp/XpSettings.cs index 694b289..50fd5be 100644 --- a/src/EllieBot/Db/Models/xp/XpSettings.cs +++ b/src/EllieBot/Db/Models/xp/XpSettings.cs @@ -51,6 +51,8 @@ public class XpCurrencyReward : DbEntity public class ExcludedItem : DbEntity { + public XpSettings XpSettings { get; set; } + public ulong ItemId { get; set; } public ExcludedItemType ItemType { get; set; } diff --git a/src/EllieBot/Db/MysqlContext.cs b/src/EllieBot/Db/MysqlContext.cs index e8f4eba..7563640 100644 --- a/src/EllieBot/Db/MysqlContext.cs +++ b/src/EllieBot/Db/MysqlContext.cs @@ -28,7 +28,7 @@ public sealed class MysqlContext : EllieContext protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - + // mysql is case insensitive by default // we can set binary collation to change that modelBuilder.Entity() diff --git a/src/EllieBot/Db/PostgreSqlContext.cs b/src/EllieBot/Db/PostgreSqlContext.cs index aea3e7c..2305d19 100644 --- a/src/EllieBot/Db/PostgreSqlContext.cs +++ b/src/EllieBot/Db/PostgreSqlContext.cs @@ -6,7 +6,7 @@ public sealed class PostgreSqlContext : EllieContext { private readonly string _connStr; - protected override string CurrencyTransactionOtherIdDefaultValue + protected override string CurrencyTransactionOtherIdDefaultValue => "NULL"; public PostgreSqlContext(string connStr = "Host=localhost") @@ -17,7 +17,7 @@ public sealed class PostgreSqlContext : EllieContext protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); - + base.OnConfiguring(optionsBuilder); optionsBuilder .UseLowerCaseNamingConvention() diff --git a/src/EllieBot/Db/SqliteContext.cs b/src/EllieBot/Db/SqliteContext.cs index e284968..516d445 100644 --- a/src/EllieBot/Db/SqliteContext.cs +++ b/src/EllieBot/Db/SqliteContext.cs @@ -7,7 +7,7 @@ public sealed class SqliteContext : EllieContext { private readonly string _connectionString; - protected override string CurrencyTransactionOtherIdDefaultValue + protected override string CurrencyTransactionOtherIdDefaultValue => "NULL"; public SqliteContext(string connectionString = "Data Source=data/EllieBot.db", int commandTimeout = 60) -- 2.43.0 From 7ce7c9b4ffdb95eba101a3145adccceca6343239 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:30:39 +1200 Subject: [PATCH 014/340] Updated data files Some random stuff in these files got changed --- src/EllieBot/data/aliases.yml | 15 +- src/EllieBot/data/bot.yml | 6 +- .../data/strings/commands/commands.en-US.yml | 690 +++++++++--------- .../strings/responses/responses.en-US.json | 29 +- 4 files changed, 398 insertions(+), 342 deletions(-) diff --git a/src/EllieBot/data/aliases.yml b/src/EllieBot/data/aliases.yml index 4ec12e0..8d83875 100644 --- a/src/EllieBot/data/aliases.yml +++ b/src/EllieBot/data/aliases.yml @@ -1,6 +1,6 @@ h: - - help - h + - help gencmdlist: - gencmdlist donate: @@ -37,7 +37,7 @@ boost: boostmsg: - boostmsg boostdel: - - boostde + - boostdel logserver: - logserver logignore: @@ -195,6 +195,12 @@ setname: - newnm setnick: - setnick +setserverbanner: + - setserverbanner + - serverbanner +setservericon: + - setservericon + - servericon setavatar: - setavatar - setav @@ -335,6 +341,9 @@ allcmdcooldowns: quoteadd: - quoteadd - . +quoteedit: + - quoteedit + - qedit quoteprint: - quoteprint - .. @@ -1396,3 +1405,5 @@ todoshow: - see stickyroles: - stickyroles +cleanupguilddata: + - cleanupguilddata \ No newline at end of file diff --git a/src/EllieBot/data/bot.yml b/src/EllieBot/data/bot.yml index 81130d1..55a1d51 100644 --- a/src/EllieBot/data/bot.yml +++ b/src/EllieBot/data/bot.yml @@ -27,7 +27,7 @@ forwardMessages: false forwardToAllOwners: false # Any messages sent by users in Bot's DM to be forwarded to the specified channel. # This option will only work when ForwardToAllOwners is set to false -forwardToChannel: +forwardToChannel: # Should the bot ignore messages from other bots? # Settings this to false might get your bot banned if it gets into a spam loop with another bot. # This will only affect command executions, other features will still block bots from access. @@ -51,7 +51,7 @@ dmHelpTextKeywords: helpText: |- { "title": "To invite me to your server, use this link", - "description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", + "description": "https://discord.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", "color": 53380, "thumbnail": "https://cdn.elliebot.net/Ellie.png", "fields": [ @@ -69,7 +69,7 @@ helpText: |- }, { "name": "Ellie Support Server", - "value": "https://discord.gg/etQdZxSyEH", + "value": "https://discord.gg/etQdZxSyEH/ ", "inline": true } ] diff --git a/src/EllieBot/data/strings/commands/commands.en-US.yml b/src/EllieBot/data/strings/commands/commands.en-US.yml index 61d97ee..d64534e 100644 --- a/src/EllieBot/data/strings/commands/commands.en-US.yml +++ b/src/EllieBot/data/strings/commands/commands.en-US.yml @@ -1,8 +1,8 @@ h: desc: Either shows a help for a single command, or DMs you help link if no parameters are specified. ex: - - "{0}cmds" - - "" + - '{0}cmds' + - '' params: - fail: desc: "Fallback parameter if the command is not found." @@ -11,19 +11,19 @@ h: gencmdlist: desc: Generates the command list and sends it to the chat. Optionally also uploads it to DO spaces (not supported). ex: - - "" + - '' params: - {} donate: desc: Instructions for helping the project financially. ex: - - "" + - '' params: - {} modules: desc: Lists all bot modules. ex: - - "" + - '' params: - page: desc: "The number of the page to display in the list of bot modules." @@ -32,7 +32,7 @@ commands: ex: - Admin - Admin --view 1 - - "" + - '' params: - module: desc: "The name of a module to retrieve command information for." @@ -49,14 +49,14 @@ greetdel: greet: desc: Toggles announcements on the current channel when someone joins the server. ex: - - "" + - '' params: - {} greetmsg: desc: |- Sets a new join announcement message which will be shown in the server's channel. Type `%user.mention%` if you want to mention the new member. - Full list of placeholders can be found here + Full list of placeholders can be found here Using it with no message will show the current greet message. You can use embed json from instead of a regular text, if you want the message to be embedded. ex: @@ -67,18 +67,18 @@ greetmsg: bye: desc: Toggles announcements on the current channel when someone leaves the server. ex: - - "" + - '' params: - {} byemsg: desc: |- Sets a new leave announcement message. Type `%user.mention%` if you want to show the name the user who left. - Full list of placeholders can be found here + Full list of placeholders can be found here Using this command with no message will show the current bye message. You can use embed json from instead of a regular text, if you want the message to be embedded. ex: - - "%user.mention% has left." + - '%user.mention% has left.' params: - text: desc: "The user's farewell message to display when they leave the chat." @@ -93,48 +93,48 @@ byedel: greetdm: desc: Toggles whether the greet messages will be sent in a DM (This is separate from greet - you can have both, any or neither enabled). ex: - - "" + - '' params: - {} greettest: desc: Sends the greet message in the current channel as if you just joined the server. You can optionally specify a different user. ex: - - "" - - "@SomeoneElse" + - '' + - '@SomeoneElse' params: - user: desc: "The user to impersonate when sending the greeting, or null for the bot's own account." greetdmtest: desc: Sends the greet direct message to you as if you just joined the server. You can optionally specify a different user. ex: - - "" - - "@SomeoneElse" + - '' + - '@SomeoneElse' params: - user: desc: "The recipient of the greeting, which defaults to the caller if not specified." byetest: desc: Sends the bye message in the current channel as if you just left the server. You can optionally specify a different user. ex: - - "" - - "@SomeoneElse" + - '' + - '@SomeoneElse' params: - user: desc: "The user who is leaving the channel, or whose account is being represented as leaving the channel." boost: desc: Toggles announcements on the current channel when someone boosts the server. ex: - - "" + - '' params: - {} boostmsg: desc: |- Sets a new boost announcement message. Type `%user.mention%` if you want to show the name the user who left. - Full list of placeholders can be found here + Full list of placeholders can be found here Using this command with no message will show the current boost message. You can use embed json from instead of a regular text, if you want the message to be embedded. ex: - - "%user.mention% has boosted the server!!!" + - '%user.mention% has boosted the server!!!' params: - text: desc: "The text to set as the new announcement message." @@ -157,9 +157,9 @@ logserver: logignore: desc: Toggles whether the `{0}logserver` command ignores the specified channel or user. Provide no arguments to see the list of currently ignored users and channels ex: - - "" - - "@SomeUser" - - "#some-channel" + - '' + - '@SomeUser' + - '#some-channel' params: - {} - target: @@ -169,7 +169,7 @@ logignore: repeatlist: desc: Shows currently repeating messages and their indexes. ex: - - "" + - '' params: - {} repeatremove: @@ -187,12 +187,19 @@ repeatinvoke: - index: desc: "The index at which to display the repeat message." repeat: - desc: Repeat a message once every specified amount of time in the current channel. You can instead specify time of day for the message to be repeated daily (make sure you've set your server's timezone). If you've specified time of day, you can still override the default daily interval with your own interval. You can have up to 5 repeating messages on the server in total. + desc: |- + Repeat a message once every specified amount of time in the current channel. + You can specify a different channel as the first argument. + You can instead specify time of day for the message to be repeated daily (make sure you've set your server's timezone). + If you've specified time of day, you can still override the default daily interval with your own interval. + You can have up to 5 repeating messages on the server in total. ex: - Hello there - - 1h5m Hello @erryone - - 10:00 Daily have a nice day! This will execute once every 24h. - - 21:00 30m Starting at 21 and every 30 minutes after that i will send this message! + - '#other-channel hello there' + - '1h5m Hello @erryone' + - '10:00 Daily have a nice day! This will execute once every 24h.' + - '#other-channel 10:00 Daily have a nice day! This will execute once every 24h.' + - '21:00 30m Starting at 21 and every 30 minutes after that i will send this message!' params: - message: desc: "The text to be repeated at the specified intervals or times." @@ -204,23 +211,23 @@ repeat: desc: "The amount of time between each repetition." message: desc: "The text to be repeated at the specified intervals or times." - - ch: + - channel: desc: "The channel where the message will be sent." interval: desc: "The amount of time between each repetition." message: desc: "The text to be repeated at the specified intervals or times." - - dt: + - timeOfDay: desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." message: desc: "The text to be repeated at the specified intervals or times." - channel: desc: "The channel where the message will be repeated." - dt: + timeOfDay: desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." message: desc: "The text to be repeated at the specified intervals or times." - - dt: + - timeOfDay: desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." interval: desc: "The amount of time between each repetition." @@ -228,7 +235,7 @@ repeat: desc: "The text to be repeated at the specified intervals or times." - channel: desc: "The channel where the message will be repeated." - dt: + timeOfDay: desc: "The time at which the message should be repeated, either once every specified amount of time or at a specific time of day." interval: desc: "The amount of time between each repetition." @@ -251,7 +258,7 @@ repeatskip: rotateplaying: desc: Toggles rotation of playing status of the dynamic strings you previously specified. ex: - - "" + - '' params: - {} addplaying: @@ -267,27 +274,27 @@ addplaying: listplaying: desc: Lists all playing statuses with their corresponding number. ex: - - "" + - '' params: - {} removeplaying: desc: Removes a playing string on a given number. ex: - - "" + - '' params: - index: desc: "The position in the list where the playing string should be removed." vcrolelist: desc: Shows a list of currently set voice channel roles. ex: - - "" + - '' params: - {} vcrole: desc: Sets or resets a role which will be given to users who join the voice channel you're in when you run this command. Provide no role name to disable. You must be in a voice channel to run this command. ex: - SomeRole - - "" + - '' params: - role: desc: "The role that is assigned to new members of the voice channel." @@ -323,7 +330,7 @@ rsar: lsar: desc: Lists self-assignable roles. Shows 20 roles per page. ex: - - "" + - '' - 2 params: - page: @@ -341,7 +348,7 @@ sargn: togglexclsar: desc: Toggles whether the self-assigned roles are exclusive. While enabled, users can only have one self-assignable role per group. ex: - - "" + - '' params: - {} iam: @@ -359,16 +366,16 @@ iamnot: - role: desc: "The role being removed from the user's assignment." expradd: - desc: "Add an expression with a trigger and a response. Bot will post a response whenever someone types the trigger word. Running this command in server requires the Administration permission. Running this command in DM is Bot Owner only and adds a new global expression. Guide here: " + desc: 'Add an expression with a trigger and a response. Bot will post a response whenever someone types the trigger word. Running this command in server requires the Administration permission. Running this command in DM is Bot Owner only and adds a new global expression. Guide here: ' ex: - '"hello" Hi there %user.mention%' params: - - key: + - trigger: desc: "The trigger word that sets off the response when typed by a user." - message: - desc: "The text of the message that triggers the response when typed by a user." + response: + desc: "The text of the message that shows up when a user types the trigger word." expraddserver: - desc: "Add an expression with a trigger and a response in this server. Bot will post a response whenever someone types the trigger word. Guide here: " + desc: 'Add an expression with a trigger and a response in this server. Bot will post a response whenever someone types the trigger word. Guide here: ' ex: - '"hello" Hi there %user.mention%' params: @@ -415,25 +422,25 @@ exprdeleteserver: exprclear: desc: Deletes all expression on this server. ex: - - "" + - '' params: - {} fwclear: desc: Deletes all filtered words on this server. ex: - - "" + - '' params: - {} filterlist: desc: Lists invite and link filter channels and status. ex: - - "" + - '' params: - {} aliasesclear: desc: Deletes all aliases on this server. ex: - - "" + - '' params: - {} autoassignrole: @@ -443,7 +450,7 @@ autoassignrole: Specifying the role that is already added will remove that role from the list. Provide no parameters to list current roles. ex: - - "" + - '' - RoleName params: - role: @@ -459,7 +466,7 @@ leave: slowmode: desc: Toggles slowmode on the current channel with the specified amount of time. Provide no parameters to disable. ex: - - "" + - '' - 27s - 3h15m5s params: @@ -468,7 +475,7 @@ slowmode: delmsgoncmd: desc: "Toggles the automatic deletion of the user's successful command message to prevent chat flood. You can use it either as a server toggle, channel whitelist, or channel blacklist, as channel option has 3 settings: Enable (always do it on this channel), Disable (never do it on this channel), and Inherit (respect server setting). Use `list` parameter to see the current states." ex: - - "" + - '' - channel enable - ch inherit - list @@ -492,13 +499,13 @@ delmsgoncmd: restart: desc: Restarts the bot. Might not work. ex: - - "" + - '' params: - {} setrole: desc: Gives a role to a user. The role you specify has to be lower in the role hierarchy than your highest role. ex: - - "@User Guest" + - '@User Guest' params: - targetUser: desc: "The user being given the new role, which must have a lower rank than the assistant's highest role." @@ -507,7 +514,7 @@ setrole: removerole: desc: Removes a role from a user. The role you specify has to be lower in the role hierarchy than your highest role. ex: - - "@User Admin" + - '@User Admin' params: - targetUser: desc: "The user account being modified or checked for role eligibility." @@ -525,7 +532,7 @@ renamerole: removeallroles: desc: Removes all roles which are lower than your highest role in the role hierarchy from the user you specify. ex: - - "@User" + - '@User' params: - user: desc: "The user whose roles will be updated to reflect the new role hierarchy." @@ -566,7 +573,7 @@ rolecolor: ban: desc: Bans a user by ID or name with an optional message. You can specify a time string before the user name to ban the user temporarily. ex: - - "@Someone Get out!" + - '@Someone Get out!' - '"Some Guy#1234" Your behaviour is toxic.' - 1d12h @Someone Come back when u chill params: @@ -593,7 +600,7 @@ ban: softban: desc: Bans and then unbans a user by ID or name with an optional message. ex: - - "@Someone Get out!" + - '@Someone Get out!' - '"Some Guy#1234" Your behaviour is toxic.' params: - user: @@ -607,7 +614,7 @@ softban: kick: desc: Kicks a mentioned user. ex: - - "@Someone Get out!" + - '@Someone Get out!' - '"Some Guy#1234" Your behaviour is toxic.' params: - user: @@ -621,8 +628,8 @@ kick: timeout: desc: Times the user out for the specified amount of time. You may optionally specify a reason, which will be sent to the user. ex: - - "@Someone 3h Shut up!" - - "@Someone 1h30m" + - '@Someone 3h Shut up!' + - '@Someone 1h30m' params: - globalUser: desc: "The user's account or identity that is being timed out." @@ -633,8 +640,8 @@ timeout: mute: desc: Mutes a mentioned user both from speaking and chatting. You can also specify time string for how long the user should be muted. You can optionally specify a reason. ex: - - "@Someone" - - "@Someone too noisy" + - '@Someone' + - '@Someone too noisy' - 1h30m @Someone - 1h30m @Someone too noisy params: @@ -651,7 +658,7 @@ mute: voiceunmute: desc: Gives a previously voice-muted user a permission to speak. ex: - - "@Someguy" + - '@Someguy' params: - user: desc: "The user who was previously muted is now able to participate in the conversation again." @@ -718,14 +725,14 @@ setchanlname: prune: desc: "`{0}prune` removes all Ellie's messages in the last 100 messages. `{0}prune X` removes last `X` number of messages from the channel (up to 100). `{0}prune @Someone` removes all Someone's messages in the last 100 messages. `{0}prune @Someone X` removes last `X` number of 'Someone's' messages in the channel." ex: - - "" + - '' - -s - 5 - 5 --safe - - "@Someone" - - "@Someone --safe" - - "@Someone X" - - "@Someone X -s" + - '@Someone' + - '@Someone --safe' + - '@Someone X' + - '@Someone X -s' params: - params: desc: "The list of users, channels or message counts to be removed from the conversation history." @@ -748,13 +755,13 @@ prune: prunecancel: desc: Cancels an active prune if there is any. ex: - - "" + - '' params: - {} die: desc: Shuts the bot down. ex: - - "" + - '' params: - graceful: desc: "The option to perform a controlled shutdown, allowing for any necessary cleanup or notifications before termination." @@ -769,7 +776,7 @@ setnick: desc: Changes the nickname of the bot on this server. You can also target other users to change their nickname. ex: - BotNickname - - "@SomeUser New Nickname" + - '@SomeUser New Nickname' params: - newNick: desc: "The new nickname to be displayed for the bot or targeted user." @@ -780,14 +787,14 @@ setnick: setavatar: desc: Sets a new avatar image for the EllieBot. Parameter is a direct link to an image. ex: - - https://i.imgur.com/xTG3a1I.jpg + - https://cdn.elliebot.net/Ellie.png params: - img: desc: "The URL of the image file to be displayed as the bot's avatar." setbanner: desc: Sets a new banner image for the EllieBot. Parameter is a direct link to an image. Supports gifs. ex: - - https://i.imgur.com/xTG3a1I.jpg + - https://cdn.elliebot.net/Ellie.png params: - img: desc: "The URL of the image file to be displayed as the bot's banner." @@ -802,8 +809,22 @@ setgame: desc: "The activity type determines whether the bot is engaged in a game, listening to audio, or watching a video." game: desc: "The current state of the bot's activity in the game." +setserverbanner: + desc: Sets a new banner image for the current server. Parameter is a direct link to an image. + ex: + - https://cdn.elliebot.net/Ellie.png + params: + - img: + desc: "The URL of the image file to be displayed as the bot's banner." +setservericon: + desc: Sets a new icon image for the current server. Parameter is a direct link to an image. + ex: + - https://cdn.elliebot.net/Ellie.png + params: + - img: + desc: "The URL of the image file to be displayed as the bot's banner." send: - desc: "Sends a message to a channel or user. Channel or user can be " + desc: 'Sends a message to a channel or user. Channel or user can be ' ex: - channel 123123123132312 Stop spamming commands plz - user 1231231232132 I can see in the console what you're doing. @@ -825,7 +846,7 @@ remind: desc: "Sends a message to you or a channel after certain amount of time (max 2 months). First parameter is `me`/`here`/'channelname'. Second parameter is time in a descending order (mo>w>d>h>m) example: 1w5d3h10m. Third parameter is a (multiword) message. Requires ManageMessages server permission if you're targeting a different channel." ex: - me 1d5h Do something - - "#general 1m Start now!" + - '#general 1m Start now!' params: - meorhere: desc: "The enum value of 'me' if the user wants to be reminded, or 'here' if the user wants to be reminded in the current channel." @@ -870,7 +891,7 @@ serverinfo: channelinfo: desc: Shows info about the channel. If no channel is supplied, it defaults to current one. ex: - - "#some-channel" + - '#some-channel' params: - channel: desc: "The channel where the information will be retrieved from or displayed in." @@ -884,7 +905,7 @@ roleinfo: userinfo: desc: Shows info about the user. If no user is supplied, it defaults a user running the command. ex: - - "@SomeUser" + - '@SomeUser' params: - usr: desc: "The guild user that the information is being retrieved for." @@ -900,7 +921,7 @@ inrole: ex: - RoleName - 5 RoleName - - "" + - '' params: - page: desc: "The starting page number for the result set." @@ -919,34 +940,34 @@ checkperms: stats: desc: Shows some basic stats for Ellie. ex: - - "" + - '' params: - {} userid: desc: Shows user ID. ex: - - "" - - "@Someone" + - '' + - '@Someone' params: - target: desc: "The guild the user is a member of." channelid: desc: Shows current channel ID. ex: - - "" + - '' params: - {} serverid: desc: Shows current server ID. ex: - - "" + - '' params: - {} roles: desc: List roles on this server or roles of a user if specified. Paginated, 20 roles per page. ex: - 2 - - "@Someone" + - '@Someone' params: - target: desc: "The guild or user for which to list the roles." @@ -957,38 +978,38 @@ roles: channeltopic: desc: Sends current channel's topic as a message. ex: - - "" + - '' params: - channel: desc: "The channel where the topic is retrieved from." chnlfilterinv: desc: Toggles automatic deletion of invites posted in the channel. Does not negate the `{0}srvrfilterinv` enabled setting. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} srvrfilterinv: desc: Toggles automatic deletion of invites posted in the server. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} chnlfilterlin: desc: Toggles automatic deletion of links posted in the channel. Does not negate the `{0}srvrfilterlin` enabled setting. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} srvrfilterlin: desc: Toggles automatic deletion of links posted in the server. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} chnlfilterwords: desc: Toggles automatic deletion of messages containing filtered words on the channel. Does not negate the `{0}srvrfilterwords` enabled setting. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} filterword: @@ -1001,13 +1022,13 @@ filterword: srvrfilterwords: desc: Toggles automatic deletion of messages containing filtered words on the server. Does not affect users with the Administrator permission. ex: - - "" + - '' params: - {} lstfilterwords: desc: Shows a list of filtered words. ex: - - "" + - '' params: - page: desc: "The current page number in the list of filtered words." @@ -1024,7 +1045,7 @@ permrole: verbose: desc: Toggles or sets whether to show when a command/module is blocked. ex: - - "" + - '' - true params: - action: @@ -1116,14 +1137,14 @@ usrcmd: allsrvrmdls: desc: Enable or disable all modules for your server. ex: - - "[enable/disable]" + - '[enable/disable]' params: - action: desc: "The type of action to take on the enabled/disabled modules, such as enable or disable." allchnlmdls: desc: Enable or disable all modules in a specified channel. ex: - - "enable #SomeChannel" + - 'enable #SomeChannel' params: - action: desc: "The type of permission action to apply to the module, such as granting or revoking access." @@ -1132,7 +1153,7 @@ allchnlmdls: allrolemdls: desc: Enable or disable all modules for a specific role. ex: - - "[enable/disable] MyRole" + - '[enable/disable] MyRole' params: - action: desc: "The type of permission action to perform, such as granting or revoking access." @@ -1145,7 +1166,7 @@ userblacklist: ex: - add @SomeUser @SomeUser2 @SomeUser3 - rem 12312312313 - - "" + - '' - 4 params: - page: @@ -1165,7 +1186,7 @@ channelblacklist: ex: - add 12312312312 66666666666 - rem 12312312312 - - "" + - '' - 3 params: - page: @@ -1181,7 +1202,7 @@ serverblacklist: ex: - add 12312321312 - rem 12312321312 - - "" + - '' - 2 params: - page: @@ -1211,7 +1232,7 @@ cmdcooldown: allcmdcooldowns: desc: Shows a list of all commands and their respective cooldowns. ex: - - "" + - '' params: - page: desc: "The number of the page to display in the list of command cooldowns." @@ -1224,6 +1245,15 @@ quoteadd: desc: "The name of the quote used to retrieve the quote." text: desc: "The message of the quote." +quoteedit: + desc: Edits a quote with the specified ID. + ex: + - 55 This is the new response. + params: + - quoteId: + desc: "The ID of the quote being edited." + text: + desc: "The new message of the quote." quoteprint: desc: Prints a random quote with a specified name. ex: @@ -1236,10 +1266,10 @@ quoteshow: ex: - 123 params: - - id: + - quoteId: desc: "The unique identifier for the quote being queried." quotesearch: - desc: "Shows a random quote given a search query. Partially matches in several ways: 1) Only content of any quote, 2) only by author, 3) keyword and content, 3) or keyword and author" + desc: 'Shows a random quote given a search query. Partially matches in several ways: 1) Only content of any quote, 2) only by author, 3) keyword and content, 3) or keyword and author' ex: - '"find this long text"' - AuthorName @@ -1257,19 +1287,19 @@ quoteid: ex: - 123456 params: - - id: + - quoteId: desc: "The unique identifier for the quote to be displayed." quotedelete: desc: Deletes a quote with the specified ID. You have to either have the Manage Messages permission or be the creator of the quote to delete it. ex: - 123456 params: - - id: + - quoteId: desc: "The unique identifier for the quote being deleted." quotedeleteauthor: desc: Deletes all quotes by the specified author. If the author is not you, then ManageMessage server permission is required. ex: - - "@QuoteSpammer" + - '@QuoteSpammer' params: - user: desc: "The user whose quotes are to be deleted." @@ -1278,7 +1308,7 @@ quotedeleteauthor: draw: desc: Draws a card from this server's deck. You can draw up to 10 cards by supplying a number of cards to draw. ex: - - "" + - '' - 5 params: - num: @@ -1286,7 +1316,7 @@ draw: drawnew: desc: Draws a card from the NEW deck of cards. You can draw up to 10 cards by supplying a number of cards to draw. ex: - - "" + - '' - 5 params: - num: @@ -1294,13 +1324,13 @@ drawnew: playlistshuffle: desc: Shuffles the current playlist. ex: - - "" + - '' params: - {} flip: desc: Flips coin(s) - heads or tails, and shows an image. ex: - - "" + - '' - 3 params: - count: @@ -1318,7 +1348,7 @@ betflip: roll: desc: Rolls 0-100. If you supply a number `X` it rolls up to 30 normal dice. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`. `Y` can be a letter 'F' if you want to roll fate dice instead of dnd. ex: - - "" + - '' - 7 - 3d5 - 5dF @@ -1331,7 +1361,7 @@ roll: rolluo: desc: Rolls `X` normal dice (up to 30) unordered. If you split 2 numbers with letter `d` (`xdy`) it will roll `X` dice from 1 to `y`. ex: - - "" + - '' - 7 - 3d5 params: @@ -1350,14 +1380,14 @@ nroll: race: desc: Starts a new animal race. ex: - - "" + - '' params: - params: desc: "The list of commands or actions for the animals in the race." joinrace: desc: Joins a new race. You can specify an amount of currency for betting (optional). You will get YourBet*(participants-1) back if you win. ex: - - "" + - '' - 5 params: - amount: @@ -1365,20 +1395,20 @@ joinrace: nunchi: desc: Creates or joins an existing nunchi game. Users have to count up by 1 from the starting number shown by the bot. If someone makes a mistake (types an incorrect number, or repeats the same number) they are out of the game and a new round starts without them. Minimum 3 users required. ex: - - "" + - '' params: - {} connect4: desc: Creates or joins an existing connect4 game. 2 players are required for the game. Objective of the game is to get 4 of your pieces next to each other in a vertical, horizontal or diagonal line. You can specify a bet when you create a game and only users who bet the same amount will be able to join your game. ex: - - "" + - '' params: - params: desc: "The list of command-line arguments passed by the user to customize the game setup or behavior." raffle: desc: Prints a name and ID of a random online user from the server, or from the online user in the specified role. ex: - - "" + - '' - RoleName params: - role: @@ -1386,8 +1416,8 @@ raffle: raffleany: desc: Prints a name and ID of a random user from the server, or from the specified role. ex: - - "" - - " RoleName" + - '' + - ' RoleName' params: - role: desc: "The role that determines which users are eligible for selection." @@ -1468,7 +1498,7 @@ luckyladder: leaderboard: desc: Displays the bot's currency leaderboard. ex: - - "" + - '' params: - params: desc: "The list of player names or IDs to display in the leaderboard." @@ -1479,7 +1509,7 @@ leaderboard: trivia: desc: Starts a game of trivia. You can add `nohint` to prevent hints. First player to get to 10 points wins by default. You can specify a different number. 30 seconds per question. ex: - - "" + - '' - --timeout 5 -p -w 3 -q 10 params: - params: @@ -1487,26 +1517,26 @@ trivia: tl: desc: Shows a current trivia leaderboard. ex: - - "" + - '' params: - {} tq: desc: Quits current trivia after current question. ex: - - "" + - '' params: - {} typestart: desc: Starts a typing contest. ex: - - "" + - '' params: - params: desc: "The list of words or phrases for the contestants to type." typestop: desc: Stops a typing contest on the current channel. ex: - - "" + - '' params: - {} typeadd: @@ -1519,7 +1549,7 @@ typeadd: pick: desc: Picks the currency planted in this channel. If the plant has a password, you need to specify it. ex: - - "" + - '' - passwd params: - pass: @@ -1537,13 +1567,13 @@ plant: gencurrency: desc: Toggles currency generation on this channel. Every posted message will have chance to spawn currency. Chance is specified by the Bot Owner. (default is 2%) ex: - - "" + - '' params: - {} gencurlist: desc: Shows the list of server and channel ids where gc is enabled. Paginated with 9 per page. ex: - - "" + - '' params: - page: desc: "The current page number for pagination." @@ -1567,13 +1597,13 @@ rps: next: desc: Goes to the next song in the queue. You have to be in the same voice channel as the bot ex: - - "" + - '' params: - {} play: desc: If no parameters are specified, acts as `{0}next 1` command. If you specify a song number, it will jump to that song. If you specify a search query, acts as a `{0}q` command ex: - - "" + - '' - 5 - Dream Of Venice params: @@ -1585,19 +1615,19 @@ play: stop: desc: Stops the music and preserves the current song index. Stays in the channel. ex: - - "" + - '' params: - {} destroy: desc: Completely stops the music and unbinds the bot from the channel. (may cause weird behaviour) ex: - - "" + - '' params: - {} pause: desc: Pauses or Unpauses the song. ex: - - "" + - '' params: - {} queue: @@ -1624,7 +1654,7 @@ queuesearch: listqueue: desc: Lists 10 currently queued songs per page. Default page is 1. ex: - - "" + - '' - 2 params: - {} @@ -1633,7 +1663,7 @@ listqueue: nowplaying: desc: Shows the song that the bot is currently playing. ex: - - "" + - '' params: - {} volume: @@ -1658,7 +1688,7 @@ localplaylist: - dirPath: desc: "The path to the directory containing the songs to be queued." radio: - desc: "Queues a radio stream from a link. It can be a direct mp3 radio stream, .m3u, .pls .asx or .xspf (Usage Video: )" + desc: 'Queues a radio stream from a link. It can be a direct mp3 radio stream, .m3u, .pls .asx or .xspf (Usage Video: )' ex: - radio link here params: @@ -1674,7 +1704,7 @@ local: join: desc: Makes the bot join your voice channel. ex: - - "" + - '' params: - {} trackremove: @@ -1682,7 +1712,7 @@ trackremove: ex: - 5 - all - - "" + - '' params: - index: desc: "The position of the song to be removed from the playlist." @@ -1704,7 +1734,7 @@ queuerepeat: - `s` / `song` - player will repeat current song - `q` / `queue` or empty - player will repeat entire music queue ex: - - "" + - '' - n - song params: @@ -1760,7 +1790,7 @@ deleteplaylist: queueautoplay: desc: Toggles autoplay - When the song is finished, automatically queue a related Youtube song. (Works only for Youtube songs) ex: - - "" + - '' params: - {} streamadd: @@ -1773,7 +1803,7 @@ streamadd: streamsclear: desc: Removes all followed streams on this server. ex: - - "" + - '' params: - {} streamremove: @@ -1786,20 +1816,20 @@ streamremove: streamlist: desc: Lists all streams you are following on this server and their respective indexes. ex: - - "" + - '' params: - page: desc: "The number of the page to retrieve from the list of followed streams." streamoffline: desc: Toggles whether the bot will also notify when added streams go offline. ex: - - "" + - '' params: - {} streamonlinedelete: desc: Toggles whether the bot will delete stream online message when the stream goes offline. ex: - - "" + - '' params: - {} streammessage: @@ -1839,13 +1869,13 @@ convert: convertlist: desc: List of the convertible dimensions and currencies. ex: - - "" + - '' params: - {} wowjoke: desc: Get one of penultimate WoW jokes. ex: - - "" + - '' params: - {} calculate: @@ -1883,7 +1913,7 @@ osu5: - user: desc: "The username of the player whose top 5 plays are being displayed." mode: - desc: 'The type of game mode to display the top 5 plays for, such as "osu", "taiko", or "ctb".' + desc: "The type of game mode to display the top 5 plays for, such as \"osu\", \"taiko\", or \"ctb\"." pokemon: desc: Searches for a pokemon. ex: @@ -1901,7 +1931,7 @@ pokemonability: memelist: desc: Shows a list of template keys (and their respective names) used for `{0}memegen`. ex: - - "" + - '' params: - page: desc: "The number of pages in the list to be displayed." @@ -1960,25 +1990,25 @@ manga: randomcat: desc: Shows a random cat image. ex: - - "" + - '' params: - {} randomdog: desc: Shows a random dog image. ex: - - "" + - '' params: - {} randomfood: desc: Shows a random food image. ex: - - "" + - '' params: - {} randombird: desc: Shows a random bird image. ex: - - "" + - '' params: - {} image: @@ -2019,31 +2049,31 @@ urbandict: catfact: desc: Shows a random catfact from ex: - - "" + - '' params: - {} yomama: desc: Shows a random joke from ex: - - "" + - '' params: - {} randjoke: desc: Shows a random joke. ex: - - "" + - '' params: - {} chucknorris: desc: Shows a random Chuck Norris joke. ex: - - "" + - '' params: - {} magicitem: desc: Shows a random magic item from ex: - - "" + - '' params: - {} wiki: @@ -2064,7 +2094,7 @@ color: avatar: desc: Shows a mentioned person's avatar. ex: - - "@Someone" + - '@Someone' params: - usr: desc: "The user whose avatar is being displayed." @@ -2082,19 +2112,19 @@ translate: translangs: desc: Lists the valid languages for translation. ex: - - "" + - '' params: - {} guide: desc: Sends a readme and a guide links to the channel. ex: - - "" + - '' params: - {} calcops: desc: Shows all available operations in the `{0}calc` command ex: - - "" + - '' params: - {} delallquotes: @@ -2105,17 +2135,17 @@ delallquotes: - keyword: desc: "The keyword to search for in the text." greetdmmsg: - desc: Sets a new join announcement message which will be sent to the user who joined. Type `%user.mention%` if you want to mention the new member. Using it with no message will show the current DM greet message. You can use embed json from instead of a regular text, if you want the message to be embedded. + desc: Sets a new join announcement message which will be sent to the user who joined. Type `%user.mention%` if you want to mention the new member. Using it with no message will show the current DM greet message. You can use embed json from instead of a regular text, if you want the message to be embedded. ex: - Welcome to the server, %user.mention% params: - text: desc: "The new join announcement message that will be sent to the user who joined." cash: - desc: Check how much currency a person has. If no argument is provided it will check your own balance. + desc: Check how much currency a person has. If no argument is provided it will check your own balance. ex: - - "" - - "@Someone" + - '' + - '@Someone' params: - userId: desc: "Optional user ID of the account holder whose currency balance is being checked." @@ -2125,7 +2155,7 @@ currencytransactions: desc: Shows your currency transactions on the specified page. Bot owner can see other people's transactions too. ex: - 2 - - "@SomeUser 2" + - '@SomeUser 2' params: - page: desc: "The number of pages to display in the list of currency transactions." @@ -2145,7 +2175,7 @@ currencytransaction: listperms: desc: Lists whole permission chain with their indexes. You can specify an optional page number if there are a lot of permissions. ex: - - "" + - '' - 3 params: - page: @@ -2190,8 +2220,8 @@ emojiadd: You can omit imageUrl and instead upload the image as an attachment. Image size has to be below 256KB. ex: - - ":someonesCustomEmoji:" - - "MyEmojiName :someonesCustomEmoji:" + - ':someonesCustomEmoji:' + - 'MyEmojiName :someonesCustomEmoji:' - owoNice https://cdn.discordapp.com/emojis/587930873811173386.png?size=128 params: - name: @@ -2207,14 +2237,14 @@ emojiadd: emojiremove: desc: Removes the specified emoji or emojis from this server. ex: - - ":eagleWarrior: :plumedArcher:" + - ':eagleWarrior: :plumedArcher:' params: - emotes: desc: "The list of emojis to be removed from the server." stickeradd: desc: Adds the sticker from your message to this server. Send the sticker along with this command (in the same message). ex: - - "" + - '' - name "description" tag1 tag2 tagN params: - name: @@ -2226,40 +2256,40 @@ stickeradd: deckshuffle: desc: Reshuffles all cards back into the deck. ex: - - "" + - '' params: - {} forwardmessages: desc: Toggles forwarding of non-command messages sent to bot's DM to the bot owners ex: - - "" + - '' params: - {} forwardtoall: desc: Toggles whether messages will be forwarded to all bot owners or only to the first one specified in the creds.yml file ex: - - "" + - '' params: - {} forwardtochannel: desc: Toggles forwarding of non-command messages sent to bot's DM to the current channel ex: - - "" + - '' params: - {} resetperms: desc: Resets the bot's permissions module on this server to the default value. ex: - - "" + - '' params: - {} antiraid: - desc: "Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut" + desc: 'Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut' ex: - 5 20 Kick - 7 9 Ban - 10 10 Ban 6h30m - - "" + - '' params: - {} - userThreshold: @@ -2277,12 +2307,12 @@ antiraid: action: desc: "The punishment action specifies the consequence for users who trigger the anti-raid protection." antispam: - desc: "Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles, Warn, TimeOut" + desc: 'Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles, Warn, TimeOut' ex: - 3 Mute - 5 Ban - 5 Ban 3h30m - - "" + - '' params: - {} - messageCount: @@ -2323,8 +2353,8 @@ antialt: chatmute: desc: Prevents a mentioned user from chatting in text channels. You can also specify time string for how long the user should be muted. You can optionally specify a reason. ex: - - "@Someone" - - "@Someone stop writing" + - '@Someone' + - '@Someone stop writing' - 15m @Someone - 1h30m @Someone - 1h @Someone chill @@ -2342,8 +2372,8 @@ chatmute: voicemute: desc: Prevents a mentioned user from speaking in voice channels. User has to be in a voice channel in order for the command to have an effect. You can also specify time string for how long the user should be muted. You can optionally specify a reason. ex: - - "@Someone" - - "@Someone stop talking" + - '@Someone' + - '@Someone stop talking' - 15m @Someone - 1h30m @Someone - 1h @Someone silence @@ -2361,7 +2391,7 @@ voicemute: muterole: desc: Sets a name of the role which will be assigned to people who should be muted. Provide no arguments to see currently set mute role. Default is ellie-mute. ex: - - "" + - '' - Silenced params: - role: @@ -2369,7 +2399,7 @@ muterole: adsarm: desc: Toggles the automatic deletion of the user's message and Ellie's confirmations for `{0}iam` and `{0}iamn` commands. ex: - - "" + - '' params: - {} setstream: @@ -2384,7 +2414,7 @@ setstream: chatunmute: desc: Removes a mute role previously set on a mentioned user with `{0}chatmute` which prevented him from chatting in text channels. ex: - - "@Someone" + - '@Someone' params: - user: desc: "The user who was previously muted and is now being unmuted." @@ -2393,7 +2423,7 @@ chatunmute: unmute: desc: Unmutes a mentioned user previously muted with `{0}mute` command. ex: - - "@Someone" + - '@Someone' params: - user: desc: "The user who was previously muted and is now being un-muted." @@ -2402,12 +2432,12 @@ unmute: xkcd: desc: Shows a XKCD comic. Specify no parameters to retrieve a random one. Number parameter will retrieve a specific comic, and "latest" will get the latest one. ex: - - "" + - '' - 1400 - latest params: - arg: - desc: 'The URL of the desired comic or "latest" to retrieve the most recent one.' + desc: "The URL of the desired comic or \"latest\" to retrieve the most recent one." - num: desc: "The number of the comic to be retrieved." autotranslang: @@ -2417,13 +2447,13 @@ autotranslang: params: - {} - fromLang: - desc: + desc: toLang: - desc: 'The destination language code, such as "en" for English or "fr" for French.' + desc: "The destination language code, such as \"en\" for English or \"fr\" for French." autotranslate: desc: Starts automatic translation of all messages by users who set their `{0}atl` in this channel. You can set "del" parameter to automatically delete all translated user messages. ex: - - "" + - '' - del params: - autoDelete: @@ -2450,7 +2480,7 @@ typedel: typelist: desc: Lists added typing articles with their IDs. 15 per page. ex: - - "" + - '' - 3 params: - page: @@ -2465,7 +2495,7 @@ listservers: cleverbot: desc: Toggles cleverbot/chatgpt session. When enabled, the bot will reply to messages starting with bot mention in the server. Expressions starting with %bot.mention% won't work if cleverbot/chatgpt is enabled. ex: - - "" + - '' params: - {} shorten: @@ -2495,13 +2525,13 @@ magicthegathering: hangmanlist: desc: Shows a list of hangman question categories. ex: - - "" + - '' params: - {} hangman: desc: Starts a game of hangman in the channel. You can optionally select a category `{0}hangmanlist` to see a list of available categories. ex: - - "" + - '' - movies params: - type: @@ -2509,13 +2539,13 @@ hangman: hangmanstop: desc: Stops the active hangman game on this channel if it exists. ex: - - "" + - '' params: - {} acrophobia: desc: Starts an Acrophobia game. ex: - - "" + - '' - -s 30 params: - params: @@ -2523,7 +2553,7 @@ acrophobia: logevents: desc: Shows a list of all events you can subscribe to with `{0}log` ex: - - "" + - '' params: - {} log: @@ -2537,7 +2567,7 @@ log: queuefairplay: desc: Triggers fairplay. The song queue will be re-ordered in a fair manner. No effect on newly added songs. ex: - - "" + - '' params: - {} define: @@ -2550,7 +2580,7 @@ define: activity: desc: Checks for spammers. ex: - - "" + - '' params: - page: desc: "The number of pages to scan for spam." @@ -2564,14 +2594,14 @@ setstatus: invitecreate: desc: Creates a new invite which has infinite max uses and never expires. ex: - - "" + - '' params: - params: desc: "The recipient's email addresses or usernames." invitelist: desc: Lists all invites for this channel. Paginated with 9 per page. ex: - - "" + - '' - 3 params: - page: @@ -2588,17 +2618,17 @@ invitedelete: antilist: desc: Shows currently enabled protection features. ex: - - "" + - '' params: - {} antispamignore: desc: Toggles whether antispam ignores current channel. Antispam must be enabled. ex: - - "" + - '' params: - {} eventstart: - desc: "Starts one of the events seen on public ellie. Events: `reaction`, `gamestatus`" + desc: 'Starts one of the events seen on public ellie. Events: `reaction`, `gamestatus`' ex: - reaction - reaction -d 1 -a 50 --pot-size 1500 @@ -2610,7 +2640,7 @@ eventstart: betstats: desc: Shows the total stats of several gambling features. Updates once an hour. ex: - - "" + - '' params: - {} slot: @@ -2623,8 +2653,8 @@ slot: affinity: desc: Sets your affinity towards someone you want to be claimed by. Setting affinity will reduce their `{0}claim` on you by 20%. Provide no parameters to clear your affinity. 30 minutes cooldown. ex: - - "@MyHusband" - - "" + - '@MyHusband' + - '' params: - user: desc: "The user being targeted for a potential claim." @@ -2640,13 +2670,13 @@ waifuclaim: waifureset: desc: Resets your waifu stats, except current waifus. ex: - - "" + - '' params: - {} waifutransfer: desc: Transfer the ownership of one of your waifus to another user. You must pay 10% of your waifu's value unless that waifu has affinity towards you, in which case you must pay 60% fee. Transferred waifu's price will be reduced by the fee amount. ex: - - "@ExWaifu @NewOwner" + - '@ExWaifu @NewOwner' params: - waifuId: desc: "The ID of the waifu being transferred to a new owner." @@ -2659,7 +2689,7 @@ waifutransfer: waifugift: desc: -| Gift an item to someone. This will increase their waifu value by a percentage of the gift's value. Negative gifts will not show up in waifuinfo. Provide no parameters to see a list of items that you can gift. ex: - - "" + - '' - Rose @Himesama params: - page: @@ -2671,7 +2701,7 @@ waifugift: waifulb: desc: Shows top 9 waifus. You can specify another page to show other waifus. ex: - - "" + - '' - 3 params: - page: @@ -2679,7 +2709,7 @@ waifulb: divorce: desc: Releases your claim on a specific waifu. You will get 50% of that waifu's value back, unless that waifu has an affinity towards you, in which case they will be reimbursed instead. 6 hours cooldown. ex: - - "@CheatingSloot" + - '@CheatingSloot' params: - target: desc: "The ID or name of the waifu being released from your claim." @@ -2690,8 +2720,8 @@ divorce: waifuinfo: desc: Shows waifu stats for a target person. Defaults to you if no user is provided. ex: - - "@MyCrush" - - "" + - '@MyCrush' + - '' params: - target: desc: "The user being targeted, whose waifu information will be displayed." @@ -2709,19 +2739,19 @@ mal: setmusicchannel: desc: Sets the current channel as the default music output channel. This will output playing, finished, paused and removed songs to that channel instead of the channel where the first song was queued in. Persistent server setting. ex: - - "" + - '' params: - {} unsetmusicchannel: desc: Bot will output playing, finished, paused and removed songs to the channel where the first song was queued in. Persistent server setting. ex: - - "" + - '' params: - {} musicquality: - desc: "Gets or sets the default music player quality. Available settings: Highest, High, Medium, Low. Default is **Highest**. Provide no argument to see current setting." + desc: 'Gets or sets the default music player quality. Available settings: Highest, High, Medium, Low. Default is **Highest**. Provide no argument to see current setting.' ex: - - "" + - '' - High - Low params: @@ -2731,7 +2761,7 @@ musicquality: stringsreload: desc: Reloads localized bot strings. ex: - - "" + - '' params: - {} shardstats: @@ -2739,7 +2769,7 @@ shardstats: Stats for shards. Paginated with 25 shards per page. Format: `[status] | # [shard_id] | [last_heartbeat] | [server_count]` ex: - - "" + - '' - 2 params: - page: @@ -2754,21 +2784,21 @@ restartshard: tictactoe: desc: Starts a game of tic tac toe. Another user must run the command in the same channel in order to accept the challenge. Use numbers 1-9 to play. ex: - - "" + - '' params: - params: desc: "The coordinates for placing an X or O on the board." timezones: desc: Lists all timezones available on the system to be used with `{0}timezone`. ex: - - "" + - '' params: - page: desc: "The number of pages to retrieve from the list of available timezones." timezone: desc: Sets this guilds timezone. This affects bot's time output in this server (logs, etc..) **Setting timezone requires Administrator server permission.** ex: - - "" + - '' - GMT Standard Time params: - {} @@ -2786,7 +2816,7 @@ languagesetdefault: languageset: desc: Sets this server's response language. If bot's response strings have been translated to that language, bot will use that language in this server. Reset by using `default` as the locale name. Provide no parameters to see currently set language. ex: - - "de-DE " + - 'de-DE ' - default params: - {} @@ -2795,13 +2825,13 @@ languageset: languageslist: desc: List of languages for which translation (or part of it) exist atm. ex: - - "" + - '' params: - {} exprtoggleglobal: desc: Toggles whether global expressions are usable on this server. ex: - - "" + - '' params: - {} exprreact: @@ -2846,7 +2876,7 @@ exprca: exprsreload: desc: Reloads all expressions on all shards. Use this if you've made changes to the database while the bot is running, or used `{0}deleteunusedcrnq` ex: - - "" + - '' params: - {} exprsimport: @@ -2859,7 +2889,7 @@ exprsimport: exprsexport: desc: Exports expressions from the current server (or global expressions in DMs) into a .yml file ex: - - "" + - '' params: - {} quotesimport: @@ -2872,13 +2902,13 @@ quotesimport: quotesexport: desc: Exports quotes from the current server into a .yml file ex: - - "" + - '' params: - {} aliaslist: desc: Shows the list of currently set aliases. Paginated. ex: - - "" + - '' - 3 params: - page: @@ -2895,7 +2925,7 @@ alias: warnlog: desc: See a list of warnings of a certain user. ex: - - "@Someone" + - '@Someone' params: - page: desc: "The number of pages to display in the warning log." @@ -2912,7 +2942,7 @@ warnlog: warnlogall: desc: See a list of all warnings on the server. 15 users per page. ex: - - "" + - '' - 2 params: - page: @@ -2922,7 +2952,7 @@ warn: Warns a user with an optional reason. You can specify a warning weight integer before the user. For example, 3 would mean that this warning counts as 3 warnings. ex: - - "@Someone Very rude person" + - '@Someone Very rude person' - 3 @Someone Very rude person params: - user: @@ -2938,7 +2968,7 @@ warn: startupcommandadd: desc: Adds a command to the list of commands which will be executed automatically in the current channel, in the order they were added in, by the bot when it startups up. ex: - - "{0}stats" + - '{0}stats' params: - cmdText: desc: "The text of the command that should be recognized and executed when a user types it." @@ -2968,20 +2998,20 @@ autocommandremove: startupcommandsclear: desc: Removes all startup commands. ex: - - "" + - '' params: - {} startupcommandslist: desc: Lists all startup commands in the order they will be executed in. ex: - - "" + - '' params: - page: desc: "The number of items to display per page." autocommandslist: desc: Lists all auto commands and the intervals in which they execute. ex: - - "" + - '' params: - page: desc: "The number of pages to retrieve from the list of auto commands." @@ -2996,7 +3026,7 @@ unban: - userId: desc: "The ID of the user to be unbanned." banmessage: - desc: "Sets a ban message template which will be used when a user is banned from this server. You can use embed strings and ban-specific placeholders: %ban.mod%, %ban.user%, %ban.duration% and %ban.reason%. You can disable ban message with `{0}banmsg -`" + desc: 'Sets a ban message template which will be used when a user is banned from this server. You can use embed strings and ban-specific placeholders: %ban.mod%, %ban.user%, %ban.duration% and %ban.reason%. You can disable ban message with `{0}banmsg -`' ex: - "%ban.user%, you've been banned from %server.name%. Reason: %ban.reason%" - '{{ "description": "%ban.user% you have been banned from %server.name% by %ban.mod%" }}' @@ -3018,7 +3048,7 @@ banmessagetest: banmsgreset: desc: Resets ban message to default. If you want to completely disable ban messages, use `{0}banmsg -` ex: - - "" + - '' params: - {} banprune: @@ -3041,7 +3071,7 @@ wait: warnexpire: desc: Gets or sets the number of days after which the warnings will be cleared automatically. This setting works retroactively. If you want to delete the warnings instead of clearing them, you can set the `--delete` optional parameter. Provide no parameter to see currently set expiry ex: - - "" + - '' - 3 - 6 --delete params: @@ -3053,8 +3083,8 @@ warnexpire: warnclear: desc: Clears all warnings from a certain user. You can specify a number to clear a specific one. ex: - - "@PoorDude 3" - - "@PoorDude" + - '@PoorDude 3' + - '@PoorDude' params: - user: desc: "The user whose warnings are being cleared." @@ -3067,7 +3097,7 @@ warnclear: warnpunishlist: desc: Lists punishments for warnings. ex: - - "" + - '' params: - {} warnpunish: @@ -3097,7 +3127,7 @@ warnpunish: ping: desc: Ping the bot to see if there are latency issues. ex: - - "" + - '' params: - {} time: @@ -3110,7 +3140,7 @@ time: shop: desc: Lists this server's administrators' shop. Paginated. ex: - - "" + - '' - 2 params: - page: @@ -3202,7 +3232,7 @@ buy: gamevoicechannel: desc: Toggles game voice channel feature in the voice channel you're currently in. Users who join the game voice channel will get automatically redirected to the voice channel with the name of their current game, if it exists. Can't move users to channels that the bot has no connect permission for. One per server. ex: - - "" + - '' params: - {} shoplistadd: @@ -3217,7 +3247,7 @@ shoplistadd: globalcommand: desc: Toggles whether a command can be used on any server. ex: - - "{0}stats" + - '{0}stats' params: - cmd: desc: "The type of command or expression being toggled." @@ -3231,13 +3261,13 @@ globalmodule: globalpermlist: desc: Lists global permissions set by the bot owner. ex: - - "" + - '' params: - {} resetglobalperms: desc: Resets global permissions set by bot owner. ex: - - "" + - '' params: - {} prefix: @@ -3262,7 +3292,7 @@ defprefix: verboseerror: desc: Toggles or sets whether the bot should print command errors when a command is incorrectly used. ex: - - "" + - '' - false params: - newstate: @@ -3270,7 +3300,7 @@ verboseerror: streamrolekeyword: desc: Sets keyword which is required in the stream's title in order for the streamrole to apply. Provide no keyword in order to reset. ex: - - "" + - '' - PUBG params: - keyword: @@ -3303,7 +3333,7 @@ config: Provide config name and property name to see that property's description and value. Provide config name, property name and value to set that property to the new value. ex: - - "" + - '' - bot - bot color.ok - bot color.ok ff0000 @@ -3325,21 +3355,21 @@ configreload: experience: desc: Shows your xp stats. Specify the user to show that user's stats instead. ex: - - "" - - "@someguy" + - '' + - '@someguy' params: - user: desc: "The ID or handle of a player whose XP statistics are being displayed." xptemplatereload: desc: Reloads the xp template file. Xp template file allows you to customize the position and color of elements on the `{0}xp` card. ex: - - "" + - '' params: - {} xpexclusionlist: desc: Shows the roles and channels excluded from the XP system on this server, as well as whether the whole server is excluded. ex: - - "" + - '' params: - {} xpexclude: @@ -3372,14 +3402,14 @@ xpnotify: xpleveluprewards: desc: Shows currently set level up rewards. ex: - - "" + - '' params: - page: desc: "The page number for which the level up rewards are being displayed." xprewsreset: desc: Resets all currently set xp level up rewards. ex: - - "" + - '' params: - {} xprolereward: @@ -3412,7 +3442,7 @@ xpcurrencyreward: xpleaderboard: desc: Shows current server's xp leaderboard. ex: - - "" + - '' params: - params: desc: "The list of player names or IDs to filter the leaderboard by." @@ -3423,7 +3453,7 @@ xpleaderboard: xpgloballeaderboard: desc: Shows the global xp leaderboard. ex: - - "" + - '' params: - page: desc: "The current page number for displaying the leaderboard results." @@ -3455,7 +3485,7 @@ clubcreate: clubtransfer: desc: Transfers the ownership of the club to another member of the club. ex: - - "@Someone" + - '@Someone' params: - newOwner: desc: "The user who will take over the management of the club." @@ -3496,13 +3526,13 @@ clubreject: clubleave: desc: Leaves the club you're currently in. ex: - - "" + - '' params: - {} clubdisband: desc: Disbands the club you're the owner of. This action is irreversible. ex: - - "" + - '' params: - {} clubkick: @@ -3570,7 +3600,7 @@ clubleaderboard: clubadmin: desc: Assigns (or unassigns) staff role to the member of the club. Admins can ban, kick and accept applications. ex: - - "@Someone" + - '@Someone' params: - toAdmin: desc: "The user who is being assigned or unassigned as an admin." @@ -3610,8 +3640,8 @@ feed: You can optionally specify a message after the channel name which will be posted with an update. ex: - https://blog.playstation.com/feed/ - - "https://blog.playstation.com/feed/ #updates" - - "https://blog.playstation.com/feed/ #updates New playstation rss feed post!" + - 'https://blog.playstation.com/feed/ #updates' + - 'https://blog.playstation.com/feed/ #updates New playstation rss feed post!' params: - url: desc: "The URL of the feed to subscribe to, used to retrieve new content for posting." @@ -3633,7 +3663,7 @@ feedremove: feedlist: desc: Shows the list of feeds you've subscribed to on this server. ex: - - "" + - '' params: - page: desc: "The number of items to display per page." @@ -3650,7 +3680,7 @@ say: desc: Bot will send the message you typed in the specified channel. If you omit the channel name, it will send the message in the current channel. Supports embeds. ex: - hi - - "#chat hi" + - '#chat hi' params: - channel: desc: "The destination where the bot will send the message." @@ -3682,13 +3712,13 @@ sqlselectcsv: deletewaifus: desc: Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables. ex: - - "" + - '' params: - {} deletewaifu: desc: Deletes everything from WaifuUpdates, WaifuItem and WaifuInfo tables for the specified user. Also makes specified user's waifus free. ex: - - "" + - '' params: - user: desc: "The ID of the user whose waifus are to be deleted and set free." @@ -3697,26 +3727,26 @@ deletewaifu: deletecurrency: desc: Deletes everything from Currency and CurrencyTransactions. ex: - - "" + - '' params: - {} deleteplaylists: desc: Deletes everything from MusicPlaylists. ex: - - "" + - '' params: - {} deletexp: desc: Deletes everything from UserXpStats, Clubs and sets users' TotalXP to 0. ex: - - "" + - '' params: - {} discordpermoverride: desc: Overrides required user permissions that the command has with the specified ones. You can only use server-level permissions. This action will make the bot ignore user permission requirements which command has by default. Provide no permissions to reset to default. ex: - - "{0}prune ManageMessages BanMembers" - - "{0}prune" + - '{0}prune ManageMessages BanMembers' + - '{0}prune' params: - cmd: desc: "The command or expression that this override applies to, allowing you to customize permissions for specific commands or actions within your Discord server." @@ -3725,14 +3755,14 @@ discordpermoverride: discordpermoverridelist: desc: Lists all discord permission overrides on this server. ex: - - "" + - '' params: - page: desc: "The number of the page to display in the list of permission overrides." discordpermoverridereset: desc: Resets ALL currently set discord permission overrides on this server. This will make all commands have default discord permission requirements. ex: - - "" + - '' params: - {} rafflecur: @@ -3742,24 +3772,24 @@ rafflecur: - mixed 15 params: - _: - desc: 'The type of game mode to use, either "fixed" or "mixed".' + desc: "The type of game mode to use, either \"fixed\" or \"mixed\"." amount: desc: "The minimum or maximum amount of currency that can be used for betting." - amount: desc: "The minimum or maximum amount of currency that can be used for betting." mixed: - desc: 'The parameter determines whether the raffle operates in "fixed" or "proportional" mode.' + desc: "The parameter determines whether the raffle operates in \"fixed\" or \"proportional\" mode." rip: desc: Shows the inevitable fate of someone. ex: - - "@Someone" + - '@Someone' params: - usr: desc: "The user whose fate is being revealed." autodisconnect: desc: Toggles whether the bot should disconnect from the voice channel once it's done playing all of the songs and queue repeat option is set to `none`. ex: - - "" + - '' params: - {} timelyset: @@ -3775,13 +3805,13 @@ timelyset: timely: desc: Use to claim your 'timely' currency. Bot owner has to specify the amount and the period on how often you can claim your currency. ex: - - "" + - '' params: - {} timelyreset: desc: Resets all user timeouts on `{0}timely` command. ex: - - "" + - '' params: - {} crypto: @@ -3838,7 +3868,7 @@ pathofexile: pathofexileleagues: desc: Returns a list of the main Path of Exile leagues. ex: - - "" + - '' params: - {} pathofexilecurrency: @@ -3847,7 +3877,7 @@ pathofexilecurrency: - Standard "Mirror of Kalandra" params: - leagueName: - desc: 'The name of the league in which the currency is used, such as "Harbinger" or "Delve".' + desc: "The name of the league in which the currency is used, such as \"Harbinger\" or \"Delve\"." currencyName: desc: "The type of currency being converted." convertName: @@ -3856,7 +3886,7 @@ rollduel: desc: Challenge someone to a roll duel by specifying the amount and the user you wish to challenge as the parameters. To accept the challenge, just specify the name of the user who challenged you, without the amount. ex: - 50 @Someone - - "@Challenger" + - '@Challenger' params: - u: desc: "The user being challenged or accepting the challenge." @@ -3889,7 +3919,7 @@ reroadd: rerolist: desc: Lists all ReactionRole messages on this server with their message ids. Clicking/Tapping message ids will send you to that message. ex: - - "" + - '' params: - page: desc: "The number of the page to retrieve, starting from 1." @@ -3903,7 +3933,7 @@ reroremove: rerodeleteall: desc: Deletes all reaction roles on the server. This action is irreversible. ex: - - "" + - '' params: - {} rerotransfer: @@ -3925,26 +3955,26 @@ blackjack: hit: desc: In the blackjack game, ask the dealer for an extra card. ex: - - "" + - '' params: - {} stand: desc: Finish your turn in the blackjack game. ex: - - "" + - '' params: - {} double: desc: In the blackjack game, double your bet in order to receive exactly one more card, and your turn ends. ex: - - "" + - '' params: - {} xpreset: desc: Resets specified user's XP, or the XP of all users in the server. You can't reverse this action. ex: - - "@Someone" - - "" + - '@Someone' + - '' params: - user: desc: "The ID of a specific guild member whose XP is being reset." @@ -3996,7 +4026,7 @@ edit: desc: Edits bot's message, you have to specify message ID and new text. You can optionally specify target channel. Supports embeds. ex: - 7479498384 Hi :^) - - "#other-channel 771562360594628608 New message!" + - '#other-channel 771562360594628608 New message!' - '#other-channel 771562360594628608 {{"description":"hello"}}' params: - messageId: @@ -4012,7 +4042,7 @@ edit: delete: desc: Deletes a single message given the channel and message ID. If channel is ommited, message will be searched for in the current channel. You can also specify time parameter after which the message will be deleted (up to 7 days). This timer won't persist through bot restarts. ex: - - "#chat 771562360594628608" + - '#chat 771562360594628608' - 771562360594628608 - 771562360594628608 5m params: @@ -4036,19 +4066,19 @@ roleid: agerestricttoggle: desc: Toggles whether the current channel is age-restricted. ex: - - "" + - '' params: - {} economy: desc: Breakdown of the current state of the bot's economy. Updates every 3 minutes. ex: - - "" + - '' params: - {} purgeuser: desc: Purge user from the database completely. This includes currency, xp, clubs that user owns, waifu info ex: - - "@Oblivion" + - '@Oblivion' params: - userId: desc: "The ID of the user to be purged from the system." @@ -4057,28 +4087,28 @@ purgeuser: imageonlychannel: desc: "Toggles whether the channel only allows images.\nUsers who send more than a few non-image messages will be banned from using the channel. " ex: - - "" + - '' params: - time: desc: "The amount of time before banning users for sending non-image messages." linkonlychannel: desc: "Toggles whether the channel only allows links.\nUsers who send more than a few non-link messages will be banned from using the channel. " ex: - - "" + - '' params: - time: desc: "The amount of time before a user is banned for sending non-link messages." coordreload: desc: Reloads coordinator config ex: - - "" + - '' params: - {} showembed: desc: Prints the json equivalent of the embed of the message specified by its Id. ex: - 820022733172121600 - - "#some-channel 820022733172121600" + - '#some-channel 820022733172121600' params: - messageId: desc: "The ID of a message that contains an embed to be displayed as JSON." @@ -4089,17 +4119,17 @@ showembed: deleteemptyservers: desc: Deletes all servers in which the bot is the only member. ex: - - "" + - '' params: - {} marmaladeload: desc: |- Loads a marmalade with the specified name from the data/marmalades/ folder. Provide no name to see the list of loadable marmalades. - Read about the marmalade system [here](https://docs.elliebot.net/v4/) + Read about the marmalade system [here](https://docs.elliebot.net/ellie/) ex: - mycoolmarmalade - - "" + - '' params: - name: desc: "The name of a pre-existing marmalade to load." @@ -4107,10 +4137,10 @@ marmaladeunload: desc: |- Unloads the previously loaded marmalade. Provide no name to see the list of unloadable marmalades. - Read about the marmalade system [here](https://docs.elliebot.net/v4/) + Read about the marmalade system [here](https://docs.elliebot.net/ellie/) ex: - mycoolmarmalade - - "" + - '' params: - name: desc: "The name of a specific marmalade to be unloaded." @@ -4118,19 +4148,19 @@ marmaladeinfo: desc: |- Shows information about the specified marmalade such as the author, name, description, list of sneks, number of commands etc. Provide no name to see the basic information about all loaded marmalades. - Read about the marmalade system [here](https://docs.elliebot.net/v4/) + Read about the marmalade system [here](https://docs.elliebot.net/ellie/) ex: - mycoolmarmalade - - "" + - '' params: - name: desc: "The author of the specified marmalade." marmaladelist: desc: |- Lists all loaded and unloaded marmalades. - Read about the marmalade system [here](https://docs.elliebot.net/v4/) + Read about the marmalade system [here](https://docs.elliebot.net/ellie/) ex: - - "" + - '' params: - {} marmaladesearch: @@ -4156,7 +4186,7 @@ bankwithdraw: bankbalance: desc: Shows your current bank balance available for withdrawal. ex: - - "" + - '' params: - {} banktake: @@ -4184,7 +4214,7 @@ bankaward: patron: desc: Check your patronage status and command usage quota. Bot owners can check targeted user's patronage status. ex: - - "" + - '' params: - {} - user: @@ -4244,7 +4274,7 @@ bettest: Tests a betting command by specifying the name followed by the number of tests. Some have multiple variations. See the list of all tests by specifying no parameters. ex: - - "" + - '' - betflip 1000 - slot 2000 params: @@ -4270,13 +4300,13 @@ threaddelete: autopublish: desc: Make the bot automatically publish all messages posted in the news channel this command was executed in. ex: - - "" + - '' params: - {} doas: desc: Execute the command as if you were the target user. Requires bot ownership and server administrator permission. ex: - - "@Thief .give all @Admin" + - '@Thief .give all @Admin' params: - user: desc: "The user whose identity is being impersonated for the command execution." @@ -4292,7 +4322,7 @@ clubrename: cacheusers: desc: Caches users of a Discord server and saves them to the database. ex: - - "" + - '' - serverId params: - {} @@ -4301,7 +4331,7 @@ cacheusers: stickyroles: desc: Toggles whether the bot will save the leaving users' roles, and reapply them once they re-join. The roles will be stored for up to 30 days. ex: - - "" + - '' params: - {} giveawaystart: @@ -4339,13 +4369,13 @@ giveawayreroll: giveawaylist: desc: Lists all active giveaways. ex: - - "" + - '' params: - {} todolist: desc: Lists all todos. ex: - - "" + - '' params: - {} todoadd: @@ -4381,7 +4411,7 @@ tododelete: todoclear: desc: Deletes all unarchived todos. ex: - - "" + - '' params: - {} todoarchiveadd: @@ -4394,7 +4424,7 @@ todoarchiveadd: todoarchivelist: desc: Lists all archived todo lists. ex: - - "" + - '' params: - page: desc: "The number of the page to retrieve from the list of archived todo lists." @@ -4419,3 +4449,11 @@ todoarchivedelete: params: - todoId: desc: "The identifier for the archived todo item to be deleted." +cleanupguilddata: + desc: |- + Deletes data for all servers bot is no longer a member of from the database. + This is a highly destructive and irreversible command. + ex: + - '' + params: + - {} \ No newline at end of file diff --git a/src/EllieBot/data/strings/responses/responses.en-US.json b/src/EllieBot/data/strings/responses/responses.en-US.json index 0487021..bbc021b 100644 --- a/src/EllieBot/data/strings/responses/responses.en-US.json +++ b/src/EllieBot/data/strings/responses/responses.en-US.json @@ -188,6 +188,11 @@ "setrole_err": "Failed to add role. I have insufficient permissions.", "set_avatar": "New avatar set!", "set_banner": "New banner set!", + "set_srvr_icon": "New server icon set!", + "set_srvr_banner": "New server banner set!", + "srvr_banner_invalid": "Specified image has an invalid filetype. Make sure you're specifying a direct image url.", + "srvr_banner_too_large": "Specified image is too large! Maximum size is 8MB.", + "srvr_banner_invalid_url": "Specified url is not valid. Make sure you're specifying a direct image url.", "set_channel_name": "New channel name set.", "set_game": "New game set!", "set_stream": "New stream set!", @@ -230,7 +235,7 @@ "user_unbanned": "User unbanned", "presence_updates": "Presence updates", "sb_user": "User soft-banned", - "awarded": "has awarded {0} to {1}", + "awarded": "{2} has awarded {0} to {1}", "better_luck": "Better luck next time ^_^", "br_win": "Congratulations! You won {0} for rolling above {1}", "deck_reshuffled": "Deck reshuffled.", @@ -241,7 +246,7 @@ "cards_left": "{0} cards left in the deck.", "cards": "Cards", "hand_value": "Hand value", - "gifted": "has gifted {0} to {1}", + "gifted": "{2} has gifted {0} to {1}", "has": "{0} has {1}", "heads": "Head", "mass_award": "Awarded {0} to {1} users from {2} role.", @@ -351,7 +356,7 @@ "hangman_running": "Hangman game already running on this channel.", "hangman_types": "List of \"{0}hangman\" term types:", "leaderboard": "Leaderboard", - "picked": "picked {0}", + "picked": "{1} picked {0}", "planted": "{0} planted {1}", "trivia_already_running": "Trivia game is already running on this server.", "trivia_game": "Trivia Game", @@ -366,7 +371,7 @@ "ttt_against_yourself": "You can't play against yourself.", "ttt_already_running": "TicTacToe Game is already running in this channel.", "ttt_a_draw": "A draw!", - "ttt_created": "has created a game of TicTacToe.", + "ttt_created": "{0} has created a game of TicTacToe.", "ttt_has_won": "{0} has won!", "ttt_matched_three": "Matched three", "ttt_no_moves": "No moves left!", @@ -507,7 +512,7 @@ "city_not_found": "City not found.", "magicitems_not_loaded": "Magic Items not loaded.", "mal_profile": "{0}'s MAL profile", - "mashape_api_missing": "Bot owner didn't specify MashapeApiKey. You can't use this functionality.", + "mashape_api_missing": "Bot owner didn't specify RapidApi api key. You can't use this functionality.", "min_max": "Min/Max", "no_channel_found": "No channel found.", "on_hold": "On-hold", @@ -612,6 +617,7 @@ "quotes_remove_none": "No quotes found which you can remove.", "quote_added_new": "Quote #{0} added.", "quote_deleted": "Quote #{0} deleted.", + "quote_edited": "Quote Edited", "region": "Region", "remind": "I will remind {0} to {1} in {2} `({3:d.M.yyyy.} at {4:HH:mm})`", "remind_timely": "I will remind you about your timely reward {0}", @@ -875,7 +881,7 @@ "club_disband_error": "Error. You are either not in a club, or you are not the owner of your club.", "club_icon_too_large": "Image is too large.", "club_icon_invalid_filetype": "Specified image has an invalid filetype. Make sure you're specifying a direct image url.", - "club_icon_url_format": "You must specify an absolute image url/.", + "club_icon_url_format": "You must specify an absolute image url.", "club_icon_set": "New club icon set.", "club_bans_for": "Bans for {0} club", "club_apps_for": "Applicants for {0} club", @@ -960,8 +966,8 @@ "perm_override_all_confirm": "Are you sure that you want to remove **ALL** discord permission overrides on this server? This action is irreversible.", "perm_overrides": "Discord Permission Overrides", "perm_override_reset": "Discord Permission Overrides for this command have been cleared.", - "bj_created": "has created a new BlackJack game in this channel.", - "bj_joined": "has joined the BlackJack game", + "bj_created": "{0} has created a new BlackJack game in this channel.", + "bj_joined": "{0} has joined the BlackJack game", "reset": "Xp Reset", "reset_server_confirm": "Are you sure that you want to reset the XP of all users on the server?", "reset_user_confirm": "Are you sure that you want to reset specified user's XP on this server?", @@ -996,7 +1002,7 @@ "module_description_permissions": "Setup perms for commands, filter words and set up command cooldowns", "module_description_searches": "Search for jokes, images of animals, anime and manga", "module_description_xp": "Gain xp based on chat activity, check users' xp cards", - "module_description_marmalade": "**Bot Owner only.** Load, unload and handle dynamic modules. Read more [here](https://docs.elliebot.net/v4/)", + "module_description_marmalade": "**Bot Owner only.** Load, unload and handle dynamic modules. Read more [here](https://docs.elliebot.net/ellie/)", "module_description_patronage": "Commands related to supporting the bot", "module_description_missing": "Description is missing for this module.", "purge_user_confirm": "Are you sure that you want to purge {0} from the database?", @@ -1094,5 +1100,6 @@ "todo_archive_not_found": "Archived todo list not found.", "todo_archived_list": "Archived Todo List", "search_results": "Search results", - "queue_search_results": "Type the number of the search result to queue up that track." -} \ No newline at end of file + "queue_search_results": "Type the number of the search result to queue up that track.", + "overloads": "Overloads" +} -- 2.43.0 From 06f399ff6334563b62f2aa931e5ac8e3181c34d1 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:32:40 +1200 Subject: [PATCH 015/340] Updated Waifu.cs --- src/EllieBot/Db/Models/Waifu.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EllieBot/Db/Models/Waifu.cs b/src/EllieBot/Db/Models/Waifu.cs index d9f36d9..2583afb 100644 --- a/src/EllieBot/Db/Models/Waifu.cs +++ b/src/EllieBot/Db/Models/Waifu.cs @@ -1,7 +1,7 @@ #nullable disable using EllieBot.Db.Models; -namespace NadekoBot.Services.Database.Models; +namespace EllieBot.Services.Database.Models; public class WaifuInfo : DbEntity { -- 2.43.0 From 547aa8b34deccb047ca651d258794bfc0fb22486 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:44:07 +1200 Subject: [PATCH 016/340] Added common files This took way too long --- src/EllieBot/_common/AddRemove.cs | 10 + .../_common/Attributes/AliasesAttribute.cs | 12 + .../_common/Attributes/CmdAttribute.cs | 18 + .../_common/Attributes/DIIgnoreAttribute.cs | 11 + .../Attributes/EllieOptionsAttribute.cs | 7 + .../Attributes/NoPublicBotAttribute.cs | 20 + .../Attributes/OnlyPublicBotAttribute.cs | 21 + .../_common/Attributes/OwnerOnlyAttribute.cs | 19 + .../_common/Attributes/RatelimitAttribute.cs | 37 + .../_common/Attributes/UserPermAttribute.cs | 29 + src/EllieBot/_common/BotCommandTypeReader.cs | 30 + src/EllieBot/_common/CleanupModuleBase.cs | 25 + src/EllieBot/_common/CleverBotResponseStr.cs | 10 + src/EllieBot/_common/CmdStrings.cs | 17 + src/EllieBot/_common/CommandData.cs | 9 + src/EllieBot/_common/CommandNameLoadHelper.cs | 40 + src/EllieBot/_common/Configs/BotConfig.cs | 210 ++++ src/EllieBot/_common/Configs/IConfigSeria.cs | 18 + src/EllieBot/_common/Creds.cs | 272 ++++++ src/EllieBot/_common/Currency/CurrencyType.cs | 6 + src/EllieBot/_common/Currency/IBankService.cs | 10 + .../_common/Currency/ICurrencyService.cs | 43 + src/EllieBot/_common/Currency/ITxTracker.cs | 9 + src/EllieBot/_common/Currency/IWallet.cs | 40 + src/EllieBot/_common/Currency/TxData.cs | 7 + src/EllieBot/_common/DbService.cs | 15 + src/EllieBot/_common/Deck/Deck.cs | 309 ++++++ src/EllieBot/_common/Deck/NewCard.cs | 5 + src/EllieBot/_common/Deck/NewDeck.cs | 54 ++ .../MultipleRegularDeck.cs | 28 + .../_common/Deck/Regular/RegularCard.cs | 4 + .../_common/Deck/Regular/RegularDeck.cs | 15 + .../Deck/Regular/RegularDeckExtensions.cs | 56 ++ .../_common/Deck/Regular/RegularSuit.cs | 9 + .../_common/Deck/Regular/RegularValue.cs | 18 + src/EllieBot/_common/DoAsUserMessage.cs | 154 +++ src/EllieBot/_common/DownloadTracker.cs | 38 + src/EllieBot/_common/EllieModule.cs | 108 +++ src/EllieBot/_common/EllieTypeReader.cs | 14 + .../Gambling/Betdraw/BetdrawColorGuess.cs | 7 + .../_common/Gambling/Betdraw/BetdrawGame.cs | 86 ++ .../_common/Gambling/Betdraw/BetdrawResult.cs | 11 + .../Gambling/Betdraw/BetdrawResultType.cs | 7 + .../Gambling/Betdraw/BetdrawValueGuess.cs | 7 + .../_common/Gambling/Betflip/BetflipGame.cs | 33 + .../_common/Gambling/Betflip/BetflipResult.cs | 8 + .../_common/Gambling/Betroll/BetrollGame.cs | 42 + .../_common/Gambling/Betroll/BetrollResult.cs | 9 + src/EllieBot/_common/Gambling/Rps/RpsGame.cs | 75 ++ .../_common/Gambling/Slot/SlotGame.cs | 116 +++ .../_common/Gambling/Slot/SlotResult.cs | 9 + .../_common/Gambling/Wof/LuLaResult.cs | 9 + src/EllieBot/_common/Gambling/Wof/WofGame.cs | 34 + src/EllieBot/_common/Helpers.cs | 13 + src/EllieBot/_common/IBot.cs | 12 + src/EllieBot/_common/ICloneable.cs | 8 + src/EllieBot/_common/ICurrencyProvider.cs | 29 + .../_common/IDiscordPermOverrideService.cs | 7 + src/EllieBot/_common/IEllieCommandOptions.cs | 7 + src/EllieBot/_common/ILogCommandService.cs | 34 + src/EllieBot/_common/IPermissionChecker.cs | 37 + src/EllieBot/_common/IPlaceholderProvider.cs | 7 + src/EllieBot/_common/ImageUrls.cs | 51 + .../_common/Interaction/EllieInteraction.cs | 164 ++++ .../Interaction/EllieInteractionService.cs | 77 ++ .../Interaction/IEllieInteractionService.cs | 30 + .../_common/Interaction/InteractionHelpers.cs | 7 + .../Models/EllieButtonInteraction.cs | 21 + .../Models/EllieInteractionExtensions.cs | 15 + .../Models/EllieSelectInteraction.cs | 21 + .../JsonConverters/CultureInfoConverter.cs | 14 + .../_common/JsonConverters/Rgba32Converter.cs | 14 + src/EllieBot/_common/LbOpts.cs | 14 + src/EllieBot/_common/Linq2DbExpressions.cs | 16 + src/EllieBot/_common/LoginErrorHandler.cs | 52 + .../Common/Adapters/BehaviorAdapter.cs | 78 ++ .../Common/Adapters/ContextAdapterFactory.cs | 9 + .../Common/Adapters/DmContextAdapter.cs | 47 + .../Common/Adapters/FilterAdapter.cs | 33 + .../Common/Adapters/GuildContextAdapter.cs | 49 + .../Common/Adapters/ParamParserAdapter.cs | 34 + .../Marmalade/Common/CommandContextType.cs | 27 + .../Common/Config/IMarmaladeConfigService.cs | 8 + .../Common/Config/MarmaladeConfig.cs | 20 + .../Common/Config/MarmaladeConfigService.cs | 45 + .../Common/MarmaladeAssemblyLoadContext.cs | 35 + .../Common/MarmaladeIoCKernelModule.cs | 74 ++ .../Common/MarmaladeLoaderService.cs | 916 ++++++++++++++++++ .../Common/Models/CanaryCommandData.cs | 44 + .../Marmalade/Common/Models/CanaryData.cs | 11 + .../Marmalade/Common/Models/ParamData.cs | 10 + .../Common/Models/ResolvedMarmalade.cs | 15 + .../Marmalade/IMarmaladeLoaderService.cs | 24 + .../_common/Marmalade/MarmaladeLoadResult.cs | 10 + .../Marmalade/MarmaladeUnloadResult.cs | 9 + src/EllieBot/_common/MessageType.cs | 8 + .../_common/ModuleBehaviors/IBehavior.cs | 6 + .../_common/ModuleBehaviors/IExecNoCommand.cs | 19 + .../_common/ModuleBehaviors/IExecOnMessage.cs | 21 + .../ModuleBehaviors/IExecPostCommand.cs | 22 + .../ModuleBehaviors/IExecPreCommand.cs | 25 + .../ModuleBehaviors/IInputTransformer.cs | 25 + .../_common/ModuleBehaviors/IReadyExecutor.cs | 13 + .../_common/NinjectKernelExtensions.cs | 51 + src/EllieBot/_common/OldCreds.cs | 45 + src/EllieBot/_common/OptionsParser.cs | 23 + .../_common/Patronage/FeatureLimitKey.cs | 7 + .../_common/Patronage/FeatureQuotaStats.cs | 8 + src/EllieBot/_common/Patronage/IPatronData.cs | 11 + .../_common/Patronage/IPatronageService.cs | 56 ++ .../_common/Patronage/ISubscriptionHandler.cs | 16 + src/EllieBot/_common/Patronage/Patron.cs | 38 + .../_common/Patronage/PatronConfigData.cs | 37 + .../_common/Patronage/PatronExtensions.cs | 39 + src/EllieBot/_common/Patronage/PatronTier.cs | 14 + src/EllieBot/_common/Patronage/QuotaLimit.cs | 66 ++ src/EllieBot/_common/Patronage/QuotaPer.cs | 8 + .../Patronage/SubscriptionChargeStatus.cs | 10 + .../_common/Patronage/UserQuotaStats.cs | 25 + src/EllieBot/_common/Pokemon/PokemonNameId.cs | 8 + src/EllieBot/_common/Pokemon/SearchPokemon.cs | 41 + .../_common/Pokemon/SearchPokemonAbility.cs | 10 + .../Replacements/IReplacementPatternStore.cs | 20 + .../Replacements/IReplacementService.cs | 7 + .../Replacements/Impl/ReplacementContext.cs | 69 ++ .../Replacements/Impl/ReplacementInfo.cs | 57 ++ .../Impl/ReplacementPatternStore.cs | 130 +++ .../Impl/ReplacementRegistrator.default.cs | 113 +++ .../Replacements/Impl/ReplacementService.cs | 137 +++ .../_common/Replacements/Impl/Replacer.cs | 138 +++ ...RequireObjectPropertiesContractResolver.cs | 15 + .../_common/Sender/IMessageSenderService.cs | 12 + .../_common/Sender/MessageSenderService.cs | 57 ++ .../ResponseBuilder.PaginationSender.cs | 153 +++ .../_common/Sender/ResponseBuilder.cs | 492 ++++++++++ .../Sender/ResponseBuilderExtensions.cs | 28 + .../_common/Sender/ResponseMessageModel.cs | 12 + .../_common/ServiceCollectionExtensions.cs | 131 +++ .../_common/Services/CommandHandler.cs | 433 +++++++++ .../Services/Currency/CurrencyService.cs | 116 +++ .../Currency/CurrencyServiceExtensions.cs | 48 + .../Services/Currency/DefaultWallet.cs | 108 +++ .../Services/Currency/GamblingTxTracker.cs | 110 +++ .../_common/Services/IBehaviourHandler.cs | 17 + .../_common/Services/ICommandHandler.cs | 12 + src/EllieBot/_common/Services/ICoordinator.cs | 20 + .../_common/Services/ICustomBehavior.cs | 13 + src/EllieBot/_common/Services/IEService.cs | 9 + .../_common/Services/IGoogleApiService.cs | 18 + .../_common/Services/ILocalDataCache.cs | 13 + .../_common/Services/ILocalization.cs | 19 + .../_common/Services/IRemindService.cs | 15 + .../_common/Services/IStatsService.cs | 70 ++ .../_common/Services/ITimezoneService.cs | 6 + .../_common/Services/Impl/BehaviorExecutor.cs | 302 ++++++ .../_common/Services/Impl/BlacklistService.cs | 141 +++ .../Services/Impl/CommandsUtilityService.cs | 184 ++++ .../Impl/DiscordPermOverrideService.cs | 136 +++ .../_common/Services/Impl/FontProvider.cs | 60 ++ .../_common/Services/Impl/IImageCache.cs | 17 + .../_common/Services/Impl/ImagesConfig.cs | 19 + .../Services/Impl/RedisImageExtensions.cs | 11 + .../Services/Impl/SingleProcessCoordinator.cs | 58 ++ .../Impl/StartingGuildsListService.cs | 18 + .../_common/Services/Impl/StatsService.cs | 222 +++++ .../_common/Services/Impl/YtdlOperation.cs | 77 ++ .../Services/strings/impl/BotStrings.cs | 102 ++ .../strings/impl/LocalFileStringsSource.cs | 73 ++ .../strings/impl/MemoryBotStringsProvider.cs | 38 + .../_common/Settings/BotConfigService.cs | 73 ++ .../_common/Settings/ConfigParsers.cs | 50 + .../_common/Settings/ConfigServiceBase.cs | 201 ++++ .../_common/Settings/IConfigService.cs | 46 + .../_common/Settings/SettingParser.cs | 8 + .../_common/SmartText/SmartEmbedText.cs | 184 ++++ .../_common/SmartText/SmartEmbedTextArray.cs | 34 + .../_common/SmartText/SmartPlainText.cs | 19 + src/EllieBot/_common/SmartText/SmartText.cs | 92 ++ .../_common/SmartText/SmartTextEmbedAuthor.cs | 16 + .../_common/SmartText/SmartTextEmbedField.cs | 9 + .../_common/SmartText/SmartTextEmbedFooter.cs | 14 + src/EllieBot/_common/TriviaQuestionModel.cs | 11 + src/EllieBot/_common/TypeReaderResult.cs | 30 + .../_common/TypeReaders/CommandOrExprInfo.cs | 23 + .../_common/TypeReaders/EmoteTypeReader.cs | 13 + .../TypeReaders/GuildDateTimeTypeReader.cs | 49 + .../_common/TypeReaders/GuildTypeReader.cs | 24 + .../TypeReaders/GuildUserTypeReader.cs | 33 + .../_common/TypeReaders/KwumTypeReader.cs | 19 + .../TypeReaders/Models/PermissionAction.cs | 27 + .../_common/TypeReaders/Models/StoopidTime.cs | 55 ++ .../_common/TypeReaders/ModuleTypeReader.cs | 52 + .../TypeReaders/PermissionActionTypeReader.cs | 39 + .../_common/TypeReaders/Rgba32TypeReader.cs | 20 + .../TypeReaders/StoopidTimeTypeReader.cs | 22 + src/EllieBot/_common/Yml/CommentAttribute.cs | 11 + .../Yml/CommentGatheringTypeInspector.cs | 65 ++ .../_common/Yml/CommentsObjectDescriptor.cs | 30 + .../_common/Yml/CommentsObjectGraphVisitor.cs | 29 + .../Yml/MultilineScalarFlowStyleEmitter.cs | 36 + src/EllieBot/_common/Yml/Rgba32Converter.cs | 47 + src/EllieBot/_common/Yml/UriConverter.cs | 25 + src/EllieBot/_common/Yml/Yaml.cs | 30 + .../_Extensions/BotCredentialsExtensions.cs | 10 + .../_Extensions/CommandContextExtensions.cs | 30 + .../_common/_Extensions/DbExtensions.cs | 11 + .../_common/_Extensions/Extensions.cs | 237 +++++ .../_Extensions/ImagesharpExtensions.cs | 97 ++ .../_Extensions/LinkedListExtensions.cs | 18 + .../_common/_Extensions/NumberExtensions.cs | 7 + .../_Extensions/ReflectionExtensions.cs | 23 + .../_common/_Extensions/Rgba32Extensions.cs | 57 ++ .../SocketMessageComponentExtensions.cs | 33 + .../_common/_Extensions/UserExtensions.cs | 21 + 214 files changed, 11046 insertions(+) create mode 100644 src/EllieBot/_common/AddRemove.cs create mode 100644 src/EllieBot/_common/Attributes/AliasesAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/CmdAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/RatelimitAttribute.cs create mode 100644 src/EllieBot/_common/Attributes/UserPermAttribute.cs create mode 100644 src/EllieBot/_common/BotCommandTypeReader.cs create mode 100644 src/EllieBot/_common/CleanupModuleBase.cs create mode 100644 src/EllieBot/_common/CleverBotResponseStr.cs create mode 100644 src/EllieBot/_common/CmdStrings.cs create mode 100644 src/EllieBot/_common/CommandData.cs create mode 100644 src/EllieBot/_common/CommandNameLoadHelper.cs create mode 100644 src/EllieBot/_common/Configs/BotConfig.cs create mode 100644 src/EllieBot/_common/Configs/IConfigSeria.cs create mode 100644 src/EllieBot/_common/Creds.cs create mode 100644 src/EllieBot/_common/Currency/CurrencyType.cs create mode 100644 src/EllieBot/_common/Currency/IBankService.cs create mode 100644 src/EllieBot/_common/Currency/ICurrencyService.cs create mode 100644 src/EllieBot/_common/Currency/ITxTracker.cs create mode 100644 src/EllieBot/_common/Currency/IWallet.cs create mode 100644 src/EllieBot/_common/Currency/TxData.cs create mode 100644 src/EllieBot/_common/DbService.cs create mode 100644 src/EllieBot/_common/Deck/Deck.cs create mode 100644 src/EllieBot/_common/Deck/NewCard.cs create mode 100644 src/EllieBot/_common/Deck/NewDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularCard.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularSuit.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularValue.cs create mode 100644 src/EllieBot/_common/DoAsUserMessage.cs create mode 100644 src/EllieBot/_common/DownloadTracker.cs create mode 100644 src/EllieBot/_common/EllieModule.cs create mode 100644 src/EllieBot/_common/EllieTypeReader.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs create mode 100644 src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs create mode 100644 src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs create mode 100644 src/EllieBot/_common/Gambling/Rps/RpsGame.cs create mode 100644 src/EllieBot/_common/Gambling/Slot/SlotGame.cs create mode 100644 src/EllieBot/_common/Gambling/Slot/SlotResult.cs create mode 100644 src/EllieBot/_common/Gambling/Wof/LuLaResult.cs create mode 100644 src/EllieBot/_common/Gambling/Wof/WofGame.cs create mode 100644 src/EllieBot/_common/Helpers.cs create mode 100644 src/EllieBot/_common/IBot.cs create mode 100644 src/EllieBot/_common/ICloneable.cs create mode 100644 src/EllieBot/_common/ICurrencyProvider.cs create mode 100644 src/EllieBot/_common/IDiscordPermOverrideService.cs create mode 100644 src/EllieBot/_common/IEllieCommandOptions.cs create mode 100644 src/EllieBot/_common/ILogCommandService.cs create mode 100644 src/EllieBot/_common/IPermissionChecker.cs create mode 100644 src/EllieBot/_common/IPlaceholderProvider.cs create mode 100644 src/EllieBot/_common/ImageUrls.cs create mode 100644 src/EllieBot/_common/Interaction/EllieInteraction.cs create mode 100644 src/EllieBot/_common/Interaction/EllieInteractionService.cs create mode 100644 src/EllieBot/_common/Interaction/IEllieInteractionService.cs create mode 100644 src/EllieBot/_common/Interaction/InteractionHelpers.cs create mode 100644 src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs create mode 100644 src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs create mode 100644 src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs create mode 100644 src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs create mode 100644 src/EllieBot/_common/JsonConverters/Rgba32Converter.cs create mode 100644 src/EllieBot/_common/LbOpts.cs create mode 100644 src/EllieBot/_common/Linq2DbExpressions.cs create mode 100644 src/EllieBot/_common/LoginErrorHandler.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/CommandContextType.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs create mode 100644 src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs create mode 100644 src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs create mode 100644 src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs create mode 100644 src/EllieBot/_common/MessageType.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IBehavior.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs create mode 100644 src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs create mode 100644 src/EllieBot/_common/NinjectKernelExtensions.cs create mode 100644 src/EllieBot/_common/OldCreds.cs create mode 100644 src/EllieBot/_common/OptionsParser.cs create mode 100644 src/EllieBot/_common/Patronage/FeatureLimitKey.cs create mode 100644 src/EllieBot/_common/Patronage/FeatureQuotaStats.cs create mode 100644 src/EllieBot/_common/Patronage/IPatronData.cs create mode 100644 src/EllieBot/_common/Patronage/IPatronageService.cs create mode 100644 src/EllieBot/_common/Patronage/ISubscriptionHandler.cs create mode 100644 src/EllieBot/_common/Patronage/Patron.cs create mode 100644 src/EllieBot/_common/Patronage/PatronConfigData.cs create mode 100644 src/EllieBot/_common/Patronage/PatronExtensions.cs create mode 100644 src/EllieBot/_common/Patronage/PatronTier.cs create mode 100644 src/EllieBot/_common/Patronage/QuotaLimit.cs create mode 100644 src/EllieBot/_common/Patronage/QuotaPer.cs create mode 100644 src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs create mode 100644 src/EllieBot/_common/Patronage/UserQuotaStats.cs create mode 100644 src/EllieBot/_common/Pokemon/PokemonNameId.cs create mode 100644 src/EllieBot/_common/Pokemon/SearchPokemon.cs create mode 100644 src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs create mode 100644 src/EllieBot/_common/Replacements/IReplacementPatternStore.cs create mode 100644 src/EllieBot/_common/Replacements/IReplacementService.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementService.cs create mode 100644 src/EllieBot/_common/Replacements/Impl/Replacer.cs create mode 100644 src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs create mode 100644 src/EllieBot/_common/Sender/IMessageSenderService.cs create mode 100644 src/EllieBot/_common/Sender/MessageSenderService.cs create mode 100644 src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs create mode 100644 src/EllieBot/_common/Sender/ResponseBuilder.cs create mode 100644 src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs create mode 100644 src/EllieBot/_common/Sender/ResponseMessageModel.cs create mode 100644 src/EllieBot/_common/ServiceCollectionExtensions.cs create mode 100644 src/EllieBot/_common/Services/CommandHandler.cs create mode 100644 src/EllieBot/_common/Services/Currency/CurrencyService.cs create mode 100644 src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs create mode 100644 src/EllieBot/_common/Services/Currency/DefaultWallet.cs create mode 100644 src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs create mode 100644 src/EllieBot/_common/Services/IBehaviourHandler.cs create mode 100644 src/EllieBot/_common/Services/ICommandHandler.cs create mode 100644 src/EllieBot/_common/Services/ICoordinator.cs create mode 100644 src/EllieBot/_common/Services/ICustomBehavior.cs create mode 100644 src/EllieBot/_common/Services/IEService.cs create mode 100644 src/EllieBot/_common/Services/IGoogleApiService.cs create mode 100644 src/EllieBot/_common/Services/ILocalDataCache.cs create mode 100644 src/EllieBot/_common/Services/ILocalization.cs create mode 100644 src/EllieBot/_common/Services/IRemindService.cs create mode 100644 src/EllieBot/_common/Services/IStatsService.cs create mode 100644 src/EllieBot/_common/Services/ITimezoneService.cs create mode 100644 src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs create mode 100644 src/EllieBot/_common/Services/Impl/BlacklistService.cs create mode 100644 src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs create mode 100644 src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs create mode 100644 src/EllieBot/_common/Services/Impl/FontProvider.cs create mode 100644 src/EllieBot/_common/Services/Impl/IImageCache.cs create mode 100644 src/EllieBot/_common/Services/Impl/ImagesConfig.cs create mode 100644 src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs create mode 100644 src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs create mode 100644 src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs create mode 100644 src/EllieBot/_common/Services/Impl/StatsService.cs create mode 100644 src/EllieBot/_common/Services/Impl/YtdlOperation.cs create mode 100644 src/EllieBot/_common/Services/strings/impl/BotStrings.cs create mode 100644 src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs create mode 100644 src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs create mode 100644 src/EllieBot/_common/Settings/BotConfigService.cs create mode 100644 src/EllieBot/_common/Settings/ConfigParsers.cs create mode 100644 src/EllieBot/_common/Settings/ConfigServiceBase.cs create mode 100644 src/EllieBot/_common/Settings/IConfigService.cs create mode 100644 src/EllieBot/_common/Settings/SettingParser.cs create mode 100644 src/EllieBot/_common/SmartText/SmartEmbedText.cs create mode 100644 src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs create mode 100644 src/EllieBot/_common/SmartText/SmartPlainText.cs create mode 100644 src/EllieBot/_common/SmartText/SmartText.cs create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedField.cs create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs create mode 100644 src/EllieBot/_common/TriviaQuestionModel.cs create mode 100644 src/EllieBot/_common/TypeReaderResult.cs create mode 100644 src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs create mode 100644 src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/GuildTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/KwumTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs create mode 100644 src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs create mode 100644 src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs create mode 100644 src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs create mode 100644 src/EllieBot/_common/Yml/CommentAttribute.cs create mode 100644 src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs create mode 100644 src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs create mode 100644 src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs create mode 100644 src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs create mode 100644 src/EllieBot/_common/Yml/Rgba32Converter.cs create mode 100644 src/EllieBot/_common/Yml/UriConverter.cs create mode 100644 src/EllieBot/_common/Yml/Yaml.cs create mode 100644 src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/CommandContextExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/DbExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/Extensions.cs create mode 100644 src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/LinkedListExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/NumberExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/ReflectionExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/Rgba32Extensions.cs create mode 100644 src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs create mode 100644 src/EllieBot/_common/_Extensions/UserExtensions.cs diff --git a/src/EllieBot/_common/AddRemove.cs b/src/EllieBot/_common/AddRemove.cs new file mode 100644 index 0000000..bb3862e --- /dev/null +++ b/src/EllieBot/_common/AddRemove.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Common; + +public enum AddRemove +{ + Add = int.MinValue, + Remove = int.MinValue + 1, + Rem = int.MinValue + 1, + Rm = int.MinValue + 1 +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/AliasesAttribute.cs b/src/EllieBot/_common/Attributes/AliasesAttribute.cs new file mode 100644 index 0000000..bef833e --- /dev/null +++ b/src/EllieBot/_common/Attributes/AliasesAttribute.cs @@ -0,0 +1,12 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class AliasesAttribute : AliasAttribute +{ + public AliasesAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetAliasesFor(memberName)) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/CmdAttribute.cs b/src/EllieBot/_common/Attributes/CmdAttribute.cs new file mode 100644 index 0000000..a02fd1e --- /dev/null +++ b/src/EllieBot/_common/Attributes/CmdAttribute.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class CmdAttribute : CommandAttribute +{ + public string MethodName { get; } + + public CmdAttribute([CallerMemberName] string memberName = "") + : base(CommandNameLoadHelper.GetCommandNameFor(memberName)) + { + MethodName = memberName.ToLowerInvariant(); + Aliases = CommandNameLoadHelper.GetAliasesFor(memberName); + Remarks = memberName.ToLowerInvariant(); + Summary = memberName.ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs new file mode 100644 index 0000000..7be799a --- /dev/null +++ b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common; + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[AttributeUsage(AttributeTargets.Class)] +public class DIIgnoreAttribute : Attribute +{ + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs new file mode 100644 index 0000000..c94b109 --- /dev/null +++ b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class EllieOptionsAttribute : Attribute + where TOption: IEllieCommandOptions +{ +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs new file mode 100644 index 0000000..2ce8ccc --- /dev/null +++ b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs @@ -0,0 +1,20 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NoPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE + return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net/ellie/).")); +#else + return Task.FromResult(PreconditionResult.FromSuccess()); +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs new file mode 100644 index 0000000..6ae9408 --- /dev/null +++ b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs @@ -0,0 +1,21 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +[SuppressMessage("Style", "IDE0022:Use expression body for methods")] +public sealed class OnlyPublicBotAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { +#if GLOBAL_ELLIE || DEBUG + return Task.FromResult(PreconditionResult.FromSuccess()); +#else + return Task.FromResult(PreconditionResult.FromError("Only available on the public bot.")); +#endif + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs new file mode 100644 index 0000000..7aa9317 --- /dev/null +++ b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class OwnerOnlyAttribute : PreconditionAttribute +{ + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var creds = services.GetRequiredService().GetCreds(); + + return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("Not owner")); + } +} diff --git a/src/EllieBot/_common/Attributes/RatelimitAttribute.cs b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs new file mode 100644 index 0000000..7fcf9c8 --- /dev/null +++ b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class RatelimitAttribute : PreconditionAttribute +{ + public int Seconds { get; } + + public RatelimitAttribute(int seconds) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds); + + Seconds = seconds; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + if (Seconds == 0) + return PreconditionResult.FromSuccess(); + + var cache = services.GetRequiredService(); + var rem = await cache.GetRatelimitAsync( + new($"precondition:{context.User.Id}:{command.Name}"), + Seconds.Seconds()); + + if (rem is null) + return PreconditionResult.FromSuccess(); + + var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s."; + + return PreconditionResult.FromError(msgContent); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/UserPermAttribute.cs b/src/EllieBot/_common/Attributes/UserPermAttribute.cs new file mode 100644 index 0000000..1b4ee75 --- /dev/null +++ b/src/EllieBot/_common/Attributes/UserPermAttribute.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Discord; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class UserPermAttribute : RequireUserPermissionAttribute +{ + public UserPermAttribute(GuildPerm permission) + : base(permission) + { + } + + public UserPermAttribute(ChannelPerm permission) + : base(permission) + { + } + + public override Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var permService = services.GetRequiredService(); + if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _)) + return Task.FromResult(PreconditionResult.FromSuccess()); + + return base.CheckPermissionsAsync(context, command, services); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/BotCommandTypeReader.cs b/src/EllieBot/_common/BotCommandTypeReader.cs new file mode 100644 index 0000000..fc839bd --- /dev/null +++ b/src/EllieBot/_common/BotCommandTypeReader.cs @@ -0,0 +1,30 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class CommandTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _handler; + + public CommandTypeReader(ICommandHandler handler, CommandService cmds) + { + _handler = handler; + _cmds = cmds; + } + + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + input = input.ToUpperInvariant(); + var prefix = _handler.GetPrefix(ctx.Guild); + if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + input = input[prefix.Length..]; + + var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input)); + if (cmd is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such command found.")); + + return new(TypeReaderResult.FromSuccess(cmd)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CleanupModuleBase.cs b/src/EllieBot/_common/CleanupModuleBase.cs new file mode 100644 index 0000000..1e97a66 --- /dev/null +++ b/src/EllieBot/_common/CleanupModuleBase.cs @@ -0,0 +1,25 @@ +#nullable disable +namespace EllieBot.Common; + +public abstract class CleanupModuleBase : EllieModule +{ + protected async Task ConfirmActionInternalAsync(string name, Func action) + { + try + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(name); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await action(); + await ctx.OkAsync(); + } + catch (Exception ex) + { + await Response().Error(ex.ToString()).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CleverBotResponseStr.cs b/src/EllieBot/_common/CleverBotResponseStr.cs new file mode 100644 index 0000000..6675a41 --- /dev/null +++ b/src/EllieBot/_common/CleverBotResponseStr.cs @@ -0,0 +1,10 @@ +#nullable disable +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Permissions; + +[StructLayout(LayoutKind.Sequential, Size = 1)] +public readonly struct CleverBotResponseStr +{ + public const string CLEVERBOT_RESPONSE = "cleverbot:response"; +} \ No newline at end of file diff --git a/src/EllieBot/_common/CmdStrings.cs b/src/EllieBot/_common/CmdStrings.cs new file mode 100644 index 0000000..c28ed1a --- /dev/null +++ b/src/EllieBot/_common/CmdStrings.cs @@ -0,0 +1,17 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Common; + +public class CmdStrings +{ + public string[] Usages { get; } + public string Description { get; } + + [JsonConstructor] + public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description) + { + Usages = usages; + Description = description; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CommandData.cs b/src/EllieBot/_common/CommandData.cs new file mode 100644 index 0000000..f0514da --- /dev/null +++ b/src/EllieBot/_common/CommandData.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Common; + +public class CommandData +{ + public string Cmd { get; set; } + public string Desc { get; set; } + public string[] Usage { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/CommandNameLoadHelper.cs b/src/EllieBot/_common/CommandNameLoadHelper.cs new file mode 100644 index 0000000..3d69f2e --- /dev/null +++ b/src/EllieBot/_common/CommandNameLoadHelper.cs @@ -0,0 +1,40 @@ +using EllieBot.Common.Yml; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Attributes; + +public static class CommandNameLoadHelper +{ + private static readonly IDeserializer _deserializer = new Deserializer(); + + private static readonly Lazy> _lazyCommandAliases + = new(() => LoadAliases()); + + public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml") + { + var text = File.ReadAllText(aliasesFilePath); + return _deserializer.Deserialize>(text); + } + + public static Dictionary LoadCommandStrings( + string commandsFilePath = "data/strings/commands.yml") + { + var text = File.ReadAllText(commandsFilePath); + + return Yaml.Deserializer.Deserialize>(text); + } + + public static string[] GetAliasesFor(string methodName) + => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1 + ? aliases.ToArray() + : Array.Empty(); + + public static string GetCommandNameFor(string methodName) + { + methodName = methodName.ToLowerInvariant(); + var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0 + ? aliases[0] + : methodName; + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Configs/BotConfig.cs b/src/EllieBot/_common/Configs/BotConfig.cs new file mode 100644 index 0000000..0715701 --- /dev/null +++ b/src/EllieBot/_common/Configs/BotConfig.cs @@ -0,0 +1,210 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Configs; + +[Cloneable] +public sealed partial class BotConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 7; + + [Comment(""" + Most commands, when executed, have a small colored line + next to the response. The color depends whether the command + is completed, errored or in progress (pending) + Color settings below are for the color of those lines. + To get color's hex, you can go here https://htmlcolorcodes.com/ + and copy the hex code fo your selected color (marked as #) + """)] + public ColorConfig Color { get; set; } + + [Comment("Default bot language. It has to be in the list of supported languages (.langli)")] + public CultureInfo DefaultLocale { get; set; } + + [Comment(""" + Style in which executed commands will show up in the console. + Allowed values: Simple, Normal, None + """)] + public ConsoleOutputType ConsoleOutputType { get; set; } + + [Comment("""Whether the bot will check for new releases every hour""")] + public bool CheckForUpdates { get; set; } = true; + + [Comment("""Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?""")] + public bool ForwardMessages { get; set; } + + [Comment(""" + Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml), + or all owners? (this might cause the bot to lag if there's a lot of owners specified) + """)] + public bool ForwardToAllOwners { get; set; } + + [Comment(""" + Any messages sent by users in Bot's DM to be forwarded to the specified channel. + This option will only work when ForwardToAllOwners is set to false + """)] + public ulong? ForwardToChannel { get; set; } + + [Comment(""" + Should the bot ignore messages from other bots? + Settings this to false might get your bot banned if it gets into a spam loop with another bot. + This will only affect command executions, other features will still block bots from access. + Default true + """)] + public bool IgnoreOtherBots { get; set; } + + [Comment(""" + When a user DMs the bot with a message which is not a command + they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot. + Supports embeds. How it looks: https://puu.sh/B0BLV.png + """)] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string DmHelpText { get; set; } + + [Comment(""" + Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response. + Case insensitive. + Leave empty to reply with DmHelpText to every DM. + """)] + public List DmHelpTextKeywords { get; set; } + + [Comment("""This is the response for the .h command""")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string HelpText { get; set; } + + [Comment("""List of modules and commands completely blocked on the bot""")] + public BlockedConfig Blocked { get; set; } + + [Comment("""Which string will be used to recognize the commands""")] + public string Prefix { get; set; } + + [Comment(""" + Toggles whether your bot will group greet/bye messages into a single message every 5 seconds. + 1st user who joins will get greeted immediately + If more users join within the next 5 seconds, they will be greeted in groups of 5. + This will cause %user.mention% and other placeholders to be replaced with multiple users. + Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail, + it will become invalid, as it will resolve to a list of avatars of grouped users. + note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some + servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited, + and (slightly) reduce the greet spam in those servers. + """)] + public bool GroupGreets { get; set; } + + [Comment(""" + Whether the bot will rotate through all specified statuses. + This setting can be changed via .ropl command. + See RotatingStatuses submodule in Administration. + """)] + public bool RotateStatuses { get; set; } + + public BotConfig() + { + var color = new ColorConfig(); + Color = color; + DefaultLocale = new("en-US"); + ConsoleOutputType = ConsoleOutputType.Normal; + ForwardMessages = false; + ForwardToAllOwners = false; + DmHelpText = """{"description": "Type `%prefix%h` for help."}"""; + HelpText = """ + { + "title": "To invite me to your server, use this link", + "description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", + "color": 53380, + "thumbnail": "https://cdn.elliebot.net/Ellie.png", + "fields": [ + { + "name": "Useful help commands", + "value": "`%bot.prefix%modules` Lists all bot modules. + `%prefix%h CommandName` Shows some help about a specific command. + `%prefix%commands ModuleName` Lists all commands in a module.", + "inline": false + }, + { + "name": "List of all Commands", + "value": "https://commands.elliebot.net/", + "inline": false + }, + { + "name": "Ellie Support Server", + "value": "https://discord.gg/etQdZxSyEH ", + "inline": true + } + ] + } + """; + var blocked = new BlockedConfig(); + Blocked = blocked; + Prefix = "."; + RotateStatuses = false; + GroupGreets = false; + DmHelpTextKeywords = + [ + "help", + "commands", + "cmds", + "module", + "can you do" + ]; + } + + // [Comment(@"Whether the prefix will be a suffix, or prefix. + // For example, if your prefix is ! you will run a command called 'cash' by typing either + // '!cash @Someone' if your prefixIsSuffix: false or + // 'cash @Someone!' if your prefixIsSuffix: true")] + // public bool PrefixIsSuffix { get; set; } + + // public string Prefixed(string text) => PrefixIsSuffix + // ? text + Prefix + // : Prefix + text; + + public string Prefixed(string text) + => Prefix + text; +} + +[Cloneable] +public sealed partial class BlockedConfig +{ + public HashSet Commands { get; set; } + public HashSet Modules { get; set; } + + public BlockedConfig() + { + Modules = []; + Commands = []; + } +} + +[Cloneable] +public partial class ColorConfig +{ + [Comment("""Color used for embed responses when command successfully executes""")] + public Rgba32 Ok { get; set; } + + [Comment("""Color used for embed responses when command has an error""")] + public Rgba32 Error { get; set; } + + [Comment("""Color used for embed responses while command is doing work or is in progress""")] + public Rgba32 Pending { get; set; } + + public ColorConfig() + { + Ok = Rgba32.ParseHex("00e584"); + Error = Rgba32.ParseHex("ee281f"); + Pending = Rgba32.ParseHex("faa61a"); + } +} + +public enum ConsoleOutputType +{ + Normal = 0, + Simple = 1, + None = 2 +} \ No newline at end of file diff --git a/src/EllieBot/_common/Configs/IConfigSeria.cs b/src/EllieBot/_common/Configs/IConfigSeria.cs new file mode 100644 index 0000000..1f96850 --- /dev/null +++ b/src/EllieBot/_common/Configs/IConfigSeria.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Common.Configs; + +/// +/// Base interface for available config serializers +/// +public interface IConfigSeria +{ + /// + /// Serialize the object to string + /// + public string Serialize(T obj) + where T : notnull; + + /// + /// Deserialize string data into an object of the specified type + /// + public T Deserialize(string data); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Creds.cs b/src/EllieBot/_common/Creds.cs new file mode 100644 index 0000000..f6aef5d --- /dev/null +++ b/src/EllieBot/_common/Creds.cs @@ -0,0 +1,272 @@ +#nullable disable +using EllieBot.Common.Yml; + +namespace Ellie.Common; + +public sealed class Creds : IBotCredentials +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } + + [Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")] + public string Token { get; set; } + + [Comment(""" + List of Ids of the users who have bot owner permissions + **DO NOT ADD PEOPLE YOU DON'T TRUST** + """)] + public ICollection OwnerIds { get; set; } + + [Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")] + public bool UsePrivilegedIntents { get; set; } + + [Comment(""" + The number of shards that the bot will be running on. + Leave at 1 if you don't know what you're doing. + + note: If you are planning to have more than one shard, then you must change botCache to 'redis'. + Also, in that case you should be using EllieBot.Coordinator to start the bot, and it will correctly override this value. + """)] + public int TotalShards { get; set; } + + [Comment( + """ + Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it. + Then, go to APIs and Services -> Credentials and click Create credentials -> API key. + Used only for Youtube Data Api (at the moment). + """)] + public string GoogleApiKey { get; set; } + + [Comment( + """ + Create a new custom search here https://programmablesearchengine.google.com/cse/create/new + Enable SafeSearch + Remove all Sites to Search + Enable Search the entire web + Copy the 'Search Engine ID' to the SearchId field + + Do all steps again but enable image search for the ImageSearchId + """)] + public GoogleApiConfig Google { get; set; } + + [Comment("""Settings for voting system for discordbots. Meant for use on global Ellie.""")] + public VotesSettings Votes { get; set; } + + [Comment(""" + Patreon auto reward system settings. + go to https://www.patreon.com/portal -> my clients -> create client + """)] + public PatreonSettings Patreon { get; set; } + + [Comment("""Api key for sending stats to DiscordBotList.""")] + public string BotListToken { get; set; } + + [Comment("""Official cleverbot api key.""")] + public string CleverbotApiKey { get; set; } + + [Comment(@"Official GPT-3 api key.")] + public string Gpt3ApiKey { get; set; } + + [Comment(""" + Which cache implementation should bot use. + 'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. + 'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml + """)] + public BotCacheImplemenation BotCache { get; set; } + + [Comment(""" + Redis connection string. Don't change if you don't know what you're doing. + Only used if botCache is set to 'redis' + """)] + public string RedisOptions { get; set; } + + [Comment("""Database options. Don't change if you don't know what you're doing. Leave null for default values""")] + public DbOptions Db { get; set; } + + [Comment(""" + Address and port of the coordinator endpoint. Leave empty for default. + Change only if you've changed the coordinator address or port. + """)] + public string CoordinatorUrl { get; set; } + + [Comment( + """Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)""")] + public string RapidApiKey { get; set; } + + [Comment(""" + https://locationiq.com api key (register and you will receive the token in the email). + Used only for .time command. + """)] + public string LocationIqApiKey { get; set; } + + [Comment(""" + https://timezonedb.com api key (register and you will receive the token in the email). + Used only for .time command + """)] + public string TimezoneDbApiKey { get; set; } + + [Comment(""" + https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use. + Used for cryptocurrency related commands. + """)] + public string CoinmarketcapApiKey { get; set; } + + // [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute. + // Used for stocks related commands.")] + // public string PolygonIoApiKey { get; set; } + + [Comment("""Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api""")] + public string OsuApiKey { get; set; } + + [Comment(""" + Optional Trovo client id. + You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors. + """)] + public string TrovoClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientId { get; set; } + + [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")] + public string TwitchClientSecret { get; set; } + + [Comment(""" + Command and args which will be used to restart the bot. + Only used if bot is executed directly (NOT through the coordinator) + placeholders: + {0} -> shard id + {1} -> total shards + Linux default + cmd: dotnet + args: "EllieBot.dll -- {0}" + Windows default + cmd: EllieBot.exe + args: "{0}" + """)] + public RestartConfig RestartCommand { get; set; } + + public Creds() + { + Version = 7; + Token = string.Empty; + UsePrivilegedIntents = true; + OwnerIds = new List(); + TotalShards = 1; + GoogleApiKey = string.Empty; + Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty); + Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty); + BotListToken = string.Empty; + CleverbotApiKey = string.Empty; + Gpt3ApiKey = string.Empty; + BotCache = BotCacheImplemenation.Memory; + RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password="; + Db = new DbOptions() + { + Type = "sqlite", + ConnectionString = "Data Source=data/EllieBot.db" + }; + + CoordinatorUrl = "http://localhost:3442"; + + RestartCommand = new RestartConfig(); + Google = new GoogleApiConfig(); + } + + public class DbOptions + : IDbOptions + { + [Comment(""" + Database type. "sqlite", "mysql" and "postgresql" are supported. + Default is "sqlite" + """)] + public string Type { get; set; } + + [Comment(""" + Database connection string. + You MUST change this if you're not using "sqlite" type. + Default is "Data Source=data/EllieBot.db" + Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie" + Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;" + """)] + public string ConnectionString { get; set; } + } + + public sealed record PatreonSettings : IPatreonSettings + { + public string ClientId { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public string ClientSecret { get; set; } + + [Comment( + """Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")] + public string CampaignId { get; set; } + + public PatreonSettings( + string accessToken, + string refreshToken, + string clientSecret, + string campaignId) + { + AccessToken = accessToken; + RefreshToken = refreshToken; + ClientSecret = clientSecret; + CampaignId = campaignId; + } + + public PatreonSettings() + { + } + } + + public sealed record VotesSettings : IVotesSettings + { + [Comment(""" + top.gg votes service url + This is the url of your instance of the EllieBot.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string TopggServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the TopGG service url with each request + This should be equivalent to the TopggKey in your EllieBot.Votes api appsettings.json file + """)] + public string TopggKey { get; set; } + + [Comment(""" + discords.com votes service url + This is the url of your instance of the EllieBot.Votes api + Example: https://votes.my.cool.bot.com + """)] + public string DiscordsServiceUrl { get; set; } + + [Comment(""" + Authorization header value sent to the Discords service url with each request + This should be equivalent to the DiscordsKey in your EllieBot.Votes api appsettings.json file + """)] + public string DiscordsKey { get; set; } + + public VotesSettings() + { + } + + public VotesSettings( + string topggServiceUrl, + string topggKey, + string discordsServiceUrl, + string discordsKey) + { + TopggServiceUrl = topggServiceUrl; + TopggKey = topggKey; + DiscordsServiceUrl = discordsServiceUrl; + DiscordsKey = discordsKey; + } + } +} + +public class GoogleApiConfig : IGoogleApiConfig +{ + public string SearchId { get; init; } + public string ImageSearchId { get; init; } +} diff --git a/src/EllieBot/_common/Currency/CurrencyType.cs b/src/EllieBot/_common/Currency/CurrencyType.cs new file mode 100644 index 0000000..1037fa4 --- /dev/null +++ b/src/EllieBot/_common/Currency/CurrencyType.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Services.Currency; + +public enum CurrencyType +{ + Default +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/IBankService.cs b/src/EllieBot/_common/Currency/IBankService.cs new file mode 100644 index 0000000..f563fb9 --- /dev/null +++ b/src/EllieBot/_common/Currency/IBankService.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.Gambling.Bank; + +public interface IBankService +{ + Task DepositAsync(ulong userId, long amount); + Task WithdrawAsync(ulong userId, long amount); + Task GetBalanceAsync(ulong userId); + Task AwardAsync(ulong userId, long amount); + Task TakeAsync(ulong userId, long amount); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/ICurrencyService.cs b/src/EllieBot/_common/Currency/ICurrencyService.cs new file mode 100644 index 0000000..35e8273 --- /dev/null +++ b/src/EllieBot/_common/Currency/ICurrencyService.cs @@ -0,0 +1,43 @@ +using EllieBot.Db.Models; +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public interface ICurrencyService +{ + Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default); + + Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task AddAsync( + ulong userId, + long amount, + TxData? txData); + + Task AddAsync( + IUser user, + long amount, + TxData? txData); + + Task RemoveAsync( + ulong userId, + long amount, + TxData? txData); + + Task RemoveAsync( + IUser user, + long amount, + TxData? txData); + + Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/ITxTracker.cs b/src/EllieBot/_common/Currency/ITxTracker.cs new file mode 100644 index 0000000..d7cad66 --- /dev/null +++ b/src/EllieBot/_common/Currency/ITxTracker.cs @@ -0,0 +1,9 @@ +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public interface ITxTracker +{ + Task TrackAdd(long amount, TxData? txData); + Task TrackRemove(long amount, TxData? txData); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/IWallet.cs b/src/EllieBot/_common/Currency/IWallet.cs new file mode 100644 index 0000000..39018e9 --- /dev/null +++ b/src/EllieBot/_common/Currency/IWallet.cs @@ -0,0 +1,40 @@ +namespace EllieBot.Services.Currency; + +public interface IWallet +{ + public ulong UserId { get; } + + public Task GetBalance(); + public Task Take(long amount, TxData? txData); + public Task Add(long amount, TxData? txData); + + public async Task Transfer( + long amount, + IWallet to, + TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + if (txData is not null) + txData = txData with + { + OtherId = to.UserId + }; + + var succ = await Take(amount, txData); + + if (!succ) + return false; + + if (txData is not null) + txData = txData with + { + OtherId = UserId + }; + + await to.Add(amount, txData); + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/TxData.cs b/src/EllieBot/_common/Currency/TxData.cs new file mode 100644 index 0000000..06dbab2 --- /dev/null +++ b/src/EllieBot/_common/Currency/TxData.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Services.Currency; + +public record class TxData( + string Type, + string Extra, + string? Note = "", + ulong? OtherId = null); \ No newline at end of file diff --git a/src/EllieBot/_common/DbService.cs b/src/EllieBot/_common/DbService.cs new file mode 100644 index 0000000..cdff91f --- /dev/null +++ b/src/EllieBot/_common/DbService.cs @@ -0,0 +1,15 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Services; + +public abstract class DbService +{ + /// + /// Call this to apply all migrations + /// + public abstract Task SetupAsync(); + + public abstract DbContext CreateRawDbContext(string dbType, string connString); + public abstract DbContext GetDbContext(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Deck.cs b/src/EllieBot/_common/Deck/Deck.cs new file mode 100644 index 0000000..3398d3c --- /dev/null +++ b/src/EllieBot/_common/Deck/Deck.cs @@ -0,0 +1,309 @@ +#nullable disable +namespace Ellie.Econ; + +public class Deck +{ + public enum CardSuit + { + Spades = 1, + Hearts = 2, + Diamonds = 3, + Clubs = 4 + } + + private static readonly Dictionary _cardNames = new() + { + { 1, "Ace" }, + { 2, "Two" }, + { 3, "Three" }, + { 4, "Four" }, + { 5, "Five" }, + { 6, "Six" }, + { 7, "Seven" }, + { 8, "Eight" }, + { 9, "Nine" }, + { 10, "Ten" }, + { 11, "Jack" }, + { 12, "Queen" }, + { 13, "King" } + }; + + private static Dictionary, bool>> handValues; + + public List CardPool { get; set; } + private readonly Random _r = new EllieRandom(); + + static Deck() + => InitHandValues(); + + /// + /// Creates a new instance of the BlackJackGame, this allows you to create multiple games running at one time. + /// + public Deck() + => RefillPool(); + + /// + /// Restart the game of blackjack. It will only refill the pool for now. Probably wont be used, unless you want to have + /// only 1 bjg running at one time, + /// then you will restart the same game every time. + /// + public void Restart() + => RefillPool(); + + /// + /// Removes all cards from the pool and refills the pool with all of the possible cards. NOTE: I think this is too + /// expensive. + /// We should probably make it so it copies another premade list with all the cards, or something. + /// + protected virtual void RefillPool() + { + CardPool = new(52); + //foreach suit + for (var j = 1; j < 14; j++) + // and number + for (var i = 1; i < 5; i++) + //generate a card of that suit and number and add it to the pool + + // the pool will go from ace of spades,hears,diamonds,clubs all the way to the king of spades. hearts, ... + CardPool.Add(new((CardSuit)i, j)); + } + + /// + /// Take a card from the pool, you either take it from the top if the deck is shuffled, or from a random place if the + /// deck is in the default order. + /// + /// A card from the pool + public Card Draw() + { + if (CardPool.Count == 0) + Restart(); + //you can either do this if your deck is not shuffled + + var num = _r.Next(0, CardPool.Count); + var c = CardPool[num]; + CardPool.RemoveAt(num); + return c; + + // if you want to shuffle when you fill, then take the first one + /* + Card c = cardPool[0]; + cardPool.RemoveAt(0); + return c; + */ + } + + /// + /// Shuffles the deck. Use this if you want to take cards from the top of the deck, instead of randomly. See DrawACard + /// method. + /// + private void Shuffle() + { + if (CardPool.Count <= 1) + return; + var orderedPool = CardPool.Shuffle(); + CardPool ??= orderedPool.ToList(); + } + + public override string ToString() + => string.Concat(CardPool.Select(c => c.ToString())) + Environment.NewLine; + + private static void InitHandValues() + { + bool HasPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 1; + } + + bool IsPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 3) == 0 && HasPair(cards); + } + + bool IsTwoPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 2; + } + + bool IsStraight(List cards) + { + if (cards.GroupBy(card => card.Number).Count() != cards.Count()) + return false; + var toReturn = cards.Max(card => card.Number) - cards.Min(card => card.Number) == 4; + if (toReturn || cards.All(c => c.Number != 1)) + return toReturn; + + var newCards = cards.Select(c => c.Number == 1 ? new(c.Suit, 14) : c).ToArray(); + return newCards.Max(card => card.Number) - newCards.Min(card => card.Number) == 4; + } + + bool HasThreeOfKind(List cards) + { + return cards.GroupBy(card => card.Number).Any(group => group.Count() == 3); + } + + bool IsThreeOfKind(List cards) + { + return HasThreeOfKind(cards) && !HasPair(cards); + } + + bool IsFlush(List cards) + { + return cards.GroupBy(card => card.Suit).Count() == 1; + } + + bool IsFourOfKind(List cards) + { + return cards.GroupBy(card => card.Number).Any(group => group.Count() == 4); + } + + bool IsFullHouse(List cards) + { + return HasPair(cards) && HasThreeOfKind(cards); + } + + bool HasStraightFlush(List cards) + { + return IsFlush(cards) && IsStraight(cards); + } + + bool IsRoyalFlush(List cards) + { + return cards.Min(card => card.Number) == 1 + && cards.Max(card => card.Number) == 13 + && HasStraightFlush(cards); + } + + bool IsStraightFlush(List cards) + { + return HasStraightFlush(cards) && !IsRoyalFlush(cards); + } + + handValues = new() + { + { "Royal Flush", IsRoyalFlush }, + { "Straight Flush", IsStraightFlush }, + { "Four Of A Kind", IsFourOfKind }, + { "Full House", IsFullHouse }, + { "Flush", IsFlush }, + { "Straight", IsStraight }, + { "Three Of A Kind", IsThreeOfKind }, + { "Two Pairs", IsTwoPair }, + { "A Pair", IsPair } + }; + } + + public static string GetHandValue(List cards) + { + if (handValues is null) + InitHandValues(); + + foreach (var kvp in handValues.Where(x => x.Value(cards))) + return kvp.Key; + return "High card " + (cards.FirstOrDefault(c => c.Number == 1)?.GetValueText() ?? cards.Max().GetValueText()); + } + + public class Card : IComparable + { + private static readonly IReadOnlyDictionary _suitToSuitChar = new Dictionary + { + { CardSuit.Diamonds, "♦" }, + { CardSuit.Clubs, "♣" }, + { CardSuit.Spades, "♠" }, + { CardSuit.Hearts, "♥" } + }; + + private static readonly IReadOnlyDictionary _suitCharToSuit = new Dictionary + { + { "♦", CardSuit.Diamonds }, + { "d", CardSuit.Diamonds }, + { "♣", CardSuit.Clubs }, + { "c", CardSuit.Clubs }, + { "♠", CardSuit.Spades }, + { "s", CardSuit.Spades }, + { "♥", CardSuit.Hearts }, + { "h", CardSuit.Hearts } + }; + + private static readonly IReadOnlyDictionary _numberCharToNumber = new Dictionary + { + { 'a', 1 }, + { '2', 2 }, + { '3', 3 }, + { '4', 4 }, + { '5', 5 }, + { '6', 6 }, + { '7', 7 }, + { '8', 8 }, + { '9', 9 }, + { 't', 10 }, + { 'j', 11 }, + { 'q', 12 }, + { 'k', 13 } + }; + + public CardSuit Suit { get; } + public int Number { get; } + + public string FullName + { + get + { + var str = string.Empty; + + if (Number is <= 10 and > 1) + str += "_" + Number; + else + str += GetValueText().ToLowerInvariant(); + return str + "_of_" + Suit.ToString().ToLowerInvariant(); + } + } + + private readonly string[] _regIndicators = + [ + "🇦", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:", ":keycap_ten:", + "🇯", "🇶", "🇰" + ]; + + public Card(CardSuit s, int cardNum) + { + Suit = s; + Number = cardNum; + } + + public string GetValueText() + => _cardNames[Number]; + + public override string ToString() + => _cardNames[Number] + " Of " + Suit; + + public int CompareTo(object obj) + { + if (obj is not Card card) + return 0; + return Number - card.Number; + } + + public static Card Parse(string input) + { + if (string.IsNullOrWhiteSpace(input)) + throw new ArgumentNullException(nameof(input)); + + if (input.Length != 2 + || !_numberCharToNumber.TryGetValue(input[0], out var n) + || !_suitCharToSuit.TryGetValue(input[1].ToString(), out var s)) + throw new ArgumentException("Invalid input", nameof(input)); + + return new(s, n); + } + + public string GetEmojiString() + { + var str = string.Empty; + + str += _regIndicators[Number - 1]; + str += _suitToSuitChar[Suit]; + + return str; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/NewCard.cs b/src/EllieBot/_common/Deck/NewCard.cs new file mode 100644 index 0000000..4a091a4 --- /dev/null +++ b/src/EllieBot/_common/Deck/NewCard.cs @@ -0,0 +1,5 @@ +namespace Ellie.Econ; + +public abstract record class NewCard(TSuit Suit, TValue Value) + where TSuit : struct, Enum + where TValue : struct, Enum; \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/NewDeck.cs b/src/EllieBot/_common/Deck/NewDeck.cs new file mode 100644 index 0000000..a71406c --- /dev/null +++ b/src/EllieBot/_common/Deck/NewDeck.cs @@ -0,0 +1,54 @@ +namespace Ellie.Econ; + +public abstract class NewDeck + where TCard: NewCard + where TSuit : struct, Enum + where TValue : struct, Enum +{ + protected static readonly TSuit[] _suits = Enum.GetValues(); + protected static readonly TValue[] _values = Enum.GetValues(); + + public virtual int CurrentCount + => _cards.Count; + + public virtual int TotalCount { get; } + + protected readonly LinkedList _cards = new(); + public NewDeck() + { + TotalCount = _suits.Length * _values.Length; + } + + public virtual TCard? Draw() + { + var first = _cards.First; + if (first is not null) + { + _cards.RemoveFirst(); + return first.Value; + } + + return null; + } + + public virtual TCard? Peek(int x = 0) + { + var card = _cards.First; + for (var i = 0; i < x; i++) + { + card = card?.Next; + } + + return card?.Value; + } + + public virtual void Shuffle() + { + var cards = _cards.ToList(); + var newCards = cards.Shuffle(); + + _cards.Clear(); + foreach (var card in newCards) + _cards.AddFirst(card); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs new file mode 100644 index 0000000..2a7e7df --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs @@ -0,0 +1,28 @@ +namespace Ellie.Econ; + +public class MultipleRegularDeck : NewDeck +{ + private int Decks { get; } + + public override int TotalCount { get; } + + public MultipleRegularDeck(int decks = 1) + { + if (decks < 1) + throw new ArgumentOutOfRangeException(nameof(decks), "Has to be more than 0"); + + Decks = decks; + TotalCount = base.TotalCount * decks; + + for (var i = 0; i < Decks; i++) + { + foreach (var suit in _suits) + { + foreach (var val in _values) + { + _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!); + } + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularCard.cs b/src/EllieBot/_common/Deck/Regular/RegularCard.cs new file mode 100644 index 0000000..337a1ff --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularCard.cs @@ -0,0 +1,4 @@ +namespace Ellie.Econ; + +public sealed record class RegularCard(RegularSuit Suit, RegularValue Value) + : NewCard(Suit, Value); \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeck.cs b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs new file mode 100644 index 0000000..6997623 --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs @@ -0,0 +1,15 @@ +namespace Ellie.Econ; + +public sealed class RegularDeck : NewDeck +{ + public RegularDeck() + { + foreach (var suit in _suits) + { + foreach (var val in _values) + { + _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs new file mode 100644 index 0000000..98c880c --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs @@ -0,0 +1,56 @@ +namespace Ellie.Econ; + +public static class RegularDeckExtensions +{ + public static string GetEmoji(this RegularSuit suit) + => suit switch + { + RegularSuit.Hearts => "♥️", + RegularSuit.Spades => "♠️", + RegularSuit.Diamonds => "♦️", + _ => "♣️", + }; + + public static string GetEmoji(this RegularValue value) + => value switch + { + RegularValue.Ace => "🇦", + RegularValue.Two => "2️⃣", + RegularValue.Three => "3️⃣", + RegularValue.Four => "4️⃣", + RegularValue.Five => "5️⃣", + RegularValue.Six => "6️⃣", + RegularValue.Seven => "7️⃣", + RegularValue.Eight => "8️⃣", + RegularValue.Nine => "9️⃣", + RegularValue.Ten => "🔟", + RegularValue.Jack => "🇯", + RegularValue.Queen => "🇶", + _ => "🇰", + }; + + public static string GetEmoji(this RegularCard card) + => $"{card.Value.GetEmoji()} {card.Suit.GetEmoji()}"; + + public static string GetName(this RegularValue value) + => value.ToString(); + + public static string GetName(this RegularSuit suit) + => suit.ToString(); + + public static string GetName(this RegularCard card) + => $"{card.Value.ToString()} of {card.Suit.GetName()}"; +} + + + + + + + + + + + + + diff --git a/src/EllieBot/_common/Deck/Regular/RegularSuit.cs b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs new file mode 100644 index 0000000..dc4167b --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs @@ -0,0 +1,9 @@ +namespace Ellie.Econ; + +public enum RegularSuit +{ + Hearts, + Diamonds, + Clubs, + Spades +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularValue.cs b/src/EllieBot/_common/Deck/Regular/RegularValue.cs new file mode 100644 index 0000000..8aa9171 --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularValue.cs @@ -0,0 +1,18 @@ +namespace Ellie.Econ; + +public enum RegularValue +{ + Ace = 1, + Two = 2, + Three = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9, + Ten = 10, + Jack = 12, + Queen = 13, + King = 14, +} \ No newline at end of file diff --git a/src/EllieBot/_common/DoAsUserMessage.cs b/src/EllieBot/_common/DoAsUserMessage.cs new file mode 100644 index 0000000..f8fba27 --- /dev/null +++ b/src/EllieBot/_common/DoAsUserMessage.cs @@ -0,0 +1,154 @@ +using MessageType = Discord.MessageType; + +namespace EllieBot.Modules.Administration; + +public sealed class DoAsUserMessage : IUserMessage +{ + private readonly string _message; + private IUserMessage _msg; + private readonly IUser _user; + + public DoAsUserMessage(SocketUserMessage msg, IUser user, string message) + { + _msg = msg; + _user = user; + _message = message; + } + + public ulong Id => _msg.Id; + + public DateTimeOffset CreatedAt => _msg.CreatedAt; + + public Task DeleteAsync(RequestOptions? options = null) + { + return _msg.DeleteAsync(options); + } + + public Task AddReactionAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.AddReactionAsync(emote, options); + } + + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, user, options); + } + + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null) + { + return _msg.RemoveReactionAsync(emote, userId, options); + } + + public Task RemoveAllReactionsAsync(RequestOptions? options = null) + { + return _msg.RemoveAllReactionsAsync(options); + } + + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null) + { + return _msg.RemoveAllReactionsForEmoteAsync(emote, options); + } + + public IAsyncEnumerable> GetReactionUsersAsync( + IEmote emoji, + int limit, + RequestOptions? options = null, + ReactionType type = ReactionType.Normal) + => _msg.GetReactionUsersAsync(emoji, limit, options, type); + + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, + RequestOptions? options = null) + { + return _msg.GetReactionUsersAsync(emoji, limit, options); + } + + public MessageType Type => _msg.Type; + + public MessageSource Source => _msg.Source; + + public bool IsTTS => _msg.IsTTS; + + public bool IsPinned => _msg.IsPinned; + + public bool IsSuppressed => _msg.IsSuppressed; + + public bool MentionedEveryone => _msg.MentionedEveryone; + + public string Content => _message; + + public string CleanContent => _msg.CleanContent; + + public DateTimeOffset Timestamp => _msg.Timestamp; + + public DateTimeOffset? EditedTimestamp => _msg.EditedTimestamp; + + public IMessageChannel Channel => _msg.Channel; + + public IUser Author => _user; + + public IThreadChannel Thread => _msg.Thread; + + public IReadOnlyCollection Attachments => _msg.Attachments; + + public IReadOnlyCollection Embeds => _msg.Embeds; + + public IReadOnlyCollection Tags => _msg.Tags; + + public IReadOnlyCollection MentionedChannelIds => _msg.MentionedChannelIds; + + public IReadOnlyCollection MentionedRoleIds => _msg.MentionedRoleIds; + + public IReadOnlyCollection MentionedUserIds => _msg.MentionedUserIds; + + public MessageActivity Activity => _msg.Activity; + + public MessageApplication Application => _msg.Application; + + public MessageReference Reference => _msg.Reference; + + public IReadOnlyDictionary Reactions => _msg.Reactions; + + public IReadOnlyCollection Components => _msg.Components; + + public IReadOnlyCollection Stickers => _msg.Stickers; + + public MessageFlags? Flags => _msg.Flags; + + [Obsolete("Obsolete in favor of InteractionMetadata")] + public IMessageInteraction Interaction => _msg.Interaction; + public MessageRoleSubscriptionData RoleSubscriptionData => _msg.RoleSubscriptionData; + + public Task ModifyAsync(Action func, RequestOptions? options = null) + { + return _msg.ModifyAsync(func, options); + } + + public Task PinAsync(RequestOptions? options = null) + { + return _msg.PinAsync(options); + } + + public Task UnpinAsync(RequestOptions? options = null) + { + return _msg.UnpinAsync(options); + } + + public Task CrosspostAsync(RequestOptions? options = null) + { + return _msg.CrosspostAsync(options); + } + + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + { + return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + } + + public MessageResolvedData ResolvedData => _msg.ResolvedData; + + public IUserMessage ReferencedMessage => _msg.ReferencedMessage; + + public IMessageInteractionMetadata InteractionMetadata + => _msg.InteractionMetadata; +} \ No newline at end of file diff --git a/src/EllieBot/_common/DownloadTracker.cs b/src/EllieBot/_common/DownloadTracker.cs new file mode 100644 index 0000000..51d7cc6 --- /dev/null +++ b/src/EllieBot/_common/DownloadTracker.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Common; + +public class DownloadTracker : IEService +{ + private ConcurrentDictionary LastDownloads { get; } = new(); + private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1); + + /// + /// Ensures all users on the specified guild were downloaded within the last hour. + /// + /// Guild to check and potentially download users from + /// Task representing download state + public async Task EnsureUsersDownloadedAsync(IGuild guild) + { +#if GLOBAL_ELLIE + return; +#endif + await _downloadUsersSemaphore.WaitAsync(); + try + { + var now = DateTime.UtcNow; + + // download once per hour at most + var added = LastDownloads.AddOrUpdate(guild.Id, + now, + (_, old) => now - old > TimeSpan.FromHours(1) ? now : old); + + // means that this entry was just added - download the users + if (added == now) + await guild.DownloadUsersAsync(); + } + finally + { + _downloadUsersSemaphore.Release(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/EllieModule.cs b/src/EllieBot/_common/EllieModule.cs new file mode 100644 index 0000000..ba52708 --- /dev/null +++ b/src/EllieBot/_common/EllieModule.cs @@ -0,0 +1,108 @@ +#nullable disable +using System.Globalization; + +// ReSharper disable InconsistentNaming + +namespace EllieBot.Common; + +[UsedImplicitly(ImplicitUseTargetFlags.Default + | ImplicitUseTargetFlags.WithInheritors + | ImplicitUseTargetFlags.WithMembers)] +public abstract class EllieModule : ModuleBase +{ + protected CultureInfo Culture { get; set; } + + // Injected by Discord.net + public IBotStrings Strings { get; set; } + public ICommandHandler _cmdHandler { get; set; } + public ILocalization _localization { get; set; } + public IEllieInteractionService _inter { get; set; } + public IReplacementService repSvc { get; set; } + public IMessageSenderService _sender { get; set; } + public BotConfigService _bcs { get; set; } + + protected string prefix + => _cmdHandler.GetPrefix(ctx.Guild); + + protected ICommandContext ctx + => Context; + + public ResponseBuilder Response() + => new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client) + .Context(ctx); + + protected override void BeforeExecute(CommandInfo command) + => Culture = _localization.GetCultureInfo(ctx.Guild?.Id); + + protected string GetText(in LocStr data) + => Strings.GetText(data, Culture); + + // localized normal + public async Task PromptUserConfirmAsync(EmbedBuilder embed) + { + embed.WithPendingColor() + .WithFooter("yes/no"); + + var msg = await Response().Embed(embed).SendAsync(); + try + { + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id); + input = input?.ToUpperInvariant(); + + if (input != "YES" && input != "Y") + return false; + + return true; + } + finally + { + _ = Task.Run(() => msg.DeleteAsync()); + } + } + + // TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ? + public async Task GetUserInputAsync(ulong userId, ulong channelId, Func validate = null) + { + var userInputTask = new TaskCompletionSource(); + var dsc = (DiscordSocketClient)ctx.Client; + try + { + dsc.MessageReceived += MessageReceived; + + if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task) + return null; + + return await userInputTask.Task; + } + finally + { + dsc.MessageReceived -= MessageReceived; + } + + Task MessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + if (arg is not SocketUserMessage userMsg + || userMsg.Channel is not ITextChannel + || userMsg.Author.Id != userId + || userMsg.Channel.Id != channelId) + return Task.CompletedTask; + + if (validate is not null && !validate(arg.Content)) + return Task.CompletedTask; + + if (userInputTask.TrySetResult(arg.Content)) + userMsg.DeleteAfter(1); + + return Task.CompletedTask; + }); + return Task.CompletedTask; + } + } +} + +public abstract class EllieModule : EllieModule +{ + public TService _service { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/EllieTypeReader.cs b/src/EllieBot/_common/EllieTypeReader.cs new file mode 100644 index 0000000..bab013e --- /dev/null +++ b/src/EllieBot/_common/EllieTypeReader.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)] +public abstract class EllieTypeReader : TypeReader +{ + public abstract ValueTask> ReadAsync(ICommandContext ctx, string input); + + public override async Task ReadAsync( + ICommandContext ctx, + string input, + IServiceProvider services) + => await ReadAsync(ctx, input); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs new file mode 100644 index 0000000..8b95530 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawColorGuess +{ + Red, + Black +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs new file mode 100644 index 0000000..5ebcb6c --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs @@ -0,0 +1,86 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Betdraw; + +public sealed class BetdrawGame +{ + private static readonly EllieRandom _rng = new(); + private readonly RegularDeck _deck; + + private const decimal SINGLE_GUESS_MULTI = 2.075M; + private const decimal DOUBLE_GUESS_MULTI = 4.15M; + + public BetdrawGame() + { + _deck = new RegularDeck(); + } + + public BetdrawResult Draw(BetdrawValueGuess? val, BetdrawColorGuess? col, decimal amount) + { + if (val is null && col is null) + throw new ArgumentNullException(nameof(val)); + + var card = _deck.Peek(_rng.Next(0, 52))!; + + var realVal = (int)card.Value < 7 + ? BetdrawValueGuess.Low + : BetdrawValueGuess.High; + + var realCol = card.Suit is RegularSuit.Diamonds or RegularSuit.Hearts + ? BetdrawColorGuess.Red + : BetdrawColorGuess.Black; + + // if card is 7, autoloss + if (card.Value == RegularValue.Seven) + { + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card, + }; + } + + byte win = 0; + if (val is BetdrawValueGuess valGuess) + { + if (realVal != valGuess) + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card + }; + + ++win; + } + + if (col is BetdrawColorGuess colGuess) + { + if (realCol != colGuess) + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card + }; + + ++win; + } + + var multi = win == 1 + ? SINGLE_GUESS_MULTI + : DOUBLE_GUESS_MULTI; + + return new() + { + Won = amount * multi, + Multiplier = multi, + ResultType = BetdrawResultType.Win, + Card = card + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs new file mode 100644 index 0000000..a491985 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs @@ -0,0 +1,11 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Betdraw; + +public readonly struct BetdrawResult +{ + public decimal Won { get; init; } + public decimal Multiplier { get; init; } + public BetdrawResultType ResultType { get; init; } + public RegularCard Card { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs new file mode 100644 index 0000000..cc7ab51 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawResultType +{ + Win, + Lose +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs new file mode 100644 index 0000000..204cc46 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawValueGuess +{ + High, + Low, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs new file mode 100644 index 0000000..f704025 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class BetflipGame +{ + private readonly decimal _winMulti; + private static readonly EllieRandom _rng = new EllieRandom(); + + public BetflipGame(decimal winMulti) + { + _winMulti = winMulti; + } + + public BetflipResult Flip(byte guess, decimal amount) + { + var side = (byte)_rng.Next(0, 2); + if (side == guess) + { + return new BetflipResult() + { + Side = side, + Won = amount * _winMulti, + Multiplier = _winMulti + }; + } + + return new BetflipResult() + { + Side = side, + Won = 0, + Multiplier = 0, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs new file mode 100644 index 0000000..e87f2f8 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct BetflipResult +{ + public decimal Won { get; init; } + public byte Side { get; init; } + public decimal Multiplier { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs new file mode 100644 index 0000000..7937538 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs @@ -0,0 +1,42 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class BetrollGame +{ + private readonly (int WhenAbove, decimal MultiplyBy)[] _thresholdPairs; + private readonly EllieRandom _rng; + + public BetrollGame(IReadOnlyList<(int WhenAbove, decimal MultiplyBy)> pairs) + { + _thresholdPairs = pairs.OrderByDescending(x => x.WhenAbove).ToArray(); + _rng = new(); + } + + public BetrollResult Roll(decimal amount = 0) + { + var roll = _rng.Next(1, 101); + + for (var i = 0; i < _thresholdPairs.Length; i++) + { + ref var pair = ref _thresholdPairs[i]; + + if (pair.WhenAbove < roll) + { + return new() + { + Multiplier = pair.MultiplyBy, + Roll = roll, + Threshold = pair.WhenAbove, + Won = amount * pair.MultiplyBy + }; + } + } + + return new() + { + Multiplier = 0, + Roll = roll, + Threshold = -1, + Won = 0, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs new file mode 100644 index 0000000..b107f36 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct BetrollResult +{ + public int Roll { get; init; } + public decimal Multiplier { get; init; } + public decimal Threshold { get; init; } + public decimal Won { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Rps/RpsGame.cs b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs new file mode 100644 index 0000000..976a67a --- /dev/null +++ b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs @@ -0,0 +1,75 @@ +namespace EllieBot.Modules.Gambling.Rps; + +public sealed class RpsGame +{ + private static readonly EllieRandom _rng = new EllieRandom(); + + private const decimal WIN_MULTI = 1.95m; + private const decimal DRAW_MULTI = 1m; + private const decimal LOSE_MULTI = 0m; + + public RpsGame() + { + + } + + public RpsResult Play(RpsPick pick, decimal amount) + { + var compPick = (RpsPick)_rng.Next(0, 3); + if (compPick == pick) + { + return new() + { + Won = amount * DRAW_MULTI, + Multiplier = DRAW_MULTI, + ComputerPick = compPick, + Result = RpsResultType.Draw, + }; + } + + if ((compPick == RpsPick.Paper && pick == RpsPick.Rock) + || (compPick == RpsPick.Rock && pick == RpsPick.Scissors) + || (compPick == RpsPick.Scissors && pick == RpsPick.Paper)) + { + return new() + { + Won = amount * LOSE_MULTI, + Multiplier = LOSE_MULTI, + Result = RpsResultType.Lose, + ComputerPick = compPick, + }; + } + + return new() + { + Won = amount * WIN_MULTI, + Multiplier = WIN_MULTI, + Result = RpsResultType.Win, + ComputerPick = compPick, + }; + } +} + +public enum RpsPick : byte +{ + Rock = 0, + Paper = 1, + Scissors = 2, +} + +public enum RpsResultType : byte +{ + Win, + Draw, + Lose +} + + + +public readonly struct RpsResult +{ + public decimal Won { get; init; } + public decimal Multiplier { get; init; } + public RpsResultType Result { get; init; } + public RpsPick ComputerPick { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Slot/SlotGame.cs b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs new file mode 100644 index 0000000..83e92eb --- /dev/null +++ b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs @@ -0,0 +1,116 @@ +namespace EllieBot.Modules.Gambling; + +//here is a payout chart +//https://lh6.googleusercontent.com/-i1hjAJy_kN4/UswKxmhrbPI/AAAAAAAAB1U/82wq_4ZZc-Y/DE6B0895-6FC1-48BE-AC4F-14D1B91AB75B.jpg +//thanks to judge for helping me with this +public class SlotGame +{ + private static readonly EllieRandom _rng = new EllieRandom(); + + public SlotResult Spin(decimal bet) + { + var rolls = new[] + { + (byte)_rng.Next(0, 6), + (byte)_rng.Next(0, 6), + (byte)_rng.Next(0, 6) + }; + + ref var a = ref rolls[0]; + ref var b = ref rolls[1]; + ref var c = ref rolls[2]; + + var multi = 0; + var winType = SlotWinType.None; + if (a == b && b == c) + { + if (a == 5) + { + winType = SlotWinType.TrippleJoker; + multi = 30; + } + else + { + winType = SlotWinType.TrippleNormal; + multi = 10; + } + } + else if (a == 5 && (b == 5 || c == 5) + || (b == 5 && c == 5)) + { + winType = SlotWinType.DoubleJoker; + multi = 4; + } + else if (a == 5 || b == 5 || c == 5) + { + winType = SlotWinType.SingleJoker; + multi = 1; + } + + return new() + { + Won = bet * multi, + WinType = winType, + Multiplier = multi, + Rolls = rolls, + }; + } +} + +public enum SlotWinType : byte +{ + None, + SingleJoker, + DoubleJoker, + TrippleNormal, + TrippleJoker, +} + +/* +var rolls = new[] + { + _rng.Next(default(byte), 6), + _rng.Next(default(byte), 6), + _rng.Next(default(byte), 6) + }; + + var multi = 0; + var winType = SlotWinType.None; + + ref var a = ref rolls[0]; + ref var b = ref rolls[1]; + ref var c = ref rolls[2]; + if (a == b && b == c) + { + if (a == 5) + { + winType = SlotWinType.TrippleJoker; + multi = 30; + } + else + { + winType = SlotWinType.TrippleNormal; + multi = 10; + } + } + else if (a == 5 && (b == 5 || c == 5) + || (b == 5 && c == 5)) + { + winType = SlotWinType.DoubleJoker; + multi = 4; + } + else if (rolls.Any(x => x == 5)) + { + winType = SlotWinType.SingleJoker; + multi = 1; + } + + return new() + { + Won = bet * multi, + WinType = winType, + Multiplier = multi, + Rolls = rolls, + }; + } +*/ \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Slot/SlotResult.cs b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs new file mode 100644 index 0000000..d88a706 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct SlotResult +{ + public decimal Multiplier { get; init; } + public byte[] Rolls { get; init; } + public decimal Won { get; init; } + public SlotWinType WinType { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs new file mode 100644 index 0000000..cec8cca --- /dev/null +++ b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct LuLaResult +{ + public int Index { get; init; } + public decimal Multiplier { get; init; } + public decimal Won { get; init; } + public IReadOnlyList Multipliers { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Wof/WofGame.cs b/src/EllieBot/_common/Gambling/Wof/WofGame.cs new file mode 100644 index 0000000..0922271 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Wof/WofGame.cs @@ -0,0 +1,34 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class LulaGame +{ + private static readonly IReadOnlyList DEFAULT_MULTIPLIERS = new[] { 1.7M, 1.5M, 0.2M, 0.1M, 0.3M, 0.5M, 1.2M, 2.4M }; + + private readonly IReadOnlyList _multipliers; + private static readonly EllieRandom _rng = new(); + + public LulaGame(IReadOnlyList multipliers) + { + _multipliers = multipliers; + } + + public LulaGame() : this(DEFAULT_MULTIPLIERS) + { + } + + public LuLaResult Spin(long bet) + { + var result = _rng.Next(0, _multipliers.Count); + + var multi = _multipliers[result]; + var amount = bet * multi; + + return new() + { + Index = result, + Multiplier = multi, + Won = amount, + Multipliers = _multipliers.ToArray(), + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Helpers.cs b/src/EllieBot/_common/Helpers.cs new file mode 100644 index 0000000..a7d458f --- /dev/null +++ b/src/EllieBot/_common/Helpers.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Common; + +public static class Helpers +{ + public static void ReadErrorAndExit(int exitCode) + { + if (!Console.IsInputRedirected) + Console.ReadKey(); + + Environment.Exit(exitCode); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/IBot.cs b/src/EllieBot/_common/IBot.cs new file mode 100644 index 0000000..c6c5a06 --- /dev/null +++ b/src/EllieBot/_common/IBot.cs @@ -0,0 +1,12 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot; + +public interface IBot +{ + IReadOnlyList GetCurrentGuildIds(); + event Func JoinedGuild; + IReadOnlyCollection AllGuildConfigs { get; } + bool IsReady { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/ICloneable.cs b/src/EllieBot/_common/ICloneable.cs new file mode 100644 index 0000000..c8d3fa8 --- /dev/null +++ b/src/EllieBot/_common/ICloneable.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Common; + +public interface ICloneable + where T : new() +{ + public T Clone(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ICurrencyProvider.cs b/src/EllieBot/_common/ICurrencyProvider.cs new file mode 100644 index 0000000..0cca0ae --- /dev/null +++ b/src/EllieBot/_common/ICurrencyProvider.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Numerics; + +namespace EllieBot.Common; + +public interface ICurrencyProvider +{ + string GetCurrencySign(); +} + +public static class CurrencyHelper +{ + public static string N(T cur, IFormatProvider format) + where T : INumber + => cur.ToString("C0", format); + + public static string N(T cur, CultureInfo culture, string currencySign) + where T : INumber + => N(cur, GetCurrencyFormat(culture, currencySign)); + + private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign) + { + var flowersCurrencyCulture = (CultureInfo)culture.Clone(); + flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign; + flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5; + + return flowersCurrencyCulture; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/IDiscordPermOverrideService.cs b/src/EllieBot/_common/IDiscordPermOverrideService.cs new file mode 100644 index 0000000..b8471c3 --- /dev/null +++ b/src/EllieBot/_common/IDiscordPermOverrideService.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace Ellie.Common; + +public interface IDiscordPermOverrideService +{ + bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm); +} \ No newline at end of file diff --git a/src/EllieBot/_common/IEllieCommandOptions.cs b/src/EllieBot/_common/IEllieCommandOptions.cs new file mode 100644 index 0000000..bb758b5 --- /dev/null +++ b/src/EllieBot/_common/IEllieCommandOptions.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Common; + +public interface IEllieCommandOptions +{ + void NormalizeOptions(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ILogCommandService.cs b/src/EllieBot/_common/ILogCommandService.cs new file mode 100644 index 0000000..344be96 --- /dev/null +++ b/src/EllieBot/_common/ILogCommandService.cs @@ -0,0 +1,34 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Common; + +public interface ILogCommandService +{ + void AddDeleteIgnore(ulong xId); + Task LogServer(ulong guildId, ulong channelId, bool actionValue); + bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType); + LogSetting? GetGuildLogSettings(ulong guildId); + bool Log(ulong guildId, ulong? channelId, LogType type); +} + +public enum LogType +{ + Other, + MessageUpdated, + MessageDeleted, + UserJoined, + UserLeft, + UserBanned, + UserUnbanned, + UserUpdated, + ChannelCreated, + ChannelDestroyed, + ChannelUpdated, + UserPresence, + VoicePresence, + UserMuted, + UserWarned, + + ThreadDeleted, + ThreadCreated +} \ No newline at end of file diff --git a/src/EllieBot/_common/IPermissionChecker.cs b/src/EllieBot/_common/IPermissionChecker.cs new file mode 100644 index 0000000..81aaa64 --- /dev/null +++ b/src/EllieBot/_common/IPermissionChecker.cs @@ -0,0 +1,37 @@ +using OneOf; + +namespace EllieBot.Common; + +public interface IPermissionChecker +{ + Task CheckPermsAsync(IGuild guild, + IMessageChannel channel, + IUser author, + string module, + string? cmd); +} + +[GenerateOneOf] +public partial class PermCheckResult + : OneOfBase +{ + public bool IsAllowed + => IsT0; + + public bool IsCooldown + => IsT1; + + public bool IsGlobalBlock + => IsT2; + + public bool IsDisallowed + => IsT3; +} + +public readonly record struct PermAllowed; + +public readonly record struct PermCooldown; + +public readonly record struct PermGlobalBlock; + +public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose); \ No newline at end of file diff --git a/src/EllieBot/_common/IPlaceholderProvider.cs b/src/EllieBot/_common/IPlaceholderProvider.cs new file mode 100644 index 0000000..1766577 --- /dev/null +++ b/src/EllieBot/_common/IPlaceholderProvider.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Common; + +public interface IPlaceholderProvider +{ + public IEnumerable<(string Name, Func Func)> GetPlaceholders(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ImageUrls.cs b/src/EllieBot/_common/ImageUrls.cs new file mode 100644 index 0000000..7274e60 --- /dev/null +++ b/src/EllieBot/_common/ImageUrls.cs @@ -0,0 +1,51 @@ +#nullable disable +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Common; + +[Cloneable] +public partial class ImageUrls : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + public CoinData Coins { get; set; } + public Uri[] Currency { get; set; } + public Uri[] Dice { get; set; } + public RategirlData Rategirl { get; set; } + public XpData Xp { get; set; } + + //new + public RipData Rip { get; set; } + public SlotData Slots { get; set; } + + public class RipData + { + public Uri Bg { get; set; } + public Uri Overlay { get; set; } + } + + public class SlotData + { + public Uri[] Emojis { get; set; } + public Uri Bg { get; set; } + } + + public class CoinData + { + public Uri[] Heads { get; set; } + public Uri[] Tails { get; set; } + } + + public class RategirlData + { + public Uri Matrix { get; set; } + public Uri Dot { get; set; } + } + + public class XpData + { + public Uri Bg { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/EllieInteraction.cs b/src/EllieBot/_common/Interaction/EllieInteraction.cs new file mode 100644 index 0000000..89e2103 --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteraction.cs @@ -0,0 +1,164 @@ +namespace EllieBot; + +public abstract class EllieInteractionBase +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + private readonly bool _singleUse; + + public EllieInteractionBase( + DiscordSocketClient client, + ulong authorId, + string customId, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + { + _authorId = authorId; + _customId = customId; + _onAction = onAction; + _onlyAuthor = onlyAuthor; + _singleUse = singleUse; + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.InteractionCreated += OnInteraction; + if (_singleUse) + await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task); + else + await Task.Delay(30_000); + Client.InteractionCreated -= OnInteraction; + + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketInteraction arg) + { + if (arg is not SocketMessageComponent smc) + return Task.CompletedTask; + + if (smc.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && smc.User.Id != _authorId) + return Task.CompletedTask; + + if (smc.Data.CustomId != _customId) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + _interactionCompletedSource.TrySetResult(true); + await ExecuteOnActionAsync(smc); + + if (!smc.HasResponded) + { + await smc.DeferAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occured while handling an interaction: {Message}", ex.Message); + } + }); + + return Task.CompletedTask; + } + + + public abstract void AddTo(ComponentBuilder cb); + + public Task ExecuteOnActionAsync(SocketMessageComponent smc) + => _onAction(smc); +} + +public sealed class EllieModalSubmitHandler +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + + public EllieModalSubmitHandler( + DiscordSocketClient client, + ulong authorId, + string customId, + Func onAction, + bool onlyAuthor) + { + _authorId = authorId; + _customId = customId; + _onAction = onAction; + _onlyAuthor = onlyAuthor; + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.ModalSubmitted += OnInteraction; + await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task); + Client.ModalSubmitted -= OnInteraction; + + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketModal sm) + { + if (sm.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && sm.User.Id != _authorId) + return Task.CompletedTask; + + if (sm.Data.CustomId != _customId) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + _interactionCompletedSource.TrySetResult(true); + await ExecuteOnActionAsync(sm); + + if (!sm.HasResponded) + { + await sm.DeferAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, "An exception occured while handling a: {Message}", ex.Message); + } + }); + + return Task.CompletedTask; + } + + + public Task ExecuteOnActionAsync(SocketModal smd) + => _onAction(smd); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/EllieInteractionService.cs b/src/EllieBot/_common/Interaction/EllieInteractionService.cs new file mode 100644 index 0000000..115c417 --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteractionService.cs @@ -0,0 +1,77 @@ +namespace EllieBot; + +public class EllieInteractionService : IEllieInteractionService, IEService +{ + private readonly DiscordSocketClient _client; + + public EllieInteractionService(DiscordSocketClient client) + { + _client = client; + } + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + bool singleUse = true) + => new EllieButtonInteractionHandler(_client, + userId, + button, + onTrigger, + onlyAuthor: true, + singleUse: singleUse); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true) + => Create(userId, + button, + ((Func>)((data) + => smc => onTrigger(smc, data)))(state), + singleUse); + + public EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true) + => new EllieButtonSelectInteractionHandler(_client, + userId, + menu, + onTrigger, + onlyAuthor: true, + singleUse: singleUse); + + + /// + /// Create an interaction which opens a modal + /// + /// Id of the author + /// Button builder for the button that will open the modal + /// Modal + /// The function that will be called when the modal is submitted + /// Whether the button is single use + /// + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func onTrigger, + bool singleUse = true) + => Create(userId, + button, + async (smc) => + { + await smc.RespondWithModalAsync(modal.Build()); + var modalHandler = new EllieModalSubmitHandler(_client, + userId, + modal.CustomId, + onTrigger, + true); + await modalHandler.RunAsync(smc.Message); + }, + singleUse: singleUse); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/IEllieInteractionService.cs b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs new file mode 100644 index 0000000..967e91d --- /dev/null +++ b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs @@ -0,0 +1,30 @@ +namespace EllieBot; + +public interface IEllieInteractionService +{ + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + bool singleUse = true); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true); + + EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true); + + EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func onTrigger, + bool singleUse = true); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/InteractionHelpers.cs b/src/EllieBot/_common/Interaction/InteractionHelpers.cs new file mode 100644 index 0000000..0bac67f --- /dev/null +++ b/src/EllieBot/_common/Interaction/InteractionHelpers.cs @@ -0,0 +1,7 @@ +namespace EllieBot; + +public static class InteractionHelpers +{ + public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>"); + public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>"); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs new file mode 100644 index 0000000..3e65c1c --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs @@ -0,0 +1,21 @@ +namespace EllieBot; + +public sealed class EllieButtonInteractionHandler : EllieInteractionBase +{ + public EllieButtonInteractionHandler( + DiscordSocketClient client, + ulong authorId, + ButtonBuilder button, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + : base(client, authorId, button.CustomId, onAction, onlyAuthor, singleUse) + { + Button = button; + } + + public ButtonBuilder Button { get; } + + public override void AddTo(ComponentBuilder cb) + => cb.WithButton(Button); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs new file mode 100644 index 0000000..cf54f9e --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs @@ -0,0 +1,15 @@ +namespace EllieBot; + +public static class EllieInteractionExtensions +{ + public static MessageComponent CreateComponent( + this EllieInteractionBase ellieInteractionBase + ) + { + var cb = new ComponentBuilder(); + + ellieInteractionBase.AddTo(cb); + + return cb.Build(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs new file mode 100644 index 0000000..7100f01 --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs @@ -0,0 +1,21 @@ +namespace EllieBot; + +public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase +{ + public EllieButtonSelectInteractionHandler( + DiscordSocketClient client, + ulong authorId, + SelectMenuBuilder menu, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + : base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse) + { + Menu = menu; + } + + public SelectMenuBuilder Menu { get; } + + public override void AddTo(ComponentBuilder cb) + => cb.WithSelectMenu(Menu); +} \ No newline at end of file diff --git a/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs new file mode 100644 index 0000000..28167d6 --- /dev/null +++ b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs @@ -0,0 +1,14 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common.JsonConverters; + +public class CultureInfoConverter : JsonConverter +{ + public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => new(reader.GetString() ?? "en_US"); + + public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options) + => writer.WriteStringValue(value.Name); +} \ No newline at end of file diff --git a/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs new file mode 100644 index 0000000..ef619a6 --- /dev/null +++ b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs @@ -0,0 +1,14 @@ +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common.JsonConverters; + +public class Rgba32Converter : JsonConverter +{ + public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => Rgba32.ParseHex(reader.GetString()); + + public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToHex()); +} \ No newline at end of file diff --git a/src/EllieBot/_common/LbOpts.cs b/src/EllieBot/_common/LbOpts.cs new file mode 100644 index 0000000..5df4986 --- /dev/null +++ b/src/EllieBot/_common/LbOpts.cs @@ -0,0 +1,14 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Common; + +public class LbOpts : IEllieCommandOptions +{ + [Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")] + public bool Clean { get; set; } + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Linq2DbExpressions.cs b/src/EllieBot/_common/Linq2DbExpressions.cs new file mode 100644 index 0000000..a724652 --- /dev/null +++ b/src/EllieBot/_common/Linq2DbExpressions.cs @@ -0,0 +1,16 @@ +#nullable disable +using LinqToDB; +using System.Linq.Expressions; + +namespace EllieBot.Common; + +public static class Linq2DbExpressions +{ + [ExpressionMethod(nameof(GuildOnShardExpression))] + public static bool GuildOnShard(ulong guildId, int totalShards, int shardId) + => throw new NotSupportedException(); + + private static Expression> GuildOnShardExpression() + => (guildId, totalShards, shardId) + => guildId / 4194304 % (ulong)totalShards == (ulong)shardId; +} \ No newline at end of file diff --git a/src/EllieBot/_common/LoginErrorHandler.cs b/src/EllieBot/_common/LoginErrorHandler.cs new file mode 100644 index 0000000..bbdc9ce --- /dev/null +++ b/src/EllieBot/_common/LoginErrorHandler.cs @@ -0,0 +1,52 @@ +#nullable disable +using System.Net; +using System.Runtime.CompilerServices; + +namespace EllieBot.Common; + +public class LoginErrorHandler +{ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(Exception ex) + => Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord"); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Handle(HttpException ex) + { + switch (ex.HttpCode) + { + case HttpStatusCode.Unauthorized: + Log.Error("Your bot token is wrong.\n" + + "You can find the bot token under the Bot tab in the developer page.\n" + + "Fix your token in the credentials file and restart the bot"); + break; + + case HttpStatusCode.BadRequest: + Log.Error("Something has been incorrectly formatted in your credentials file.\n" + + "Use the JSON Guide as reference to fix it and restart the bot"); + Log.Error("If you are on Linux, make sure Redis is installed and running"); + break; + + case HttpStatusCode.RequestTimeout: + Log.Error("The request timed out. Make sure you have no external program blocking the bot " + + "from connecting to the internet"); + break; + + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.InternalServerError: + Log.Error("Discord is having internal issues. Please, try again later"); + break; + + case HttpStatusCode.TooManyRequests: + Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n" + + "Global ratelimits usually last for an hour"); + break; + + default: + Log.Warning("An error occurred while attempting to connect to Discord"); + break; + } + + Log.Fatal(ex, "Fatal error occurred while loading credentials"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs new file mode 100644 index 0000000..d76137f --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs @@ -0,0 +1,78 @@ +using EllieBot.Marmalade; + +[DIIgnore] +public sealed class BehaviorAdapter : ICustomBehavior +{ + private readonly WeakReference _canaryWr; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + private readonly string _name; + + public string Name => _name; + + // unused + public int Priority + => 0; + + public BehaviorAdapter(WeakReference canaryWr, IMarmaladeStrings strings, IServiceProvider services) + { + _canaryWr = canaryWr; + _strings = strings; + _services = services; + + _name = canaryWr.TryGetTarget(out var canary) + ? $"canary/{canary.GetType().Name}" + : "unknown"; + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return false; + + return await canary.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + command.Name); + } + + public async Task ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return false; + + return await canary.ExecOnMessageAsync(guild, msg); + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return null; + + return await canary.ExecInputTransformAsync(guild, channel, user, input); + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return; + + await canary.ExecOnNoCommandAsync(guild, msg); + } + + public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return; + + await canary.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + commandName); + } + + public override string ToString() + => _name; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs new file mode 100644 index 0000000..38c1ad9 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs @@ -0,0 +1,9 @@ +using EllieBot.Marmalade; + +internal class ContextAdapterFactory +{ + public static AnyContext CreateNew(ICommandContext context, IMarmaladeStrings strings, IServiceProvider services) + => context.Guild is null + ? new DmContextAdapter(context, strings, services) + : new GuildContextAdapter(context, strings, services); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs new file mode 100644 index 0000000..1f2d1cf --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Marmalade; + +public sealed class DmContextAdapter : DmContext +{ + public override IMarmaladeStrings Strings { get; } + public override IDMChannel Channel { get; } + public override IUserMessage Message { get; } + public override ISelfUser Bot { get; } + public override IUser User + => Message.Author; + + + private readonly IServiceProvider _services; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public DmContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx is not { Channel: IDMChannel ch }) + { + throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx)); + } + + Strings = strings; + + _services = services; + + Channel = ch; + Message = ctx.Message; + Bot = ctx.Client.CurrentUser; + + + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + } + + public override string GetText(string key, object[]? args = null) + { + var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?)); + var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty()); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs new file mode 100644 index 0000000..a23ceec --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs @@ -0,0 +1,33 @@ +using EllieBot.Marmalade; + +namespace Ellie.Marmalade.Adapters; + +public class FilterAdapter : PreconditionAttribute +{ + private readonly FilterAttribute _filterAttribute; + private readonly IMarmaladeStrings _strings; + + public FilterAdapter(FilterAttribute filterAttribute, + IMarmaladeStrings strings) + { + _filterAttribute = filterAttribute; + _strings = strings; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var medusaContext = ContextAdapterFactory.CreateNew(context, + _strings, + services); + + var result = await _filterAttribute.CheckAsync(medusaContext); + + if (!result) + return PreconditionResult.FromError($"Precondition '{_filterAttribute.GetType().Name}' failed."); + + return PreconditionResult.FromSuccess(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs new file mode 100644 index 0000000..8b0c294 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Marmalade; + +public sealed class GuildContextAdapter : GuildContext +{ + private readonly IServiceProvider _services; + private readonly ICommandContext _ctx; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public override IMarmaladeStrings Strings { get; } + public override IGuild Guild { get; } + public override ITextChannel Channel { get; } + public override ISelfUser Bot { get; } + public override IUserMessage Message + => _ctx.Message; + + public override IGuildUser User { get; } + + public GuildContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel) + { + throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx)); + } + + Strings = strings; + User = (IGuildUser)ctx.User; + Bot = ctx.Client.CurrentUser; + + _services = services; + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + + (_ctx, Guild, Channel) = (ctx, guild, channel); + } + + public override string GetText(string key, object[]? args = null) + { + args ??= Array.Empty(); + + var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id); + var output = Strings.GetText(key, cultureInfo, args); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs new file mode 100644 index 0000000..707abbb --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs @@ -0,0 +1,34 @@ +using EllieBot.Marmalade; + +public sealed class ParamParserAdapter : TypeReader +{ + private readonly ParamParser _parser; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + + public ParamParserAdapter(ParamParser parser, + IMarmaladeStrings strings, + IServiceProvider services) + { + _parser = parser; + _strings = strings; + _services = services; + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + var marmaladeContext = ContextAdapterFactory.CreateNew(context, + _strings, + _services); + + var result = await _parser.TryParseAsync(marmaladeContext, input); + + if(result.IsSuccess) + return Discord.Commands.TypeReaderResult.FromSuccess(result.Data); + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs b/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs new file mode 100644 index 0000000..c517510 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs @@ -0,0 +1,27 @@ +namespace EllieBot.Marmalade; + +/// +/// Enum specifying in which context the command can be executed +/// +public enum CommandContextType +{ + /// + /// Command can only be executed in a guild + /// + Guild, + + /// + /// Command can only be executed in DMs + /// + Dm, + + /// + /// Command can be executed anywhere + /// + Any, + + /// + /// Command can be executed anywhere, and it doesn't require context to be passed to it + /// + Unspecified +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs b/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs new file mode 100644 index 0000000..5cb14fc --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Marmalade; + +public interface IMarmaladeConfigService +{ + IReadOnlyCollection GetLoadedMarmalades(); + void AddLoadedMarmalade(string name); + void RemoveLoadedMarmalade(string name); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs new file mode 100644 index 0000000..c14e893 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs @@ -0,0 +1,20 @@ +#nullable enable +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Marmalade; + +[Cloneable] +public sealed partial class MarmaladeConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 1; + + [Comment("""List of marmalades automatically loaded at startup""")] + public List? Loaded { get; set; } + + public MarmaladeConfig() + { + Loaded = new(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs new file mode 100644 index 0000000..43f3a26 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs @@ -0,0 +1,45 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Marmalade; + +public sealed class MarmaladeConfigService : ConfigServiceBase, IMarmaladeConfigService +{ + private const string FILE_PATH = "data/marmalades/marmalade.yml"; + private static readonly TypedKey _changeKey = new("config.marmalade.updated"); + + public override string Name + => "marmalade"; + + public MarmaladeConfigService( + IConfigSeria serializer, + IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + } + + public IReadOnlyCollection GetLoadedMarmalades() + => Data.Loaded?.ToList() ?? new List(); + + public void AddLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + if(!conf.Loaded.Contains(name)) + conf.Loaded.Add(name); + }); + } + + public void RemoveLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + conf.Loaded.Remove(name); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs new file mode 100644 index 0000000..f07f7b5 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace EllieBot.Marmalade; + +public class MarmaladeAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public MarmaladeAssemblyLoadContext(string folderPath) : base(isCollectible: true) + => _resolver = new(folderPath); + + // public Assembly MainAssembly { get; private set; } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath != null) + { + var assembly = LoadFromAssemblyPath(assemblyPath); + LoadDependencies(assembly); + return assembly; + } + + return null; + } + + public void LoadDependencies(Assembly assembly) + { + foreach (var reference in assembly.GetReferencedAssemblies()) + { + Load(reference); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs new file mode 100644 index 0000000..e55ddff --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs @@ -0,0 +1,74 @@ +using DryIoc; +using System.Reflection; +using System.Text.Json; + +namespace EllieBot.Marmalade; + +public interface IIocModule +{ + public string Name { get; } + public void Load(); + public void Unload(); +} + +public sealed class MarmaladeNinjectIocModule : IIocModule, IDisposable +{ + public string Name { get; } + private volatile bool isLoaded = false; + private readonly Dictionary _types; + private readonly IContainer _cont; + + public MarmaladeNinjectIocModule(IContainer cont, Assembly assembly, string name) + { + Name = name; + _cont = cont; + _types = assembly.GetExportedTypes() + .Where(t => t.IsClass) + .Where(t => t.GetCustomAttribute() is not null) + .ToDictionary(x => x, + type => type.GetInterfaces().ToArray()); + } + + public void Load() + { + if (isLoaded) + return; + + foreach (var (type, data) in _types) + { + var attribute = type.GetCustomAttribute()!; + + var reuse = attribute.Lifetime == Lifetime.Singleton + ? Reuse.Singleton + : Reuse.Transient; + + _cont.RegisterMany([type], reuse); + } + + isLoaded = true; + } + + public void Unload() + { + if (!isLoaded) + return; + + foreach (var type in _types.Keys) + { + _cont.Unregister(type); + } + + _types.Clear(); + + // in case the library uses System.Text.Json + var assembly = typeof(JsonSerializerOptions).Assembly; + var updateHandlerType = assembly.GetType("System.Text.Json.JsonSerializerOptionsUpdateHandler"); + var clearCacheMethod = updateHandlerType?.GetMethod("ClearCache", BindingFlags.Static | BindingFlags.Public); + clearCacheMethod?.Invoke(null, [null]); + + isLoaded = false; + } + + public void Dispose() + => _types.Clear(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs new file mode 100644 index 0000000..a17e05a --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs @@ -0,0 +1,916 @@ +using Discord.Commands.Builders; +using DryIoc; +using Microsoft.Extensions.DependencyInjection; +using Ellie.Common.Marmalade; +using Ellie.Marmalade.Adapters; +using EllieBot.Common.ModuleBehaviors; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace EllieBot.Marmalade; + +// ReSharper disable RedundantAssignment +public sealed class MarmaladeLoaderService : IMarmaladeLoaderService, IReadyExecutor, IEService +{ + private readonly CommandService _cmdService; + private readonly IBehaviorHandler _behHandler; + private readonly IPubSub _pubSub; + private readonly IMarmaladeConfigService _marmaladeConfig; + private readonly IContainer _cont; + + private readonly ConcurrentDictionary _resolved = new(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + private readonly TypedKey _loadKey = new("marmalade:load"); + private readonly TypedKey _unloadKey = new("marmalade:unload"); + + private readonly TypedKey _stringsReload = new("marmalade:reload_strings"); + + private const string BASE_DIR = "data/marmalades"; + + public MarmaladeLoaderService( + CommandService cmdService, + IContainer cont, + IBehaviorHandler behHandler, + IPubSub pubSub, + IMarmaladeConfigService marmaladeConfig) + { + _cmdService = cmdService; + _behHandler = behHandler; + _pubSub = pubSub; + _marmaladeConfig = marmaladeConfig; + _cont = cont; + + // has to be done this way to support this feature on sharded bots + _pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name)); + _pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name)); + + _pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal()); + } + + public IReadOnlyCollection GetAllMarmalades() + { + if (!Directory.Exists(BASE_DIR)) + return Array.Empty(); + + return Directory.GetDirectories(BASE_DIR) + .Select(x => Path.GetRelativePath(BASE_DIR, x)) + .ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection GetLoadedMarmalades(CultureInfo? culture) + { + var toReturn = new List(_resolved.Count); + foreach (var (name, resolvedData) in _resolved) + { + var canaries = new List(resolvedData.CanaryInfos.Count); + + foreach (var canaryInfos in resolvedData.CanaryInfos.Concat(resolvedData.CanaryInfos.SelectMany(x => x.Subcanaries))) + { + var commands = new List(); + + foreach (var command in canaryInfos.Commands) + { + commands.Add(new CanaryCommandStats(command.Aliases.First())); + } + + canaries.Add(new CanaryStats(canaryInfos.Name, canaryInfos.Instance.Prefix, commands)); + } + + toReturn.Add(new MarmaladeStats(name, resolvedData.Strings.GetDescription(culture), canaries)); + } + + return toReturn; + } + + public async Task OnReadyAsync() + { + foreach (var name in _marmaladeConfig.GetLoadedMarmalades()) + { + var result = await InternalLoadAsync(name); + if (result != MarmaladeLoadResult.Success) + Log.Warning("Unable to load '{MarmaladeName}' marmalade", name); + else + Log.Warning("Loaded marmalade '{MarmaladeName}'", name); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task LoadMarmaladeAsync(string marmaladeName) + { + // try loading on this shard first to see if it works + var res = await InternalLoadAsync(marmaladeName); + if (res == MarmaladeLoadResult.Success) + { + // if it does publish it so that other shards can load the marmalade too + // this method will be ran twice on this shard but it doesn't matter as + // the second attempt will be ignored + await _pubSub.Pub(_loadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task UnloadMarmaladeAsync(string marmaladeName) + { + var res = await InternalUnloadAsync(marmaladeName); + if (res == MarmaladeUnloadResult.Success) + { + await _pubSub.Pub(_unloadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return Array.Empty(); + + return data.Strings.GetCommandStrings(commandName, culture).Args + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Args + ?? [string.Empty]; + } + + public Task ReloadStrings() + => _pubSub.Pub(_stringsReload, true); + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ReloadStringsSync() + { + foreach (var resolved in _resolved.Values) + { + resolved.Strings.Reload(); + } + } + + private async Task ReloadStringsInternal() + { + await _lock.WaitAsync(); + try + { + ReloadStringsSync(); + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return string.Empty; + + return data.Strings.GetCommandStrings(commandName, culture).Desc + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Desc + ?? string.Empty; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async ValueTask InternalLoadAsync(string name) + { + if (_resolved.ContainsKey(name)) + return MarmaladeLoadResult.AlreadyLoaded; + + var safeName = Uri.EscapeDataString(name); + + await _lock.WaitAsync(); + try + { + if (LoadAssemblyInternal(safeName, + out var ctx, + out var canaryData, + out var iocModule, + out var strings, + out var typeReaders)) + { + var moduleInfos = new List(); + + LoadTypeReadersInternal(typeReaders); + + foreach (var point in canaryData) + { + try + { + // initialize canary and subcanaries + await point.Instance.InitializeAsync(); + foreach (var sub in point.Subcanaries) + { + await sub.Instance.InitializeAsync(); + } + + var module = await LoadModuleInternalAsync(name, point, strings, iocModule); + moduleInfos.Add(module); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error loading canary {CanaryName}", + point.Name); + } + } + + var execs = GetExecsInternal(canaryData, strings); + await _behHandler.AddRangeAsync(execs); + + _resolved[name] = new(LoadContext: ctx, + ModuleInfos: moduleInfos.ToImmutableArray(), + CanaryInfos: canaryData.ToImmutableArray(), + strings, + typeReaders, + execs) + { + IocModule = iocModule + }; + + + _marmaladeConfig.AddLoadedMarmalade(safeName); + return MarmaladeLoadResult.Success; + } + + return MarmaladeLoadResult.Empty; + } + catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException) + { + return MarmaladeLoadResult.NotFound; + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred loading a marmalade"); + return MarmaladeLoadResult.UnknownError; + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetExecsInternal( + IReadOnlyCollection canaryData, + IMarmaladeStrings strings) + { + var behs = new List(); + foreach (var canary in canaryData) + { + behs.Add(new BehaviorAdapter(new(canary.Instance), strings, _cont)); + + foreach (var sub in canary.Subcanaries) + { + behs.Add(new BehaviorAdapter(new(sub.Instance), strings, _cont)); + } + } + + + return behs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void LoadTypeReadersInternal(Dictionary typeReaders) + { + var notAddedTypeReaders = new List(); + foreach (var (type, typeReader) in typeReaders) + { + // if type reader for this type already exists, it will not be replaced + if (_cmdService.TypeReaders.Contains(type)) + { + notAddedTypeReaders.Add(type); + continue; + } + + _cmdService.AddTypeReader(type, typeReader); + } + + // remove the ones that were not added + // to prevent them from being unloaded later + // as they didn't come from this marmalade + foreach (var toRemove in notAddedTypeReaders) + { + typeReaders.Remove(toRemove); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool LoadAssemblyInternal( + string safeName, + [NotNullWhen(true)] out WeakReference? ctxWr, + [NotNullWhen(true)] out IReadOnlyCollection? canaryData, + [NotNullWhen(true)] out IIocModule? iocModule, + out IMarmaladeStrings strings, + out Dictionary typeReaders) + { + ctxWr = null; + canaryData = null; + + var path = Path.GetFullPath($"{BASE_DIR}/{safeName}/{safeName}.dll"); + var dir = Path.GetFullPath($"{BASE_DIR}/{safeName}"); + + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException($"Marmalade folder not found: {dir}"); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Marmalade dll not found: {path}"); + + strings = MarmaladeStrings.CreateDefault(dir); + var ctx = new MarmaladeAssemblyLoadContext(Path.GetDirectoryName(path)!); + var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path)); + ctx.LoadDependencies(a); + + // load services + iocModule = new MarmaladeNinjectIocModule(_cont, a, safeName); + iocModule.Load(); + + var sis = LoadCanariesFromAssembly(safeName, a); + typeReaders = LoadTypeReadersFromAssembly(a, strings); + + if (sis.Count == 0) + { + iocModule.Unload(); + return false; + } + + ctxWr = new(ctx); + canaryData = sis; + + return true; + } + + private static readonly Type _paramParserType = typeof(ParamParser<>); + + [MethodImpl(MethodImplOptions.NoInlining)] + private Dictionary LoadTypeReadersFromAssembly( + Assembly assembly, + IMarmaladeStrings strings) + { + var paramParsers = assembly.GetExportedTypes() + .Where(x => x.IsClass + && !x.IsAbstract + && x.BaseType is not null + && x.BaseType.IsGenericType + && x.BaseType.GetGenericTypeDefinition() == _paramParserType); + + var typeReaders = new Dictionary(); + foreach (var parserType in paramParsers) + { + var parserObj = ActivatorUtilities.CreateInstance(_cont, parserType); + + var targetType = parserType.BaseType!.GetGenericArguments()[0]; + var typeReaderInstance = (TypeReader)Activator.CreateInstance( + typeof(ParamParserAdapter<>).MakeGenericType(targetType), + args: [parserObj, strings, _cont])!; + + typeReaders.Add(targetType, typeReaderInstance); + } + + return typeReaders; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task LoadModuleInternalAsync( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + IIocModule services) + { + var module = await _cmdService.CreateModuleAsync(canaryInfo.Instance.Prefix, + CreateModuleFactory(marmaladeName, canaryInfo, strings, services)); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Action CreateModuleFactory( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + IIocModule iocModule) + => mb => + { + var m = mb.WithName(canaryInfo.Name); + + foreach (var f in canaryInfo.Filters) + { + m.AddPrecondition(new FilterAdapter(f, strings)); + } + + foreach (var cmd in canaryInfo.Commands) + { + m.AddCommand(cmd.Aliases.First(), + CreateCallback(cmd.ContextType, + new(canaryInfo), + new(cmd), + strings), + CreateCommandFactory(marmaladeName, cmd, strings)); + } + + foreach (var subInfo in canaryInfo.Subcanaries) + m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(marmaladeName, subInfo, strings, iocModule)); + }; + + private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild); + private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM); + + private Action CreateCommandFactory(string marmaladeName, CanaryCommandData cmd, IMarmaladeStrings strings) + => (cb) => + { + cb.AddAliases(cmd.Aliases.Skip(1).ToArray()); + + if (cmd.ContextType == CommandContextType.Guild) + cb.AddPrecondition(_reqGuild); + else if (cmd.ContextType == CommandContextType.Dm) + cb.AddPrecondition(_reqDm); + + foreach (var f in cmd.Filters) + cb.AddPrecondition(new FilterAdapter(f, strings)); + + foreach (var ubp in cmd.UserAndBotPerms) + { + if (ubp is user_permAttribute up) + { + if (up.GuildPerm is { } gp) + cb.AddPrecondition(new UserPermAttribute(gp)); + else if (up.ChannelPerm is { } cp) + cb.AddPrecondition(new UserPermAttribute(cp)); + } + else if (ubp is bot_permAttribute bp) + { + if (bp.GuildPerm is { } gp) + cb.AddPrecondition(new BotPermAttribute(gp)); + else if (bp.ChannelPerm is { } cp) + cb.AddPrecondition(new BotPermAttribute(cp)); + } + else if (ubp is bot_owner_onlyAttribute) + { + cb.AddPrecondition(new OwnerOnlyAttribute()); + } + } + + cb.WithPriority(cmd.Priority); + + // using summary to save method name + // method name is used to retrieve desc/usages + cb.WithRemarks($"marmalade///{marmaladeName}"); + cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant()); + + foreach (var param in cmd.Parameters) + { + cb.AddParameter(param.Name, param.Type, CreateParamFactory(param)); + } + }; + + private Action CreateParamFactory(ParamData paramData) + => (pb) => + { + pb.WithIsMultiple(paramData.IsParams) + .WithIsOptional(paramData.IsOptional) + .WithIsRemainder(paramData.IsLeftover); + + if (paramData.IsOptional) + pb.WithDefault(paramData.DefaultValue); + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private Func CreateCallback( + CommandContextType contextType, + WeakReference canaryDataWr, + WeakReference canaryCommandDataWr, + IMarmaladeStrings strings) + => async ( + context, + parameters, + svcs, + _) => + { + if (!canaryCommandDataWr.TryGetTarget(out var cmdData) + || !canaryDataWr.TryGetTarget(out var canaryData)) + { + Log.Warning("Attempted to run an unloaded canary's command"); + return; + } + + var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, _cont, strings); + + try + { + var methodInfo = cmdData.MethodInfo; + if (methodInfo.ReturnType == typeof(Task) + || (methodInfo.ReturnType.IsGenericType + && methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))) + { + await (Task)methodInfo.Invoke(canaryData.Instance, paramObjs)!; + } + else if (methodInfo.ReturnType == typeof(ValueTask)) + { + await ((ValueTask)methodInfo.Invoke(canaryData.Instance, paramObjs)!).AsTask(); + } + else // if (methodInfo.ReturnType == typeof(void)) + { + methodInfo.Invoke(canaryData.Instance, paramObjs); + } + } + finally + { + paramObjs = null; + cmdData = null; + + canaryData = null; + } + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object[] ParamObjs( + CommandContextType contextType, + CanaryCommandData cmdData, + object[] parameters, + ICommandContext context, + IServiceProvider svcs, + IServiceProvider svcProvider, + IMarmaladeStrings strings) + { + var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1; + extraParams += cmdData.InjectedParams.Count; + + var paramObjs = new object[parameters.Length + extraParams]; + + var startAt = 0; + if (contextType != CommandContextType.Unspecified) + { + paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs); + + startAt = 1; + } + + for (var i = 0; i < cmdData.InjectedParams.Count; i++) + { + var svc = svcProvider.GetService(cmdData.InjectedParams[i]); + if (svc is null) + { + throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}"); + } + + paramObjs[i + startAt] = svc; + + svc = null; + } + + startAt += cmdData.InjectedParams.Count; + + for (var i = 0; i < parameters.Length; i++) + paramObjs[startAt + i] = parameters[i]; + + return paramObjs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task InternalUnloadAsync(string name) + { + if (!_resolved.Remove(name, out var lsi)) + return MarmaladeUnloadResult.NotLoaded; + + await _lock.WaitAsync(); + try + { + UnloadTypeReaders(lsi.TypeReaders); + + foreach (var mi in lsi.ModuleInfos) + { + await _cmdService.RemoveModuleAsync(mi); + } + + await _behHandler.RemoveRangeAsync(lsi.Execs); + + await DisposeCanaryInstances(lsi); + + var lc = lsi.LoadContext; + var km = lsi.IocModule; + + lsi.IocModule.Unload(); + lsi.IocModule = null!; + + if (km is IDisposable d) + d.Dispose(); + + lsi = null; + + _marmaladeConfig.RemoveLoadedMarmalade(name); + return UnloadInternal(lc) + ? MarmaladeUnloadResult.Success + : MarmaladeUnloadResult.PossiblyUnable; + } + finally + { + _lock.Release(); + } + } + + private void UnloadTypeReaders(Dictionary valueTypeReaders) + { + foreach (var tr in valueTypeReaders) + { + _cmdService.TryRemoveTypeReader(tr.Key, false, out _); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task DisposeCanaryInstances(ResolvedMarmalade marmalade) + { + foreach (var si in marmalade.CanaryInfos) + { + try + { + await si.Instance.DisposeAsync(); + foreach (var sub in si.Subcanaries) + { + await sub.Instance.DisposeAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, + "Failed cleanup of Canary {CanaryName}. This marmalade might not unload correctly", + si.Instance.Name); + } + } + + // marmalades = null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool UnloadInternal(WeakReference lsi) + { + UnloadContext(lsi); + GcCleanup(); + + return !lsi.TryGetTarget(out _); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void UnloadContext(WeakReference lsiLoadContext) + { + if (lsiLoadContext.TryGetTarget(out var ctx)) + { + ctx.Unload(); + } + } + + private void GcCleanup() + { + // cleanup + for (var i = 0; i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.WaitForFullGCComplete(); + GC.Collect(); + } + } + + private static readonly Type _canaryType = typeof(Canary); + + // [MethodImpl(MethodImplOptions.NoInlining)] + // private MarmaladeIoCKernelModule LoadMarmaladeServicesInternal(string name, Assembly a) + // => new MarmaladeIoCKernelModule(name, a); + + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection LoadCanariesFromAssembly(string name, Assembly a) + { + // find all types in teh assembly + var types = a.GetExportedTypes(); + // canary is always a public non abstract class + var classes = types.Where(static x => x.IsClass + && (x.IsNestedPublic || x.IsPublic) + && !x.IsAbstract + && x.BaseType == _canaryType + && (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_canaryType))) + .ToList(); + + var topModules = new Dictionary(); + + foreach (var cl in classes) + { + if (cl.DeclaringType is not null) + continue; + + // get module data, and add it to the topModules dictionary + var module = GetModuleData(cl); + topModules.Add(cl, module); + } + + foreach (var c in classes) + { + if (c.DeclaringType is not Type dt) + continue; + + // if there is no top level module which this module is a child of + // just print a warning and skip it + if (!topModules.TryGetValue(dt, out var parentData)) + { + Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist", + c.Name, + dt.Name); + continue; + } + + GetModuleData(c, parentData); + } + + return topModules.Values.ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private CanaryInfo GetModuleData(Type type, CanaryInfo? parentData = null) + { + var filters = type.GetCustomAttributes(true) + .ToArray(); + + var instance = (Canary)ActivatorUtilities.CreateInstance(_cont, type); + + var module = new CanaryInfo(instance.Name, + parentData, + instance, + GetCommands(instance, type), + filters); + + if (parentData is not null) + parentData.Subcanaries.Add(module); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetCommands(Canary instance, Type type) + { + var methodInfos = type + .GetMethods(BindingFlags.Instance + | BindingFlags.DeclaredOnly + | BindingFlags.Public) + .Where(static x => + { + if (x.GetCustomAttribute(true) is null) + return false; + + if (x.ReturnType.IsGenericType) + { + var genericType = x.ReturnType.GetGenericTypeDefinition(); + if (genericType == typeof(Task<>)) + return true; + + // if (genericType == typeof(ValueTask<>)) + // return true; + + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + + return false; + } + + var succ = x.ReturnType == typeof(Task) + || x.ReturnType == typeof(ValueTask) + || x.ReturnType == typeof(void); + + if (!succ) + { + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + } + + return succ; + }); + + + var cmds = new List(); + foreach (var method in methodInfos) + { + var filters = method.GetCustomAttributes(true).ToArray(); + var userAndBotPerms = method.GetCustomAttributes(true) + .ToArray(); + var prio = method.GetCustomAttribute(true)?.Priority ?? 0; + + var paramInfos = method.GetParameters(); + var cmdParams = new List(); + var diParams = new List(); + var cmdContext = CommandContextType.Unspecified; + var canInject = false; + for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++) + { + var pi = paramInfos[paramCounter]; + + var paramName = pi.Name ?? "unnamed"; + var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext)); + + var leftoverAttribute = pi.GetCustomAttribute(true); + var hasDefaultValue = pi.HasDefaultValue; + var defaultValue = pi.DefaultValue; + var isLeftover = leftoverAttribute != null; + var isParams = pi.GetCustomAttribute() is not null; + var paramType = pi.ParameterType; + var isInjected = pi.GetCustomAttribute(true) is not null; + + if (isContext) + { + if (hasDefaultValue || leftoverAttribute != null || isParams) + throw new ArgumentException( + "IContext parameter cannot be optional, leftover, constant or params. " + + GetErrorPath(method, pi)); + + if (paramCounter != 0) + throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}"); + + canInject = true; + + if (paramType.IsAssignableTo(typeof(GuildContext))) + cmdContext = CommandContextType.Guild; + else if (paramType.IsAssignableTo(typeof(DmContext))) + cmdContext = CommandContextType.Dm; + else + cmdContext = CommandContextType.Any; + + continue; + } + + if (isInjected) + { + if (!canInject && paramCounter != 0) + throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext"); + + canInject = true; + + diParams.Add(paramType); + continue; + } + + canInject = false; + + if (isParams) + { + if (hasDefaultValue) + throw new NotSupportedException("Params can't have const values at the moment. " + + GetErrorPath(method, pi)); + // if it's params, it means it's an array, and i only need a parser for the actual type, + // as the parser will run on each array element, it can't be null + paramType = paramType.GetElementType()!; + } + + // leftover can only be the last parameter. + if (isLeftover && paramCounter != paramInfos.Length - 1) + { + var path = GetErrorPath(method, pi); + Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ", + path); + throw new ArgumentException("Leftover attribute error."); + } + + cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, defaultValue, isLeftover, isParams)); + } + + + var cmdAttribute = method.GetCustomAttribute(true)!; + var aliases = cmdAttribute.Aliases; + if (aliases.Length == 0) + aliases = [method.Name.ToLowerInvariant()]; + + cmds.Add(new( + aliases, + method, + instance, + filters, + userAndBotPerms, + cmdContext, + diParams, + cmdParams, + new(cmdAttribute.desc, cmdAttribute.args), + prio + )); + } + + return cmds; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi) + => $@"Module: {m.DeclaringType?.Name} +Command: {m.Name} +ParamName: {pi.Name} +ParamType: {pi.ParameterType.Name}"; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs b/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs new file mode 100644 index 0000000..8244576 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs @@ -0,0 +1,44 @@ +using EllieBot.Marmalade; +using System.Reflection; + +namespace EllieBot.Marmalade; + +public sealed class CanaryCommandData +{ + public CanaryCommandData( + IReadOnlyCollection aliases, + MethodInfo methodInfo, + Canary module, + FilterAttribute[] filters, + MarmaladePermAttribute[] userAndBotPerms, + CommandContextType contextType, + IReadOnlyList injectedParams, + IReadOnlyList parameters, + CommandStrings strings, + int priority) + { + Aliases = aliases; + MethodInfo = methodInfo; + Module = module; + Filters = filters; + UserAndBotPerms = userAndBotPerms; + ContextType = contextType; + InjectedParams = injectedParams; + Parameters = parameters; + Priority = priority; + OptionalStrings = strings; + } + + public MarmaladePermAttribute[] UserAndBotPerms { get; set; } + + public CommandStrings OptionalStrings { get; set; } + + public IReadOnlyCollection Aliases { get; } + public MethodInfo MethodInfo { get; set; } + public Canary Module { get; set; } + public FilterAttribute[] Filters { get; set; } + public CommandContextType ContextType { get; } + public IReadOnlyList InjectedParams { get; } + public IReadOnlyList Parameters { get; } + public int Priority { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs b/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs new file mode 100644 index 0000000..4ccb93d --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs @@ -0,0 +1,11 @@ +namespace EllieBot.Marmalade; + +public sealed record CanaryInfo( + string Name, + CanaryInfo? Parent, + Canary Instance, + IReadOnlyCollection Commands, + IReadOnlyCollection Filters) +{ + public List Subcanaries { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs b/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs new file mode 100644 index 0000000..5b3e222 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +public sealed record ParamData( + Type Type, + string Name, + bool IsOptional, + object? DefaultValue, + bool IsLeftover, + bool IsParams +); \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs b/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs new file mode 100644 index 0000000..7441d90 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; + +namespace EllieBot.Marmalade; + +public sealed record ResolvedMarmalade( + WeakReference LoadContext, + IImmutableList ModuleInfos, + IImmutableList CanaryInfos, + IMarmaladeStrings Strings, + Dictionary TypeReaders, + IReadOnlyCollection Execs +) +{ + public required IIocModule IocModule { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs b/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs new file mode 100644 index 0000000..3a17339 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs @@ -0,0 +1,24 @@ +using System.Globalization; + +namespace Ellie.Common.Marmalade; + +public interface IMarmaladeLoaderService +{ + Task LoadMarmaladeAsync(string marmaladeName); + Task UnloadMarmaladeAsync(string marmaladeName); + string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture); + string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture); + Task ReloadStrings(); + IReadOnlyCollection GetAllMarmalades(); + IReadOnlyCollection GetLoadedMarmalades(CultureInfo? cultureInfo = null); +} + +public sealed record MarmaladeStats(string Name, + string? Description, + IReadOnlyCollection Canaries); + +public sealed record CanaryStats(string Name, + string? Prefix, + IReadOnlyCollection Commands); + +public sealed record CanaryCommandStats(string Name); \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs b/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs new file mode 100644 index 0000000..826f4c5 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs @@ -0,0 +1,10 @@ +namespace Ellie.Common.Marmalade; + +public enum MarmaladeLoadResult +{ + Success, + NotFound, + AlreadyLoaded, + Empty, + UnknownError, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs b/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs new file mode 100644 index 0000000..f07569e --- /dev/null +++ b/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Common.Marmalade; + +public enum MarmaladeUnloadResult +{ + Success, + NotLoaded, + PossiblyUnable, + NotFound, +} \ No newline at end of file diff --git a/src/EllieBot/_common/MessageType.cs b/src/EllieBot/_common/MessageType.cs new file mode 100644 index 0000000..ada8d99 --- /dev/null +++ b/src/EllieBot/_common/MessageType.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Common; + +public enum MsgType +{ + Ok, + Pending, + Error +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs new file mode 100644 index 0000000..5fdcc5c --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Common.ModuleBehaviors; + +public interface IBehavior +{ + public virtual string Name => this.GetType().Name; +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs new file mode 100644 index 0000000..4ceeaaf --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand : IBehavior +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs new file mode 100644 index 0000000..7b37a24 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage : IBehavior +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task ExecOnMessageAsync(IGuild guild, IUserMessage msg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs new file mode 100644 index 0000000..ccb949c --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after the command successfully finished execution. +/// ***There is no support for this method in EllieBot services.*** +/// It is only meant to be used in marmalade system +/// +public interface IExecPostCommand : IBehavior +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs new file mode 100644 index 0000000..438cbd0 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand : IBehavior +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs new file mode 100644 index 0000000..957e659 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by services which may transform input before a command is searched for +/// +public interface IInputTransformer : IBehavior +{ + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input); +} \ No newline at end of file diff --git a/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs new file mode 100644 index 0000000..f0a200b --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// All services which need to execute something after +/// the bot is ready should implement this interface +/// +public interface IReadyExecutor : IBehavior +{ + /// + /// Executed when bot is ready + /// + public Task OnReadyAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/NinjectKernelExtensions.cs b/src/EllieBot/_common/NinjectKernelExtensions.cs new file mode 100644 index 0000000..36b4fa8 --- /dev/null +++ b/src/EllieBot/_common/NinjectKernelExtensions.cs @@ -0,0 +1,51 @@ +using DryIoc; + +namespace EllieBot.Extensions; + +public static class DryIocExtensions +{ + public static IContainer AddSingleton(this IContainer container) + where TImpl : TSvc + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + where TImpl : TSvc + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, Func factory) + where TImpl : TSvc + { + container.RegisterDelegate(factory, Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container) + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, Func factory) + { + container.RegisterDelegate(factory); + + return container; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/OldCreds.cs b/src/EllieBot/_common/OldCreds.cs new file mode 100644 index 0000000..b06e15a --- /dev/null +++ b/src/EllieBot/_common/OldCreds.cs @@ -0,0 +1,45 @@ +#nullable disable +namespace EllieBot.Common; + +public class OldCreds +{ + public string Token { get; set; } = string.Empty; + public ulong[] OwnerIds { get; set; } = new ulong[1]; + public string LoLApiKey { get; set; } = string.Empty; + public string GoogleApiKey { get; set; } = string.Empty; + public string MashapeKey { get; set; } = string.Empty; + public string OsuApiKey { get; set; } = string.Empty; + public string CleverbotApiKey { get; set; } = string.Empty; + public string CarbonKey { get; set; } = string.Empty; + public int TotalShards { get; set; } = 1; + public string PatreonAccessToken { get; set; } = string.Empty; + public string PatreonCampaignId { get; set; } = "334038"; + public RestartConfig RestartCommand { get; set; } + + public string ShardRunCommand { get; set; } = string.Empty; + public string ShardRunArguments { get; set; } = string.Empty; + public int? ShardRunPort { get; set; } + public string MiningProxyUrl { get; set; } = string.Empty; + public string MiningProxyCreds { get; set; } = string.Empty; + + public string BotListToken { get; set; } = string.Empty; + public string TwitchClientId { get; set; } = string.Empty; + public string VotesToken { get; set; } = string.Empty; + public string VotesUrl { get; set; } = string.Empty; + public string RedisOptions { get; set; } = string.Empty; + public string LocationIqApiKey { get; set; } = string.Empty; + public string TimezoneDbApiKey { get; set; } = string.Empty; + public string CoinmarketcapApiKey { get; set; } = string.Empty; + + public class RestartConfig + { + public string Cmd { get; set; } + public string Args { get; set; } + + public RestartConfig(string cmd, string args) + { + Cmd = cmd; + Args = args; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/OptionsParser.cs b/src/EllieBot/_common/OptionsParser.cs new file mode 100644 index 0000000..6908c8f --- /dev/null +++ b/src/EllieBot/_common/OptionsParser.cs @@ -0,0 +1,23 @@ +using CommandLine; + +namespace EllieBot.Common; + +public static class OptionsParser +{ + public static T ParseFrom(string[]? args) + where T : IEllieCommandOptions, new() + => ParseFrom(new T(), args).Item1; + + public static (T, bool) ParseFrom(T options, string[]? args) + where T : IEllieCommandOptions + { + using var p = new Parser(x => + { + x.HelpWriter = null; + }); + var res = p.ParseArguments(args); + var output = res.MapResult(x => x, _ => options); + output.NormalizeOptions(); + return (output, res.Tag == ParserResultType.Parsed); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/FeatureLimitKey.cs b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs new file mode 100644 index 0000000..9aa8a46 --- /dev/null +++ b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct FeatureLimitKey +{ + public string PrettyName { get; init; } + public string Key { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/FeatureQuotaStats.cs b/src/EllieBot/_common/Patronage/FeatureQuotaStats.cs new file mode 100644 index 0000000..02db5d3 --- /dev/null +++ b/src/EllieBot/_common/Patronage/FeatureQuotaStats.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct FeatureQuotaStats +{ + public (uint Cur, uint Max) Hourly { get; init; } + public (uint Cur, uint Max) Daily { get; init; } + public (uint Cur, uint Max) Monthly { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/IPatronData.cs b/src/EllieBot/_common/Patronage/IPatronData.cs new file mode 100644 index 0000000..8cd99e0 --- /dev/null +++ b/src/EllieBot/_common/Patronage/IPatronData.cs @@ -0,0 +1,11 @@ +namespace EllieBot.Modules.Patronage; + +public interface ISubscriberData +{ + public string UniquePlatformUserId { get; } + public ulong UserId { get; } + public int Cents { get; } + + public DateTime? LastCharge { get; } + public SubscriptionChargeStatus ChargeStatus { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/IPatronageService.cs b/src/EllieBot/_common/Patronage/IPatronageService.cs new file mode 100644 index 0000000..77fed4b --- /dev/null +++ b/src/EllieBot/_common/Patronage/IPatronageService.cs @@ -0,0 +1,56 @@ +using EllieBot.Db.Models; +using OneOf; + +namespace EllieBot.Modules.Patronage; + +/// +/// Manages patrons and provides access to their data +/// +public interface IPatronageService +{ + /// + /// Called when the payment is made. + /// Either as a single payment for that patron, + /// or as a recurring monthly donation. + /// + public event Func OnNewPatronPayment; + + /// + /// Called when the patron changes the pledge amount + /// (Patron old, Patron new) => Task + /// + public event Func OnPatronUpdated; + + /// + /// Called when the patron refunds the purchase or it's marked as fraud + /// + public event Func OnPatronRefunded; + + /// + /// Gets a Patron with the specified userId + /// + /// UserId for which to get the patron data for. + /// A patron with the specifeid userId + public Task GetPatronAsync(ulong userId); + + /// + /// Gets the quota statistic for the user/patron specified by the userId + /// + /// UserId of the user for which to get the quota statistic for + /// Quota stats for the specified user + Task GetUserQuotaStatistic(ulong userId); + + + Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue); + + ValueTask> TryIncrementQuotaCounterAsync( + ulong userId, + bool isSelf, + FeatureType featureType, + string featureName, + uint? maybeHourly, + uint? maybeDaily, + uint? maybeMonthly); + + PatronConfigData GetConfig(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs new file mode 100644 index 0000000..95160f7 --- /dev/null +++ b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +/// +/// Services implementing this interface are handling pledges/subscriptions/payments coming +/// from a payment platform. +/// +public interface ISubscriptionHandler +{ + /// + /// Get Current patrons in batches. + /// This will only return patrons who have their discord account connected + /// + /// Batched patrons + public IAsyncEnumerable> GetPatronsAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/Patron.cs b/src/EllieBot/_common/Patronage/Patron.cs new file mode 100644 index 0000000..a7c9d97 --- /dev/null +++ b/src/EllieBot/_common/Patronage/Patron.cs @@ -0,0 +1,38 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct Patron +{ + /// + /// Unique id assigned to this patron by the payment platform + /// + public string UniquePlatformUserId { get; init; } + + /// + /// Discord UserId to which this is connected to + /// + public ulong UserId { get; init; } + + /// + /// Amount the Patron is currently pledging or paid + /// + public int Amount { get; init; } + + /// + /// Current Tier of the patron + /// (do not question it in consumer classes, as the calculation should be always internal and may change) + /// + public PatronTier Tier { get; init; } + + /// + /// When was the last time this was paid + /// + public DateTime PaidAt { get; init; } + + /// + /// After which date does the user's Patronage benefit end + /// + public DateTime ValidThru { get; init; } + + public bool IsActive + => !ValidThru.IsBeforeToday(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronConfigData.cs b/src/EllieBot/_common/Patronage/PatronConfigData.cs new file mode 100644 index 0000000..09ed100 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronConfigData.cs @@ -0,0 +1,37 @@ +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Modules.Patronage; + +[Cloneable] +public partial class PatronConfigData : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 2; + + [Comment("Whether the patronage feature is enabled")] + public bool IsEnabled { get; set; } + + [Comment("List of patron only features and relevant quota data")] + public FeatureQuotas Quotas { get; set; } + + public PatronConfigData() + { + Quotas = new(); + } + + public class FeatureQuotas + { + [Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")] + public Dictionary> Features { get; set; } = new(); + + [Comment("Dictionary of commands with their respective quota data")] + public Dictionary?>> Commands { get; set; } = new(); + + [Comment("Dictionary of groups with their respective quota data")] + public Dictionary?>> Groups { get; set; } = new(); + + [Comment("Dictionary of modules with their respective quota data")] + public Dictionary?>> Modules { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronExtensions.cs b/src/EllieBot/_common/Patronage/PatronExtensions.cs new file mode 100644 index 0000000..b422b73 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronExtensions.cs @@ -0,0 +1,39 @@ +namespace EllieBot.Modules.Patronage; + +public static class PatronExtensions +{ + public static string ToFullName(this PatronTier tier) + => tier switch + { + _ => $"Patron Tier {tier}", + }; + + public static string ToFullName(this QuotaPer per) + => per switch + { + QuotaPer.PerDay => "per day", + QuotaPer.PerHour => "per hour", + QuotaPer.PerMonth => "per month", + _ => "Unknown", + }; + + public static DateTime DayOfNextMonth(this DateTime date, int day) + { + var nextMonth = date.AddMonths(1); + var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc); + return dt; + } + + public static DateTime FirstOfNextMonth(this DateTime date) + => date.DayOfNextMonth(1); + + public static DateTime SecondOfNextMonth(this DateTime date) + => date.DayOfNextMonth(2); + + public static string ToShortAndRelativeTimestampTag(this DateTime date) + { + var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime); + var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative); + return $"{fullResetStr}\n{relativeResetStr}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/PatronTier.cs b/src/EllieBot/_common/Patronage/PatronTier.cs new file mode 100644 index 0000000..0bbe804 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronTier.cs @@ -0,0 +1,14 @@ +// ReSharper disable InconsistentNaming +namespace EllieBot.Modules.Patronage; + +public enum PatronTier +{ + None, + I, + V, + X, + XX, + L, + C, + ComingSoon +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/QuotaLimit.cs b/src/EllieBot/_common/Patronage/QuotaLimit.cs new file mode 100644 index 0000000..b40017c --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaLimit.cs @@ -0,0 +1,66 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Patronage; + +/// +/// Represents information about why the user has triggered a quota limit +/// +public readonly struct QuotaLimit +{ + /// + /// Amount of usages reached, which is the limit + /// + public uint Quota { get; init; } + + /// + /// Which period is this quota limit for (hourly, daily, monthly, etc...) + /// + public QuotaPer QuotaPeriod { get; init; } + + /// + /// When does this quota limit reset + /// + public DateTime ResetsAt { get; init; } + + /// + /// Type of the feature this quota limit is for + /// + public FeatureType FeatureType { get; init; } + + /// + /// Name of the feature this quota limit is for + /// + public string Feature { get; init; } + + /// + /// Whether it is the user's own quota (true), or server owners (false) + /// + public bool IsOwnQuota { get; init; } +} + + +/// +/// Respresent information about the feature limit +/// +public readonly struct FeatureLimit +{ + + /// + /// Whether this limit comes from the patronage system + /// + public bool IsPatronLimit { get; init; } = false; + + /// + /// Maximum limit allowed + /// + public int? Quota { get; init; } = null; + + /// + /// Name of the limit + /// + public string Name { get; init; } = string.Empty; + + public FeatureLimit() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/QuotaPer.cs b/src/EllieBot/_common/Patronage/QuotaPer.cs new file mode 100644 index 0000000..c6080ac --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaPer.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Patronage; + +public enum QuotaPer +{ + PerHour, + PerDay, + PerMonth, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs new file mode 100644 index 0000000..7ef541f --- /dev/null +++ b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public enum SubscriptionChargeStatus +{ + Paid, + Refunded, + Unpaid, + Other, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Patronage/UserQuotaStats.cs b/src/EllieBot/_common/Patronage/UserQuotaStats.cs new file mode 100644 index 0000000..a9e33e4 --- /dev/null +++ b/src/EllieBot/_common/Patronage/UserQuotaStats.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct UserQuotaStats +{ + private static readonly IReadOnlyDictionary _emptyDictionary + = new Dictionary(); + public PatronTier Tier { get; init; } + = PatronTier.None; + + public IReadOnlyDictionary Features { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Commands { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Groups { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Modules { get; init; } + = _emptyDictionary; + + public UserQuotaStats() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/PokemonNameId.cs b/src/EllieBot/_common/Pokemon/PokemonNameId.cs new file mode 100644 index 0000000..341b5c2 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/PokemonNameId.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Common.Pokemon; + +public class PokemonNameId +{ + public int Id { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/SearchPokemon.cs b/src/EllieBot/_common/Pokemon/SearchPokemon.cs new file mode 100644 index 0000000..b4ecae1 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/SearchPokemon.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Common.Pokemon; + +public class SearchPokemon +{ + [JsonPropertyName("num")] + public int Id { get; set; } + + public string Species { get; set; } + public string[] Types { get; set; } + public GenderRatioClass GenderRatio { get; set; } + public BaseStatsClass BaseStats { get; set; } + public Dictionary Abilities { get; set; } + public float HeightM { get; set; } + public float WeightKg { get; set; } + public string Color { get; set; } + public string[] Evos { get; set; } + public string[] EggGroups { get; set; } + + public class GenderRatioClass + { + public float M { get; set; } + public float F { get; set; } + } + + public class BaseStatsClass + { + public int Hp { get; set; } + public int Atk { get; set; } + public int Def { get; set; } + public int Spa { get; set; } + public int Spd { get; set; } + public int Spe { get; set; } + + public override string ToString() + => $@"💚**HP:** {Hp,-4} ⚔**ATK:** {Atk,-4} 🛡**DEF:** {Def,-4} +✨**SPA:** {Spa,-4} 🎇**SPD:** {Spd,-4} 💨**SPE:** {Spe,-4}"; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs new file mode 100644 index 0000000..c3f8f36 --- /dev/null +++ b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Common.Pokemon; + +public class SearchPokemonAbility +{ + public string Desc { get; set; } + public string ShortDesc { get; set; } + public string Name { get; set; } + public float Rating { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs new file mode 100644 index 0000000..0a6483a --- /dev/null +++ b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs @@ -0,0 +1,20 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public interface IReplacementPatternStore : IEService +{ + IReadOnlyDictionary Replacements { get; } + IReadOnlyDictionary RegexReplacements { get; } + + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + + ValueTask Register(Regex regex, Func repFactory); + ValueTask Register(Regex regex, Func repFactory); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/IReplacementService.cs b/src/EllieBot/_common/Replacements/IReplacementService.cs new file mode 100644 index 0000000..66a0896 --- /dev/null +++ b/src/EllieBot/_common/Replacements/IReplacementService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Common; + +public interface IReplacementService +{ + ValueTask ReplaceAsync(string input, ReplacementContext repCtx); + ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs new file mode 100644 index 0000000..b321941 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed class ReplacementContext +{ + public DiscordSocketClient? Client { get; } + public IGuild? Guild { get; } + public IMessageChannel? Channel { get; } + public IUser[]? Users { get; } + + private readonly List _overrides = new(); + private readonly HashSet _tokens = new(); + + public IReadOnlyList Overrides + => _overrides.AsReadOnly(); + + private readonly List _regexOverrides = new(); + private readonly HashSet _regexPatterns = new(); + + public IReadOnlyList RegexOverrides + => _regexOverrides.AsReadOnly(); + + public ReplacementContext(ICommandContext cmdContext) : this(cmdContext.Client as DiscordSocketClient, + cmdContext.Guild, + cmdContext.Channel, + cmdContext.User) + { + } + + public ReplacementContext( + DiscordSocketClient? client = null, + IGuild? guild = null, + IMessageChannel? channel = null, + params IUser[]? users) + { + Client = client; + Guild = guild; + Channel = channel; + Users = users; + } + + public ReplacementContext WithOverride(string key, Func> repFactory) + { + if (_tokens.Add(key)) + { + _overrides.Add(new(key, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(string key, Func repFactory) + => WithOverride(key, () => new ValueTask(repFactory())); + + + public ReplacementContext WithOverride(Regex regex, Func> repFactory) + { + if (_regexPatterns.Add(regex.ToString())) + { + _regexOverrides.Add(new(regex, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(Regex regex, Func repFactory) + => WithOverride(regex, (Match m) => new ValueTask(repFactory(m))); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs new file mode 100644 index 0000000..254de96 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed class ReplacementInfo +{ + private readonly Delegate _del; + public IReadOnlyCollection InputTypes { get; } + public string Token { get; } + + private static readonly Func> _falllbackFunc = static () => default; + + public ReplacementInfo(string token, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Token = token; + } + + public async Task GetValueAsync(params object?[]? objs) + => await (ValueTask)(_del.DynamicInvoke(objs) ?? _falllbackFunc); + + public override int GetHashCode() + => Token.GetHashCode(); + + public override bool Equals(object? obj) + => obj is ReplacementInfo ri && ri.Token == Token; +} + +public sealed class RegexReplacementInfo +{ + private readonly Delegate _del; + public IReadOnlyCollection InputTypes { get; } + + public Regex Regex { get; } + public string Pattern { get; } + + private static readonly Func> _falllbackFunc = static _ => default; + + public RegexReplacementInfo(Regex regex, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Regex = regex; + Pattern = Regex.ToString(); + } + + public async Task GetValueAsync(Match m, params object?[]? objs) + => await ((Func>)(_del.DynamicInvoke(objs) ?? _falllbackFunc))(m); + + public override int GetHashCode() + => Regex.GetHashCode(); + + public override bool Equals(object? obj) + => obj is RegexReplacementInfo ri && ri.Pattern == Pattern; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs new file mode 100644 index 0000000..3692184 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs @@ -0,0 +1,130 @@ +using System.Text.RegularExpressions; +using OneOf; + +namespace EllieBot.Common; + +public sealed partial class ReplacementPatternStore : IReplacementPatternStore, IEService +{ + private readonly ConcurrentDictionary> _guids = new(); + + private readonly ConcurrentDictionary _defaultReplacements = new(); + private readonly ConcurrentDictionary _regexReplacements = new(); + + public IReadOnlyDictionary Replacements + => _defaultReplacements.AsReadOnly(); + + public IReadOnlyDictionary RegexReplacements + => _regexReplacements.AsReadOnly(); + + public ReplacementPatternStore() + { + WithClient(); + WithChannel(); + WithServer(); + WithUsers(); + WithDefault(); + WithRegex(); + } + + // private async ValueTask InternalReplace(string input, ReplacementContexta repCtx) + // { + // // multiple executions vs single execution per replacement + // var minIndex = -1; + // var index = -1; + // foreach (var rep in _replacements) + // { + // while ((index = input.IndexOf(rep.Key, StringComparison.InvariantCulture)) != -1 && index > minIndex) + // { + // var valueToInsert = await rep.Value(repCtx); + // input = input[..index] + valueToInsert +input[(index + rep.Key.Length)..]; + // minIndex = (index + valueToInsert.Length); + // } + // } + // + // return input; + // } + + private ValueTask InternalRegister(string token, Delegate repFactory) + { + if (!token.StartsWith('%') || !token.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement token: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + token); + return new(default(Guid?)); + } + + if (_defaultReplacements.TryAdd(token, new ReplacementInfo(token, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = token; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, () => new ValueTask(repFactory())); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a) => new ValueTask(repFactory(a))); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a, T2 b) => new ValueTask(repFactory(a, b))); + + + private ValueTask InternalRegexRegister(Regex regex, Delegate repFactory) + { + var regexPattern = regex.ToString(); + if (!regexPattern.StartsWith('%') || !regexPattern.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement pattern: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + regex); + return new(default(Guid?)); + } + + if (_regexReplacements.TryAdd(regexPattern, new RegexReplacementInfo(regex, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = regex; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, () => (Match m) => new ValueTask(repFactory(m))); + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, (T1 a) => (Match m) => new ValueTask(repFactory(m, a))); + + public bool Unregister(Guid guid) + { + if (_guids.TryRemove(guid, out var pattern)) + { + return pattern.Match( + token => _defaultReplacements.TryRemove(token, out _), + regex => _regexReplacements.TryRemove(regex.ToString(), out _)); + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs new file mode 100644 index 0000000..d42bba6 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class ReplacementPatternStore +{ + private static readonly Regex _rngRegex = new(@"%rng(?:(?(?:-)?\d+)-(?(?:-)?\d+))?%", + RegexOptions.Compiled); + + + private void WithDefault() + { + Register("%bot.time%", + static () + => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials())); + } + + private void WithClient() + { + Register("%bot.status%", static (DiscordSocketClient client) => client.Status.ToString()); + Register("%bot.latency%", static (DiscordSocketClient client) => client.Latency.ToString()); + Register("%bot.name%", static (DiscordSocketClient client) => client.CurrentUser.Username); + Register("%bot.fullname%", static (DiscordSocketClient client) => client.CurrentUser.ToString()); + Register("%bot.discrim%", static (DiscordSocketClient client) => client.CurrentUser.Discriminator); + Register("%bot.id%", static (DiscordSocketClient client) => client.CurrentUser.Id.ToString()); + Register("%bot.avatar%", + static (DiscordSocketClient client) => client.CurrentUser.RealAvatarUrl().ToString()); + + Register("%bot.mention%", static (DiscordSocketClient client) => client.CurrentUser.Mention); + + Register("%shard.servercount%", static (DiscordSocketClient c) => c.Guilds.Count.ToString()); + Register("%shard.usercount%", + static (DiscordSocketClient c) => c.Guilds.Sum(g => g.MemberCount).ToString()); + Register("%shard.id%", static (DiscordSocketClient c) => c.ShardId.ToString()); + } + + private void WithServer() + { + Register("%server%", static (IGuild g) => g.Name); + Register("%server.id%", static (IGuild g) => g.Id.ToString()); + Register("%server.name%", static (IGuild g) => g.Name); + Register("%server.icon%", static (IGuild g) => g.IconUrl); + Register("%server.members%", static (IGuild g) => (g as SocketGuild)?.MemberCount.ToString() ?? "?"); + Register("%server.boosters%", static (IGuild g) => g.PremiumSubscriptionCount.ToString()); + Register("%server.boost_level%", static (IGuild g) => ((int)g.PremiumTier).ToString()); + } + + private void WithChannel() + { + Register("%channel%", static (IMessageChannel ch) => ch.Name); + Register("%channel.mention%", + static (IMessageChannel ch) => (ch as ITextChannel)?.Mention ?? "#" + ch.Name); + Register("%channel.name%", static (IMessageChannel ch) => ch.Name); + Register("%channel.id%", static (IMessageChannel ch) => ch.Id.ToString()); + Register("%channel.created%", + static (IMessageChannel ch) => ch.CreatedAt.ToString("HH:mm dd.MM.yyyy")); + Register("%channel.nsfw%", + static (IMessageChannel ch) => (ch as ITextChannel)?.IsNsfw.ToString() ?? "-"); + Register("%channel.topic%", static (IMessageChannel ch) => (ch as ITextChannel)?.Topic ?? "-"); + } + + private void WithUsers() + { + Register("%user%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention))); + Register("%user.mention%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention))); + Register("%user.fullname%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.ToString()))); + Register("%user.name%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Username))); + Register("%user.discrim%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Discriminator))); + Register("%user.avatar%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.RealAvatarUrl().ToString()))); + Register("%user.id%", + static (IUser[] users) => string.Join(" ", users.Select(user => user.Id.ToString()))); + Register("%user.created_time%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.CreatedAt.ToString("HH:mm")))); + Register("%user.created_date%", + static (IUser[] users) + => string.Join(" ", users.Select(user => user.CreatedAt.ToString("dd.MM.yyyy")))); + Register("%user.joined_time%", + static (IUser[] users) => string.Join(" ", + users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("HH:mm") ?? "-"))); + Register("%user.joined_date%", + static (IUser[] users) => string.Join(" ", + users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("dd.MM.yyyy") ?? "-"))); + } + + private void WithRegex() + { + Register(_rngRegex, + match => + { + var rng = new EllieRandom(); + if (!int.TryParse(match.Groups["from"].ToString(), out var from)) + from = 0; + if (!int.TryParse(match.Groups["to"].ToString(), out var to)) + to = 0; + + if (from == 0 && to == 0) + return rng.Next(0, 11).ToString(); + + if (from >= to) + return string.Empty; + + return rng.Next(from, to + 1).ToString(); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs new file mode 100644 index 0000000..4d17213 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs @@ -0,0 +1,137 @@ +namespace EllieBot.Common; + +public sealed class ReplacementService : IReplacementService, IEService +{ + private readonly IReplacementPatternStore _repReg; + + public ReplacementService(IReplacementPatternStore repReg) + { + _repReg = repReg; + } + + public async ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + public async ValueTask ReplaceAsync(string input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + private object[] GetInputData(ReplacementContext repCtx) + { + var obj = new List(); + if (repCtx.Client is not null) + obj.Add(repCtx.Client); + + if (repCtx.Guild is not null) + obj.Add(repCtx.Guild); + + if (repCtx.Users is not null) + obj.Add(repCtx.Users); + + if (repCtx.Channel is not null) + obj.Add(repCtx.Channel); + + return obj.ToArray(); + } + + private IDictionary GetReplacementsForContext(ReplacementContext repCtx) + { + var reps = GetOriginalReplacementsForContext(repCtx); + foreach (var ovrd in repCtx.Overrides) + { + reps.Remove(ovrd.Token); + reps.TryAdd(ovrd.Token, ovrd); + } + + return reps; + } + + private IDictionary GetRegexReplacementsForContext(ReplacementContext repCtx) + { + var reps = GetOriginalRegexReplacementsForContext(repCtx); + foreach (var ovrd in repCtx.RegexOverrides) + { + reps.Remove(ovrd.Pattern); + reps.TryAdd(ovrd.Pattern, ovrd); + } + + return reps; + } + + private IDictionary GetOriginalReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.Users is not null) + { + objs.Add(repCtx.Users); + } + + if (repCtx.Guild is not null) + { + objs.Add(repCtx.Guild); + } + + var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet(); + + return _repReg.Replacements + .Values + .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t))))) + .ToDictionary(rep => rep.Token, rep => rep); + } + + private IDictionary GetOriginalRegexReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.Users is not null) + { + objs.Add(repCtx.Users); + } + + if (repCtx.Guild is not null) + { + objs.Add(repCtx.Guild); + } + + var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet(); + + return _repReg.RegexReplacements + .Values + .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t))))) + .ToDictionary(rep => rep.Pattern, rep => rep); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/Replacer.cs b/src/EllieBot/_common/Replacements/Impl/Replacer.cs new file mode 100644 index 0000000..9b43331 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/Replacer.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class Replacer +{ + private readonly IEnumerable _reps; + private readonly IEnumerable _regexReps; + private readonly object[] _inputData; + + [GeneratedRegex(@"\%[\p{L}\p{N}\._]*[\p{L}\p{N}]+[\p{L}\p{N}\._]*\%")] + private static partial Regex TokenExtractionRegex(); + + public Replacer(IEnumerable reps, IEnumerable regexReps, object[] inputData) + { + _reps = reps; + _inputData = inputData; + _regexReps = regexReps; + } + + public async ValueTask ReplaceAsync(string? input) + { + if (string.IsNullOrWhiteSpace(input)) + return input; + + var matches = TokenExtractionRegex().IsMatch(input); + + if (matches) + { + foreach (var rep in _reps) + { + if (input.Contains(rep.Token, StringComparison.InvariantCulture)) + { + var objs = GetParams(rep.InputTypes); + input = input.Replace(rep.Token, await rep.GetValueAsync(objs), StringComparison.InvariantCulture); + } + } + } + + foreach (var rep in _regexReps) + { + var sb = new StringBuilder(); + + var objs = GetParams(rep.InputTypes); + var match = rep.Regex.Match(input); + if (match.Success) + { + sb.Append(input, 0, match.Index) + .Append(await rep.GetValueAsync(match, objs)); + + var lastIndex = match.Index + match.Length; + sb.Append(input, lastIndex, input.Length - lastIndex); + input = sb.ToString(); + } + } + + return input; + } + + private object?[]? GetParams(IReadOnlyCollection inputTypes) + { + if (inputTypes.Count == 0) + return null; + + var objs = new List(); + foreach (var t in inputTypes) + { + var datum = _inputData.FirstOrDefault(x => x.GetType().IsAssignableTo(t)); + if (datum is not null) + objs.Add(datum); + } + + return objs.ToArray(); + } + + public async ValueTask ReplaceAsync(SmartText data) + => data switch + { + SmartEmbedText embedData => await ReplaceAsync(embedData) with + { + PlainText = await ReplaceAsync(embedData.PlainText), + Color = embedData.Color + }, + SmartPlainText plain => await ReplaceAsync(plain), + SmartEmbedTextArray arr => await ReplaceAsync(arr), + _ => throw new ArgumentOutOfRangeException(nameof(data), "Unsupported argument type") + }; + + private async Task ReplaceAsync(SmartEmbedTextArray embedArr) + => new() + { + Embeds = await embedArr.Embeds.Map(async e => await ReplaceAsync(e) with + { + Color = e.Color + }).WhenAll(), + Content = await ReplaceAsync(embedArr.Content) + }; + + private async ValueTask ReplaceAsync(SmartPlainText plain) + => await ReplaceAsync(plain.Text); + + private async Task ReplaceAsync(T embedData) where T : SmartEmbedTextBase, new() + { + var newEmbedData = new T + { + Description = await ReplaceAsync(embedData.Description), + Title = await ReplaceAsync(embedData.Title), + Thumbnail = await ReplaceAsync(embedData.Thumbnail), + Image = await ReplaceAsync(embedData.Image), + Url = await ReplaceAsync(embedData.Url), + Author = embedData.Author is null + ? null + : new() + { + Name = await ReplaceAsync(embedData.Author.Name), + IconUrl = await ReplaceAsync(embedData.Author.IconUrl) + }, + Fields = await Task.WhenAll(embedData + .Fields? + .Map(async f => new SmartTextEmbedField + { + Name = await ReplaceAsync(f.Name), + Value = await ReplaceAsync(f.Value), + Inline = f.Inline + }) ?? []), + Footer = embedData.Footer is null + ? null + : new() + { + Text = await ReplaceAsync(embedData.Footer.Text), + IconUrl = await ReplaceAsync(embedData.Footer.IconUrl) + } + }; + + return newEmbedData; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs new file mode 100644 index 0000000..74a0367 --- /dev/null +++ b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs @@ -0,0 +1,15 @@ +#nullable disable +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace EllieBot.Common; + +public class RequireObjectPropertiesContractResolver : DefaultContractResolver +{ + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + var contract = base.CreateObjectContract(objectType); + contract.ItemRequired = Required.DisallowNull; + return contract; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/IMessageSenderService.cs b/src/EllieBot/_common/Sender/IMessageSenderService.cs new file mode 100644 index 0000000..ccc8c0f --- /dev/null +++ b/src/EllieBot/_common/Sender/IMessageSenderService.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Extensions; + +public interface IMessageSenderService +{ + ResponseBuilder Response(IMessageChannel channel); + ResponseBuilder Response(ICommandContext ctx); + ResponseBuilder Response(IUser user); + + ResponseBuilder Response(SocketMessageComponent smc); + + EllieEmbedBuilder CreateEmbed(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/MessageSenderService.cs b/src/EllieBot/_common/Sender/MessageSenderService.cs new file mode 100644 index 0000000..f91f1ab --- /dev/null +++ b/src/EllieBot/_common/Sender/MessageSenderService.cs @@ -0,0 +1,57 @@ +using EllieBot.Common.Configs; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Extensions; + +public sealed class MessageSenderService : IMessageSenderService, IEService +{ + private readonly IBotStrings _bs; + private readonly BotConfigService _bcs; + private readonly DiscordSocketClient _client; + + public MessageSenderService(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client) + { + _bs = bs; + _bcs = bcs; + _client = client; + } + + + public ResponseBuilder Response(IMessageChannel channel) + => new ResponseBuilder(_bs, _bcs, _client) + .Channel(channel); + + public ResponseBuilder Response(ICommandContext ctx) + => new ResponseBuilder(_bs, _bcs, _client) + .Context(ctx); + + public ResponseBuilder Response(IUser user) + => new ResponseBuilder(_bs, _bcs, _client) + .User(user); + + public ResponseBuilder Response(SocketMessageComponent smc) + => new ResponseBuilder(_bs, _bcs, _client) + .Channel(smc.Channel); + + public EllieEmbedBuilder CreateEmbed() + => new EllieEmbedBuilder(_bcs); +} + +public class EllieEmbedBuilder : EmbedBuilder +{ + private readonly BotConfig _bc; + + public EllieEmbedBuilder(BotConfigService bcs) + { + _bc = bcs.Data; + } + + public EmbedBuilder WithOkColor() + => WithColor(_bc.Color.Ok.ToDiscordColor()); + + public EmbedBuilder WithErrorColor() + => WithColor(_bc.Color.Error.ToDiscordColor()); + + public EmbedBuilder WithPendingColor() + => WithColor(_bc.Color.Pending.ToDiscordColor()); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs new file mode 100644 index 0000000..a1dee4d --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs @@ -0,0 +1,153 @@ +namespace EllieBot.Extensions; + +public partial class ResponseBuilder +{ + public class PaginationSender + { + private const string BUTTON_LEFT = "BUTTON_LEFT"; + private const string BUTTON_RIGHT = "BUTTON_RIGHT"; + + private readonly SourcedPaginatedResponseBuilder _paginationBuilder; + private readonly ResponseBuilder _builder; + private readonly DiscordSocketClient _client; + private int currentPage; + + public PaginationSender( + SourcedPaginatedResponseBuilder paginationBuilder, + ResponseBuilder builder) + { + _paginationBuilder = paginationBuilder; + _builder = builder; + + _client = builder.Client; + currentPage = paginationBuilder.InitialPage; + } + + public async Task SendAsync(bool ephemeral = false) + { + var lastPage = (_paginationBuilder.Elems - 1) + / _paginationBuilder.ItemsPerPage; + + var items = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray(); + var embed = await _paginationBuilder.PageFunc(items, currentPage); + + if (_paginationBuilder.AddPaginatedFooter) + embed.AddPaginatedFooter(currentPage, lastPage); + + EllieInteractionBase? maybeInter = null; + + var model = await _builder.BuildAsync(ephemeral); + + async Task<(EllieButtonInteractionHandler left, EllieInteractionBase? extra, EllieButtonInteractionHandler right)> + GetInteractions() + { + var leftButton = new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_LEFT) + .WithEmote(InteractionHelpers.ArrowLeft) + .WithDisabled(lastPage == 0 || currentPage <= 0); + + var leftBtnInter = new EllieButtonInteractionHandler(_client, + model.User?.Id ?? 0, + leftButton, + (smc) => + { + try + { + if (currentPage > 0) + currentPage--; + + _ = UpdatePageAsync(smc); + } + catch (Exception ex) + { + Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message); + } + + return Task.CompletedTask; + }, + true, + singleUse: false); + + if (_paginationBuilder.InteractionFunc is not null) + { + maybeInter = await _paginationBuilder.InteractionFunc(currentPage); + } + + var rightButton = new ButtonBuilder() + .WithStyle(ButtonStyle.Primary) + .WithCustomId(BUTTON_RIGHT) + .WithEmote(InteractionHelpers.ArrowRight) + .WithDisabled(lastPage == 0 || currentPage >= lastPage); + + var rightBtnInter = new EllieButtonInteractionHandler(_client, + model.User?.Id ?? 0, + rightButton, + (smc) => + { + try + { + if (currentPage >= lastPage) + return Task.CompletedTask; + + currentPage++; + + _ = UpdatePageAsync(smc); + } + catch (Exception ex) + { + Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message); + } + + return Task.CompletedTask; + }, + true, + singleUse: false); + + return (leftBtnInter, maybeInter, rightBtnInter); + } + + async Task UpdatePageAsync(SocketMessageComponent smc) + { + var pageItems = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray(); + var toSend = await _paginationBuilder.PageFunc(pageItems, currentPage); + if (_paginationBuilder.AddPaginatedFooter) + toSend.AddPaginatedFooter(currentPage, lastPage); + + var (left, extra, right) = (await GetInteractions()); + + var cb = new ComponentBuilder(); + left.AddTo(cb); + right.AddTo(cb); + extra?.AddTo(cb); + + await smc.ModifyOriginalResponseAsync(x => + { + x.Embed = toSend.Build(); + x.Components = cb.Build(); + }); + } + + var (left, extra, right) = await GetInteractions(); + + var cb = new ComponentBuilder(); + left.AddTo(cb); + right.AddTo(cb); + extra?.AddTo(cb); + + var msg = await model.TargetChannel + .SendMessageAsync(model.Text, + embed: embed.Build(), + components: cb.Build(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + + if (lastPage == 0 && _paginationBuilder.InteractionFunc is null) + return; + + await Task.WhenAll(left.RunAsync(msg), extra?.RunAsync(msg) ?? Task.CompletedTask, right.RunAsync(msg)); + + await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.cs b/src/EllieBot/_common/Sender/ResponseBuilder.cs new file mode 100644 index 0000000..60783de --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.cs @@ -0,0 +1,492 @@ +using EllieBot.Common.Configs; +using EllieBot.Db.Models; +using System.Collections.ObjectModel; + +namespace EllieBot.Extensions; + +public sealed partial class ResponseBuilder +{ + private ICommandContext? ctx; + private IMessageChannel? channel; + private string? plainText; + private IReadOnlyCollection? embeds; + private IUserMessage? msg; + private IUser? user; + private bool sanitizeMentions = true; + private LocStr? locTxt; + private object[] locParams = []; + private bool shouldReply = true; + private readonly IBotStrings _bs; + private readonly BotConfigService _bcs; + private EmbedBuilder? embedBuilder; + private EllieInteractionBase? inter; + private Stream? fileStream; + private string? fileName; + private EmbedColor color = EmbedColor.Ok; + private LocStr? embedLocDesc; + + public DiscordSocketClient Client { get; set; } + + public ResponseBuilder(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client) + { + _bs = bs; + _bcs = bcs; + Client = client; + } + + + private MessageReference? CreateMessageReference(IMessageChannel targetChannel) + { + if (!shouldReply) + return null; + + var replyTo = msg ?? ctx?.Message; + // what message are we replying to + if (replyTo is null) + return null; + + // we have to have a channel where we are sending the message in order to know whether we can reply to it + if (targetChannel.Id != replyTo.Channel.Id) + return null; + + return new(replyTo.Id, + replyTo.Channel.Id, + (replyTo.Channel as ITextChannel)?.GuildId, + failIfNotExists: false); + } + + public async Task BuildAsync(bool ephemeral) + { + var targetChannel = await InternalResolveChannel() ?? throw new ArgumentNullException(nameof(channel)); + var msgReference = CreateMessageReference(targetChannel); + + var txt = GetText(locTxt, targetChannel); + + if (embedLocDesc is LocStr ls) + { + InternalCreateEmbed(null, GetText(ls, targetChannel)); + } + + if (embedBuilder is not null) + PaintEmbedInternal(embedBuilder); + + var finalEmbed = embedBuilder?.Build(); + + + var buildModel = new ResponseMessageModel() + { + TargetChannel = targetChannel, + MessageReference = msgReference, + Text = txt, + User = user ?? ctx?.User, + Embed = finalEmbed, + Embeds = embeds?.Map(x => x.Build()), + SanitizeMentions = sanitizeMentions ? new(AllowedMentionTypes.Users) : AllowedMentions.All, + Ephemeral = ephemeral, + Interaction = inter + }; + + return buildModel; + } + + public async Task SendAsync(bool ephemeral = false) + { + var model = await BuildAsync(ephemeral); + var sentMsg = await SendAsync(model); + + + return sentMsg; + } + + public async Task SendAsync(ResponseMessageModel model) + { + IUserMessage sentMsg; + if (fileStream is Stream stream) + { + sentMsg = await model.TargetChannel.SendFileAsync(stream, + filename: fileName, + model.Text, + embed: model.Embed, + components: inter?.CreateComponent(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + } + else + { + sentMsg = await model.TargetChannel.SendMessageAsync( + model.Text, + embed: model.Embed, + embeds: model.Embeds, + components: inter?.CreateComponent(), + allowedMentions: model.SanitizeMentions, + messageReference: model.MessageReference); + } + + if (model.Interaction is not null) + { + await model.Interaction.RunAsync(sentMsg); + } + + return sentMsg; + } + + private EmbedBuilder PaintEmbedInternal(EmbedBuilder eb) + => color switch + { + EmbedColor.Ok => eb.WithOkColor(), + EmbedColor.Pending => eb.WithPendingColor(), + EmbedColor.Error => eb.WithErrorColor(), + _ => throw new NotSupportedException() + }; + + private ulong? InternalResolveGuildId(IMessageChannel? targetChannel) + => ctx?.Guild?.Id ?? (targetChannel as ITextChannel)?.GuildId; + + private async Task InternalResolveChannel() + { + if (user is not null) + { + var ch = await user.CreateDMChannelAsync(); + + if (ch is not null) + { + return ch; + } + } + + return channel ?? ctx?.Channel ?? msg?.Channel; + } + + private string? GetText(LocStr? locStr, IMessageChannel targetChannel) + { + var guildId = InternalResolveGuildId(targetChannel); + return locStr is LocStr ls ? _bs.GetText(ls.Key, guildId, locParams) : plainText; + } + + private string GetText(LocStr locStr, IMessageChannel targetChannel) + { + var guildId = InternalResolveGuildId(targetChannel); + return _bs.GetText(locStr.Key, guildId, locStr.Params); + } + + public ResponseBuilder Text(LocStr str) + { + locTxt = str; + return this; + } + + public ResponseBuilder Text(SmartText text) + { + if (text is SmartPlainText spt) + plainText = spt.Text; + else if (text is SmartEmbedText set) + { + plainText = set.PlainText ?? plainText; + embedBuilder = set.GetEmbed(); + } + else if (text is SmartEmbedTextArray ser) + { + plainText = ser.Content ?? plainText; + embeds = ser.GetEmbedBuilders(); + } + + return this; + } + + private void InternalCreateEmbed( + string? title, + string text, + string? url = null, + string? footer = null) + { + var eb = new EllieEmbedBuilder(_bcs) + .WithDescription(text); + + if (!string.IsNullOrWhiteSpace(title)) + eb.WithTitle(title); + + if (!string.IsNullOrWhiteSpace(url)) + eb = eb.WithUrl(url); + + if (!string.IsNullOrWhiteSpace(footer)) + eb = eb.WithFooter(footer); + + embedBuilder = eb; + } + + public ResponseBuilder Confirm( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Error( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder Pending( + string? title, + string text, + string? url = null, + string? footer = null) + { + InternalCreateEmbed(title, text, url, footer); + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Confirm(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Confirm(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Ok; + return this; + } + + public ResponseBuilder Pending(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Pending(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Pending; + return this; + } + + public ResponseBuilder Error(string text) + { + InternalCreateEmbed(null, text); + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder Error(LocStr str) + { + embedLocDesc = str; + color = EmbedColor.Error; + return this; + } + + public ResponseBuilder UserBasedMentions() + { + sanitizeMentions = !((InternalResolveUser() as IGuildUser)?.GuildPermissions.MentionEveryone ?? false); + return this; + } + + private IUser? InternalResolveUser() + => ctx?.User ?? user ?? msg?.Author; + + public ResponseBuilder Embed(EmbedBuilder eb) + { + embedBuilder = eb; + return this; + } + + public ResponseBuilder Channel(IMessageChannel ch) + { + channel = ch; + return this; + } + + public ResponseBuilder Sanitize(bool shouldSantize = true) + { + sanitizeMentions = shouldSantize; + return this; + } + + public ResponseBuilder Context(ICommandContext context) + { + ctx = context; + return this; + } + + public ResponseBuilder Message(IUserMessage message) + { + msg = message; + return this; + } + + public ResponseBuilder User(IUser usr) + { + user = usr; + return this; + } + + public ResponseBuilder NoReply() + { + shouldReply = false; + return this; + } + + public ResponseBuilder Interaction(EllieInteractionBase? interaction) + { + inter = interaction; + return this; + } + + public ResponseBuilder Embeds(IReadOnlyCollection inputEmbeds) + { + embeds = inputEmbeds; + return this; + } + + public ResponseBuilder File(Stream stream, string name) + { + fileStream = stream; + fileName = name; + return this; + } + + public PaginatedResponseBuilder Paginated() + => new(this); +} + +public class PaginatedResponseBuilder +{ + protected readonly ResponseBuilder _builder; + + public PaginatedResponseBuilder(ResponseBuilder builder) + { + _builder = builder; + } + + public SourcedPaginatedResponseBuilder Items(IReadOnlyCollection items) + => new SourcedPaginatedResponseBuilder(_builder) + .Items(items); + + public SourcedPaginatedResponseBuilder PageItems(Func>> items) + => new SourcedPaginatedResponseBuilder(_builder) + .PageItems(items); +} + +public sealed class SourcedPaginatedResponseBuilder : PaginatedResponseBuilder +{ + private IReadOnlyCollection? items; + + public Func, int, Task> PageFunc { get; private set; } = static delegate + { + return Task.FromResult(new()); + }; + + public Func>> ItemsFunc { get; set; } = static delegate + { + return Task.FromResult>(ReadOnlyCollection.Empty); + }; + + public Func>? InteractionFunc { get; private set; } + + public int? Elems { get; private set; } = 1; + public int ItemsPerPage { get; private set; } = 9; + public bool AddPaginatedFooter { get; private set; } = true; + public bool IsEphemeral { get; private set; } + + public int InitialPage { get; set; } + + public SourcedPaginatedResponseBuilder(ResponseBuilder builder) + : base(builder) + { + } + + public SourcedPaginatedResponseBuilder Items(IReadOnlyCollection col) + { + items = col; + Elems = col.Count; + ItemsFunc = (i) => Task.FromResult(items.Skip(i * ItemsPerPage).Take(ItemsPerPage).ToArray() as IReadOnlyCollection); + return this; + } + + public SourcedPaginatedResponseBuilder TotalElements(int i) + { + Elems = i; + return this; + } + + public SourcedPaginatedResponseBuilder PageItems(Func>> func) + { + Elems = null; + ItemsFunc = func; + return this; + } + + + public SourcedPaginatedResponseBuilder PageSize(int i) + { + ItemsPerPage = i; + return this; + } + + public SourcedPaginatedResponseBuilder CurrentPage(int i) + { + InitialPage = i; + return this; + } + + + public SourcedPaginatedResponseBuilder Page(Func, int, EmbedBuilder> pageFunc) + { + PageFunc = (xs, x) => Task.FromResult(pageFunc(xs, x)); + return this; + } + + public SourcedPaginatedResponseBuilder Page(Func, int, Task> pageFunc) + { + PageFunc = pageFunc; + return this; + } + + public SourcedPaginatedResponseBuilder AddFooter(bool addFooter = true) + { + AddPaginatedFooter = addFooter; + return this; + } + + public SourcedPaginatedResponseBuilder Ephemeral() + { + IsEphemeral = true; + return this; + } + + + public Task SendAsync() + { + var paginationSender = new ResponseBuilder.PaginationSender( + this, + _builder); + + return paginationSender.SendAsync(IsEphemeral); + } + + public SourcedPaginatedResponseBuilder Interaction(Func> func) + { + InteractionFunc = func; //async (i) => await func(i); + return this; + } + + public SourcedPaginatedResponseBuilder Interaction(EllieInteractionBase inter) + { + InteractionFunc = _ => Task.FromResult(inter); + return this; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs new file mode 100644 index 0000000..40123a6 --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs @@ -0,0 +1,28 @@ +namespace EllieBot.Extensions; + +public static class ResponseBuilderExtensions +{ + public static EmbedBuilder WithPendingColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithPendingColor(); + + return eb; + } + + public static EmbedBuilder WithOkColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithOkColor(); + + return eb; + } + + public static EmbedBuilder WithErrorColor(this EmbedBuilder eb) + { + if (eb is EllieEmbedBuilder neb) + return neb.WithErrorColor(); + + return eb; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Sender/ResponseMessageModel.cs b/src/EllieBot/_common/Sender/ResponseMessageModel.cs new file mode 100644 index 0000000..675cd09 --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseMessageModel.cs @@ -0,0 +1,12 @@ +public class ResponseMessageModel +{ + public required IMessageChannel TargetChannel { get; set; } + public MessageReference? MessageReference { get; set; } + public string? Text { get; set; } + public Embed? Embed { get; set; } + public Embed[]? Embeds { get; set; } + public required AllowedMentions SanitizeMentions { get; set; } + public IUser? User { get; set; } + public bool Ephemeral { get; set; } + public EllieInteractionBase? Interaction { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/ServiceCollectionExtensions.cs b/src/EllieBot/_common/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4b468d2 --- /dev/null +++ b/src/EllieBot/_common/ServiceCollectionExtensions.cs @@ -0,0 +1,131 @@ +using DryIoc; +using LinqToDB.Extensions; +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Modules.Music; +using EllieBot.Modules.Music.Resolvers; +using EllieBot.Modules.Music.Services; +using StackExchange.Redis; +using System.Net; +using System.Reflection; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IContainer AddBotStringsServices(this IContainer svcs, BotCacheImplemenation botCache) + { + if (botCache == BotCacheImplemenation.Memory) + { + svcs.AddSingleton(); + svcs.AddSingleton(); + } + else + { + svcs.AddSingleton(); + svcs.AddSingleton(); + } + + svcs.AddSingleton(); + + return svcs; + } + + public static IContainer AddConfigServices(this IContainer svcs, Assembly a) + { + + foreach (var type in a.GetTypes() + .Where(x => !x.IsAbstract && x.IsAssignableToGenericType(typeof(ConfigServiceBase<>)))) + { + svcs.RegisterMany([type], + getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType), + getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton)); + } + + return svcs; + } + + + public static IContainer AddMusic(this IContainer svcs) + { + svcs.RegisterMany(Reuse.Singleton); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + + return svcs; + } + + public static IContainer AddCache(this IContainer cont, IBotCredentials creds) + { + if (creds.BotCache == BotCacheImplemenation.Redis) + { + var conf = ConfigurationOptions.Parse(creds.RedisOptions); + cont.AddSingleton(ConnectionMultiplexer.Connect(conf)); + cont.AddSingleton(); + cont.AddSingleton(); + } + else + { + cont.AddSingleton(); + cont.AddSingleton(); + } + + return cont + .AddBotStringsServices(creds.BotCache); + } + + public static IContainer AddHttpClients(this IContainer svcs) + { + IServiceCollection proxySvcs = new ServiceCollection(); + proxySvcs.AddHttpClient(); + proxySvcs.AddHttpClient("memelist") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false + }); + + proxySvcs.AddHttpClient("google:search") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }); + + var prov = proxySvcs.BuildServiceProvider(); + + svcs.RegisterDelegate(_ => prov.GetRequiredService()); + svcs.RegisterDelegate(_ => prov.GetRequiredService()); + + return svcs; + } + + public static IContainer AddLifetimeServices(this IContainer svcs, Assembly a) + { + Type[] types = + [ + typeof(IExecOnMessage), + typeof(IExecPreCommand), + typeof(IExecPostCommand), + typeof(IExecNoCommand), + typeof(IInputTransformer), + typeof(IEService) + ]; + + foreach (var svc in a.GetTypes() + .Where(type => type.IsClass && types.Any(t => type.IsAssignableTo(t)) && !type.HasAttribute() +#if GLOBAL_ELLIE + && !type.HasAttribute() +#endif + )) + { + svcs.RegisterMany([svc], + getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType), + getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton)); + } + + return svcs; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/CommandHandler.cs b/src/EllieBot/_common/Services/CommandHandler.cs new file mode 100644 index 0000000..c058887 --- /dev/null +++ b/src/EllieBot/_common/Services/CommandHandler.cs @@ -0,0 +1,433 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using ExecuteResult = Discord.Commands.ExecuteResult; +using PreconditionResult = Discord.Commands.PreconditionResult; + +namespace EllieBot.Services; + +public class CommandHandler : IEService, IReadyExecutor, ICommandHandler +{ + private const int GLOBAL_COMMANDS_COOLDOWN = 750; + + private const float ONE_THOUSANDTH = 1.0f / 1000; + + public event Func CommandExecuted = delegate { return Task.CompletedTask; }; + public event Func CommandErrored = delegate { return Task.CompletedTask; }; + + //userid/msg count + public ConcurrentDictionary UserMessagesSent { get; } = new(); + + public ConcurrentHashSet UsersOnShortCooldown { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly CommandService _commandService; + private readonly BotConfigService _bcs; + private readonly IBot _bot; + private readonly IBehaviorHandler _behaviorHandler; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary _prefixes; + + private readonly DbService _db; + + private readonly BotConfig _bc; + // private readonly InteractionService _interactions; + + public CommandHandler( + DiscordSocketClient client, + DbService db, + CommandService commandService, + BotConfigService bcs, + IBot bot, + IBehaviorHandler behaviorHandler, + // InteractionService interactions, + IServiceProvider services) + { + _client = client; + _commandService = commandService; + _bc = bcs.Data; + _bcs = bcs; + _bot = bot; + _behaviorHandler = behaviorHandler; + _db = db; + _services = services; + // _interactions = interactions; + + _prefixes = bot.AllGuildConfigs.Where(x => x.Prefix is not null) + .ToDictionary(x => x.GuildId, x => x.Prefix) + .ToConcurrent(); + } + + public async Task OnReadyAsync() + { + // clear users on short cooldown every GLOBAL_COMMANDS_COOLDOWN miliseconds + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(GLOBAL_COMMANDS_COOLDOWN)); + while (await timer.WaitForNextTickAsync()) + UsersOnShortCooldown.Clear(); + } + + public string GetPrefix(IGuild guild) + => GetPrefix(guild?.Id); + + public string GetPrefix(ulong? id = null) + { + if (id is null || !_prefixes.TryGetValue(id.Value, out var prefix)) + return _bcs.Data.Prefix; + + return prefix; + } + + public string SetDefaultPrefix(string prefix) + { + if (string.IsNullOrWhiteSpace(prefix)) + throw new ArgumentNullException(nameof(prefix)); + + _bcs.ModifyConfig(bs => + { + bs.Prefix = prefix; + }); + + return prefix; + } + + public string SetPrefix(IGuild guild, string prefix) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(prefix); + ArgumentNullException.ThrowIfNull(guild); + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set); + gc.Prefix = prefix; + uow.SaveChanges(); + } + + _prefixes[guild.Id] = prefix; + + return prefix; + } + + public async Task ExecuteExternal(ulong? guildId, ulong channelId, string commandText) + { + if (guildId is not null) + { + var guild = _client.GetGuild(guildId.Value); + if (guild?.GetChannel(channelId) is not SocketTextChannel channel) + { + Log.Warning("Channel for external execution not found"); + return; + } + + try + { + IUserMessage msg = await channel.SendMessageAsync(commandText); + msg = (IUserMessage)await channel.GetMessageAsync(msg.Id); + await TryRunCommand(guild, channel, msg); + //msg.DeleteAfter(5); + } + catch { } + } + } + + public Task StartHandling() + { + _client.MessageReceived += MessageReceivedHandler; + // _client.SlashCommandExecuted += SlashCommandExecuted; + return Task.CompletedTask; + } + + // private async Task SlashCommandExecuted(SocketSlashCommand arg) + // { + // var ctx = new SocketInteractionContext(_client, arg); + // await _interactions.ExecuteCommandAsync(ctx, _services); + // } + + private Task LogSuccessfulExecution(IUserMessage usrMsg, ITextChannel channel, params int[] execPoints) + { + if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Information(""" + Command Executed after {ExecTime}s + User: {User} + Server: {Server} + Channel: {Channel} + Message: {Message} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "PRIVATE" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "PRIVATE" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content); + } + else + { + Log.Information("Succ | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}", + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10)); + } + + return Task.CompletedTask; + } + + private void LogErroredExecution( + string errorMessage, + IUserMessage usrMsg, + ITextChannel channel, + params int[] execPoints) + { + if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal) + { + Log.Warning(""" + Command Errored after {ExecTime}s + User: {User} + Server: {Guild} + Channel: {Channel} + Message: {Message} + Error: {ErrorMessage} + """, + string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))), + usrMsg.Author + " [" + usrMsg.Author.Id + "]", + channel is null ? "DM" : channel.Guild.Name + " [" + channel.Guild.Id + "]", + channel is null ? "DM" : channel.Name + " [" + channel.Id + "]", + usrMsg.Content, + errorMessage); + } + else + { + Log.Warning(""" + Err | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message} + Err: {ErrorMessage} + """, + channel?.Guild.Id.ToString() ?? "-", + channel?.Id.ToString() ?? "-", + usrMsg.Author.Id, + usrMsg.Content.TrimTo(10), + errorMessage); + } + } + + private Task MessageReceivedHandler(SocketMessage msg) + { + if (!_bot.IsReady) + return Task.CompletedTask; + + if (_bc.IgnoreOtherBots) + { + if (msg.Author.IsBot) + return Task.CompletedTask; + } + else if (msg.Author.Id == _client.CurrentUser.Id) + return Task.CompletedTask; + + if (msg is not SocketUserMessage usrMsg) + return Task.CompletedTask; + + Task.Run(async () => + { + try + { +#if !GLOBAL_ELLIE + // track how many messages each user is sending + UserMessagesSent.AddOrUpdate(usrMsg.Author.Id, 1, (_, old) => ++old); +#endif + + var channel = msg.Channel; + var guild = (msg.Channel as SocketTextChannel)?.Guild; + + await TryRunCommand(guild, channel, usrMsg); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in CommandHandler"); + if (ex.InnerException is not null) + Log.Warning(ex.InnerException, "Inner Exception of the error in CommandHandler"); + } + }); + + return Task.CompletedTask; + } + + public async Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg) + { + var startTime = Environment.TickCount; + + var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg); + if (blocked) + return; + + var blockTime = Environment.TickCount - startTime; + + var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg); + + var prefix = GetPrefix(guild?.Id); + var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase); + // execute the command and measure the time it took + if (isPrefixCommand || messageContent.StartsWith(prefix, StringComparison.InvariantCulture)) + { + var context = new CommandContext(_client, usrMsg); + var (success, error, info) = await ExecuteCommandAsync(context, + messageContent, + isPrefixCommand ? 1 : prefix.Length, + _services, + MultiMatchHandling.Best); + + startTime = Environment.TickCount - startTime; + + // if a command is found + if (info is not null) + { + // if it successfully executed + if (success) + { + await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime); + await CommandExecuted(usrMsg, info); + await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info); + return; + } + + // if it errored + if (error is not null) + { + error = HumanizeError(error); + LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime); + + if (guild is not null) + await CommandErrored(info, channel as ITextChannel, error); + + return; + } + } + } + + await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg); + } + + private string HumanizeError(string error) + { + if (error.Contains("parse int", StringComparison.OrdinalIgnoreCase) + || error.Contains("parse float")) + return "Invalid number specified. Make sure you're specifying parameters in the correct order."; + + return error; + } + + public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync( + ICommandContext context, + string input, + int argPos, + IServiceProvider serviceProvider, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + => ExecuteCommand(context, input[argPos..], serviceProvider, multiMatchHandling); + + + public async Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommand( + ICommandContext context, + string input, + IServiceProvider services, + MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + var searchResult = _commandService.Search(context, input); + if (!searchResult.IsSuccess) + return (false, null, null); + + var commands = searchResult.Commands; + var preconditionResults = new Dictionary(); + + foreach (var match in commands) + preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services); + + var successfulPreconditions = preconditionResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulPreconditions.Length == 0) + { + //All preconditions failed, return the one from the highest priority command + var bestCandidate = preconditionResults.OrderByDescending(x => x.Key.Command.Priority) + .FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestCandidate.Value.ErrorReason, commands[0].Command); + } + + var parseResultsDict = new Dictionary(); + foreach (var pair in successfulPreconditions) + { + var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services); + + if (parseResult.Error == CommandError.MultipleMatches) + { + IReadOnlyList argList, paramList; + switch (multiMatchHandling) + { + case MultiMatchHandling.Best: + argList = parseResult.ArgValues + .Map(x => x.Values.MaxBy(y => y.Score)); + paramList = parseResult.ParamValues + .Map(x => x.Values.MaxBy(y => y.Score)); + parseResult = ParseResult.FromSuccess(argList, paramList); + break; + } + } + + parseResultsDict[pair.Key] = parseResult; + } + + // Calculates the 'score' of a command given a parse result + float CalculateScore(CommandMatch match, ParseResult parseResult) + { + float argValuesScore = 0, paramValuesScore = 0; + + if (match.Command.Parameters.Count > 0) + { + var argValuesSum = + parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + var paramValuesSum = + parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) + ?? 0; + + argValuesScore = argValuesSum / match.Command.Parameters.Count; + paramValuesScore = paramValuesSum / match.Command.Parameters.Count; + } + + var totalArgsScore = (argValuesScore + paramValuesScore) / 2; + return match.Command.Priority + (totalArgsScore * 0.99f); + } + + //Order the parse results by their score so that we choose the most likely result to execute + var parseResults = parseResultsDict.OrderByDescending(x => CalculateScore(x.Key, x.Value)).ToList(); + + var successfulParses = parseResults.Where(x => x.Value.IsSuccess).ToArray(); + + if (successfulParses.Length == 0) + { + //All parses failed, return the one from the highest priority command, using score as a tie breaker + var bestMatch = parseResults.FirstOrDefault(x => !x.Value.IsSuccess); + return (false, bestMatch.Value.ErrorReason, commands[0].Command); + } + + var cmd = successfulParses[0].Key.Command; + + // Bot will ignore commands which are ran more often than what specified by + // GlobalCommandsCooldown constant (miliseconds) + if (!UsersOnShortCooldown.Add(context.Message.Author.Id)) + return (false, null, cmd); + //return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown."); + + var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd); + if (blocked) + return (false, null, cmd); + + //If we get this far, at least one parse was successful. Execute the most likely overload. + var chosenOverload = successfulParses[0]; + var execResult = (ExecuteResult)await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services); + + if (execResult.Exception is not null + && (execResult.Exception is not HttpException he + || he.DiscordCode != DiscordErrorCode.InsufficientPermissions)) + Log.Warning(execResult.Exception, "Command Error"); + + return (true, null, cmd); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/CurrencyService.cs b/src/EllieBot/_common/Services/Currency/CurrencyService.cs new file mode 100644 index 0000000..9cb4037 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/CurrencyService.cs @@ -0,0 +1,116 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public sealed class CurrencyService : ICurrencyService, IEService +{ + private readonly DbService _db; + private readonly ITxTracker _txTracker; + + public CurrencyService(DbService db, ITxTracker txTracker) + { + _db = db; + _txTracker = txTracker; + } + + public Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + return Task.FromResult(new DefaultWallet(userId, _db)); + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + foreach (var userId in userIds) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + } + + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + await using var ctx = _db.GetDbContext(); + await ctx + .GetTable() + .Where(x => userIds.Contains(x.UserId)) + .UpdateAsync(du => new() + { + CurrencyAmount = du.CurrencyAmount >= amount + ? du.CurrencyAmount - amount + : 0 + }); + await ctx.SaveChangesAsync(); + return; + } + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddAsync( + ulong userId, + long amount, + TxData txData) + { + var wallet = await GetWalletAsync(userId); + await wallet.Add(amount, txData); + await _txTracker.TrackAdd(amount, txData); + } + + public async Task AddAsync( + IUser user, + long amount, + TxData txData) + => await AddAsync(user.Id, amount, txData); + + public async Task RemoveAsync( + ulong userId, + long amount, + TxData txData) + { + if (amount == 0) + return true; + + var wallet = await GetWalletAsync(userId); + var result = await wallet.Take(amount, txData); + if (result) + await _txTracker.TrackRemove(amount, txData); + return result; + } + + public async Task RemoveAsync( + IUser user, + long amount, + TxData txData) + => await RemoveAsync(user.Id, amount, txData); + + public async Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetTopRichest(ignoreId, page, perPage); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs new file mode 100644 index 0000000..7007ee4 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs @@ -0,0 +1,48 @@ +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public static class CurrencyServiceExtensions +{ + public static async Task GetBalanceAsync(this ICurrencyService cs, ulong userId) + { + var wallet = await cs.GetWalletAsync(userId); + return await wallet.GetBalance(); + } + + // FUTURE should be a transaction + public static async Task TransferAsync( + this ICurrencyService cs, + IMessageSenderService sender, + IUser from, + IUser to, + long amount, + string? note, + string formattedAmount) + { + var fromWallet = await cs.GetWalletAsync(from.Id); + var toWallet = await cs.GetWalletAsync(to.Id); + + var extra = new TxData("gift", from.ToString()!, note, from.Id); + + if (await fromWallet.Transfer(amount, toWallet, extra)) + { + try + { + await sender.Response(to) + .Confirm(string.IsNullOrWhiteSpace(note) + ? $"Received {formattedAmount} from {from} " + : $"Received {formattedAmount} from {from}: {note}") + .SendAsync(); + } + catch + { + //ignored + } + + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/DefaultWallet.cs b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs new file mode 100644 index 0000000..a556985 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs @@ -0,0 +1,108 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Services.Currency; + +public class DefaultWallet : IWallet +{ + private readonly DbService _db; + public ulong UserId { get; } + + public DefaultWallet(ulong userId, DbService db) + { + UserId = userId; + _db = db; + } + + public async Task GetBalance() + { + await using var ctx = _db.GetDbContext(); + var userId = UserId; + return await ctx + .GetTable() + .Where(x => x.UserId == userId) + .Select(x => x.CurrencyAmount) + .FirstOrDefaultAsync(); + } + + public async Task Take(long amount, TxData? txData) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount to take must be non negative."); + + await using var ctx = _db.GetDbContext(); + + var userId = UserId; + var changed = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.CurrencyAmount >= amount) + .UpdateAsync(x => new() + { + CurrencyAmount = x.CurrencyAmount - amount + }); + + if (changed == 0) + return false; + + if (txData is not null) + { + await ctx + .GetTable() + .InsertAsync(() => new() + { + Amount = -amount, + Note = txData.Note, + UserId = userId, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + + return true; + } + + public async Task Add(long amount, TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + await using var ctx = _db.GetDbContext(); + var userId = UserId; + + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Username = "Unknown", + Discriminator = "????", + CurrencyAmount = amount, + }, + (old) => new() + { + CurrencyAmount = old.CurrencyAmount + amount + }, + () => new() + { + UserId = userId + }); + + if (txData is not null) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Amount = amount, + UserId = userId, + Note = txData.Note, + Type = txData.Type, + Extra = txData.Extra, + OtherId = txData.OtherId, + DateAdded = DateTime.UtcNow + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs new file mode 100644 index 0000000..5751281 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs @@ -0,0 +1,110 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Services.Currency; +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor +{ + private static readonly IReadOnlySet _gamblingTypes = new HashSet(new[] + { + "lula", + "betroll", + "betflip", + "blackjack", + "betdraw", + "slot", + }); + + private ConcurrentDictionary _stats = new(); + + private readonly DbService _db; + + public GamblingTxTracker(DbService db) + { + _db = db; + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await using var trans = await ctx.Database.BeginTransactionAsync(); + + try + { + var keys = _stats.Keys; + foreach (var key in keys) + { + if (_stats.TryRemove(key, out var stat)) + { + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + Feature = key, + Bet = stat.Bet, + PaidOut = stat.PaidOut, + DateAdded = DateTime.UtcNow + }, old => new() + { + Bet = old.Bet + stat.Bet, + PaidOut = old.PaidOut + stat.PaidOut, + }, () => new() + { + Feature = key + }); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred in gambling tx tracker"); + } + finally + { + await trans.CommitAsync(); + } + } + } + + public Task TrackAdd(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (0, amount), + (_, old) => (old.Bet, old.PaidOut + amount)); + } + + return Task.CompletedTask; + } + + public Task TrackRemove(long amount, TxData? txData) + { + if (txData is null) + return Task.CompletedTask; + + if (_gamblingTypes.Contains(txData.Type)) + { + _stats.AddOrUpdate(txData.Type, + _ => (amount, 0), + (_, old) => (old.Bet + amount, old.PaidOut)); + } + + return Task.CompletedTask; + } + + public async Task> GetAllAsync() + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .ToListAsyncEF(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IBehaviourHandler.cs b/src/EllieBot/_common/Services/IBehaviourHandler.cs new file mode 100644 index 0000000..79e8e5a --- /dev/null +++ b/src/EllieBot/_common/Services/IBehaviourHandler.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IBehaviorHandler +{ + Task AddAsync(ICustomBehavior behavior); + Task AddRangeAsync(IEnumerable behavior); + Task RemoveAsync(ICustomBehavior behavior); + Task RemoveRangeAsync(IEnumerable behs); + + Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunPreCommandAsync(ICommandContext context, CommandInfo cmd); + ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd); + Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg); + void Initialize(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICommandHandler.cs b/src/EllieBot/_common/Services/ICommandHandler.cs new file mode 100644 index 0000000..f838743 --- /dev/null +++ b/src/EllieBot/_common/Services/ICommandHandler.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Services; + +public interface ICommandHandler +{ + string GetPrefix(IGuild ctxGuild); + string GetPrefix(ulong? id = null); + string SetDefaultPrefix(string toSet); + string SetPrefix(IGuild ctxGuild, string toSet); + ConcurrentDictionary UserMessagesSent { get; } + + Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICoordinator.cs b/src/EllieBot/_common/Services/ICoordinator.cs new file mode 100644 index 0000000..10ec4ae --- /dev/null +++ b/src/EllieBot/_common/Services/ICoordinator.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Services; + +public interface ICoordinator +{ + bool RestartBot(); + void Die(bool graceful); + bool RestartShard(int shardId); + IList GetAllShardStatuses(); + int GetGuildCount(); + Task Reload(); +} + +public class ShardStatus +{ + public ConnectionState ConnectionState { get; set; } + public DateTime LastUpdate { get; set; } + public int ShardId { get; set; } + public int GuildCount { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ICustomBehavior.cs b/src/EllieBot/_common/Services/ICustomBehavior.cs new file mode 100644 index 0000000..2e4bedb --- /dev/null +++ b/src/EllieBot/_common/Services/ICustomBehavior.cs @@ -0,0 +1,13 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Services; + +public interface ICustomBehavior + : IExecOnMessage, + IInputTransformer, + IExecPreCommand, + IExecNoCommand, + IExecPostCommand +{ + +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IEService.cs b/src/EllieBot/_common/Services/IEService.cs new file mode 100644 index 0000000..944d8cc --- /dev/null +++ b/src/EllieBot/_common/Services/IEService.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// All services must implement this interface in order to be auto-discovered by the DI system +/// +public interface IEService +{ +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IGoogleApiService.cs b/src/EllieBot/_common/Services/IGoogleApiService.cs new file mode 100644 index 0000000..856bd51 --- /dev/null +++ b/src/EllieBot/_common/Services/IGoogleApiService.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IGoogleApiService +{ + IReadOnlyDictionary Languages { get; } + + Task> GetVideoLinksByKeywordAsync(string keywords, int count = 1); + Task> GetVideoInfosByKeywordAsync(string keywords, int count = 1); + Task> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1); + Task> GetRelatedVideosAsync(string id, int count = 1, string user = null); + Task> GetPlaylistTracksAsync(string playlistId, int count = 50); + Task> GetVideoDurationsAsync(IEnumerable videoIds); + Task Translate(string sourceText, string sourceLanguage, string targetLanguage); + + Task ShortenUrl(string url); + Task ShortenUrl(Uri url); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ILocalDataCache.cs b/src/EllieBot/_common/Services/ILocalDataCache.cs new file mode 100644 index 0000000..e6977e3 --- /dev/null +++ b/src/EllieBot/_common/Services/ILocalDataCache.cs @@ -0,0 +1,13 @@ +#nullable disable +using EllieBot.Common.Pokemon; +using EllieBot.Modules.Games.Common.Trivia; + +namespace EllieBot.Services; + +public interface ILocalDataCache +{ + Task> GetPokemonsAsync(); + Task> GetPokemonAbilitiesAsync(); + Task GetTriviaQuestionsAsync(); + Task> GetPokemonMapAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ILocalization.cs b/src/EllieBot/_common/Services/ILocalization.cs new file mode 100644 index 0000000..3fa7c5e --- /dev/null +++ b/src/EllieBot/_common/Services/ILocalization.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Services; + +public interface ILocalization +{ + CultureInfo DefaultCultureInfo { get; } + IDictionary GuildCultureInfos { get; } + + CultureInfo GetCultureInfo(IGuild guild); + CultureInfo GetCultureInfo(ulong? guildId); + void RemoveGuildCulture(IGuild guild); + void RemoveGuildCulture(ulong guildId); + void ResetDefaultCulture(); + void SetDefaultCulture(CultureInfo ci); + void SetGuildCulture(IGuild guild, CultureInfo ci); + void SetGuildCulture(ulong guildId, CultureInfo ci); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IRemindService.cs b/src/EllieBot/_common/Services/IRemindService.cs new file mode 100644 index 0000000..5a057c8 --- /dev/null +++ b/src/EllieBot/_common/Services/IRemindService.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.Services; + +public interface IRemindService +{ + Task AddReminderAsync(ulong userId, + ulong targetId, + ulong? guildId, + bool isPrivate, + DateTime time, + string message, + ReminderType reminderType); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/IStatsService.cs b/src/EllieBot/_common/Services/IStatsService.cs new file mode 100644 index 0000000..3dee0a6 --- /dev/null +++ b/src/EllieBot/_common/Services/IStatsService.cs @@ -0,0 +1,70 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IStatsService +{ + /// + /// The author of the bot. + /// + string Author { get; } + + /// + /// The total amount of commands ran since startup. + /// + long CommandsRan { get; } + + /// + /// The amount of messages seen by the bot since startup. + /// + long MessageCounter { get; } + + /// + /// The rate of messages the bot sees every second. + /// + double MessagesPerSecond { get; } + + /// + /// The total amount of text channels the bot can see. + /// + long TextChannels { get; } + + /// + /// The total amount of voice channels the bot can see. + /// + long VoiceChannels { get; } + + /// + /// Gets for how long the bot has been up since startup. + /// + TimeSpan GetUptime(); + + /// + /// Gets a formatted string of how long the bot has been up since startup. + /// + /// The formatting separator. + string GetUptimeString(string separator = ", "); + + /// + /// Gets total amount of private memory currently in use by the bot, in Megabytes. + /// + double GetPrivateMemoryMegabytes(); + + GuildInfo GetGuildInfo(string name); + GuildInfo GetGuildInfo(ulong id); +} + +public record struct GuildInfo +{ + public required string Name { get; init; } + public required string IconUrl { get; init; } + public required string Owner { get; init; } + public required ulong OwnerId { get; init; } + public required ulong Id { get; init; } + public required int TextChannels { get; init; } + public required int VoiceChannels { get; init; } + public required DateTime CreatedAt { get; init; } + public required IReadOnlyList Features { get; init; } + public required IReadOnlyList Emojis { get; init; } + public required IReadOnlyList Roles { get; init; } + public int MemberCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/ITimezoneService.cs b/src/EllieBot/_common/Services/ITimezoneService.cs new file mode 100644 index 0000000..aa8cbff --- /dev/null +++ b/src/EllieBot/_common/Services/ITimezoneService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Common; + +public interface ITimezoneService +{ + TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs new file mode 100644 index 0000000..7e471a0 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs @@ -0,0 +1,302 @@ +#nullable disable +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Services; + +// should be renamed to handler as it's not only executing +public sealed class BehaviorHandler : IBehaviorHandler +{ + private readonly IServiceProvider _services; + + private IReadOnlyCollection noCommandExecs; + private IReadOnlyCollection preCommandExecs; + private IReadOnlyCollection onMessageExecs; + private IReadOnlyCollection inputTransformers; + + private readonly SemaphoreSlim _customLock = new(1, 1); + private readonly List _customExecs = new(); + + public BehaviorHandler(IServiceProvider services) + { + _services = services; + } + + public void Initialize() + { + noCommandExecs = _services.GetServices().ToArray(); + preCommandExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + onMessageExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + inputTransformers = _services.GetServices().ToArray(); + } + + #region Add/Remove + + public async Task AddRangeAsync(IEnumerable execs) + { + await _customLock.WaitAsync(); + try + { + foreach (var exe in execs) + { + if (_customExecs.Contains(exe)) + continue; + + _customExecs.Add(exe); + } + } + finally + { + _customLock.Release(); + } + } + + public async Task AddAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + if (_customExecs.Contains(behavior)) + return false; + + _customExecs.Add(behavior); + return true; + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + return _customExecs.Remove(behavior); + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveRangeAsync(IEnumerable behs) + { + await _customLock.WaitAsync(); + try + { + foreach(var beh in behs) + _customExecs.Remove(beh); + } + finally + { + _customLock.Release(); + } + } + + #endregion + + #region Running + + public async Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) + where T : IExecOnMessage + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecOnMessageAsync(guild, usrMsg)) + { + Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}", + GetExecName(exec), + guild?.Id, + usrMsg.Author.Id, + usrMsg.Channel.Id, + usrMsg.Content?.TrimTo(10)); + + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} late blocker: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(onMessageExecs)) + { + return true; + } + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + private string GetExecName(IBehavior exec) + => exec.Name; + + public async Task RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd) + { + async Task Exec(IReadOnlyCollection execs) where T: IExecPreCommand + { + foreach (var exec in execs) + { + try + { + if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd)) + { + Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]", + GetExecName(exec), + ctx.User, + cmd.Aliases[0]); + return true; + } + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} PreCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + + return false; + } + + if (await Exec(preCommandExecs)) + return true; + + await _customLock.WaitAsync(); + try + { + if (await Exec(_customExecs)) + return true; + } + finally + { + _customLock.Release(); + } + + return false; + } + + public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs) where T : IExecNoCommand + { + foreach (var exec in execs) + { + try + { + await exec.ExecOnNoCommandAsync(guild, usrMsg); + } + catch (Exception ex) + { + Log.Error(ex, + "An error occurred in {TypeName} OnNoCommand: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + await Exec(noCommandExecs); + + await _customLock.WaitAsync(); + try + { + await Exec(_customExecs); + } + finally + { + _customLock.Release(); + } + } + + public async Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection execs, string content) + where T : IInputTransformer + { + foreach (var exec in execs) + { + try + { + var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content); + if (newContent is not null) + { + Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}", + GetExecName(exec), + content, + newContent); + return newContent; + } + } + catch (Exception ex) + { + Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message); + } + } + + return null; + } + + var newContent = await Exec(inputTransformers, usrMsg.Content); + if (newContent is not null) + return newContent; + + await _customLock.WaitAsync(); + try + { + newContent = await Exec(_customExecs, usrMsg.Content); + if (newContent is not null) + return newContent; + } + finally + { + _customLock.Release(); + } + + return usrMsg.Content; + } + + public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd) + { + foreach (var exec in _customExecs) + { + try + { + await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name); + } + catch (Exception ex) + { + Log.Warning(ex, + "An error occured during PostCommand handling in {ExecName}: {ErrorMessage}", + GetExecName(exec), + ex.Message); + } + } + } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/BlacklistService.cs b/src/EllieBot/_common/Services/Impl/BlacklistService.cs new file mode 100644 index 0000000..ac01491 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/BlacklistService.cs @@ -0,0 +1,141 @@ +#nullable disable +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class BlacklistService : IExecOnMessage +{ + public int Priority + => int.MaxValue; + + private readonly DbService _db; + private readonly IPubSub _pubSub; + private readonly IBotCredentials _creds; + private IReadOnlyList blacklist; + + private readonly TypedKey _blPubKey = new("blacklist.reload"); + + public BlacklistService(DbService db, IPubSub pubSub, IBotCredentials creds) + { + _db = db; + _pubSub = pubSub; + _creds = creds; + + Reload(false); + _pubSub.Sub(_blPubKey, OnReload); + } + + private ValueTask OnReload(BlacklistEntry[] newBlacklist) + { + blacklist = newBlacklist; + return default; + } + + public Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + foreach (var bl in blacklist) + { + if (guild is not null && bl.Type == BlacklistType.Server && bl.ItemId == guild.Id) + { + Log.Information("Blocked input from blacklisted guild: {GuildName} [{GuildId}]", guild.Name, guild.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.Channel && bl.ItemId == usrMsg.Channel.Id) + { + Log.Information("Blocked input from blacklisted channel: {ChannelName} [{ChannelId}]", + usrMsg.Channel.Name, + usrMsg.Channel.Id); + + return Task.FromResult(true); + } + + if (bl.Type == BlacklistType.User && bl.ItemId == usrMsg.Author.Id) + { + Log.Information("Blocked input from blacklisted user: {UserName} [{UserId}]", + usrMsg.Author.ToString(), + usrMsg.Author.Id); + + return Task.FromResult(true); + } + } + + return Task.FromResult(false); + } + + public IReadOnlyList GetBlacklist() + => blacklist; + + public void Reload(bool publish = true) + { + using var uow = _db.GetDbContext(); + var toPublish = uow.GetTable().ToArray(); + blacklist = toPublish; + if (publish) + _pubSub.Pub(_blPubKey, toPublish); + } + + public async Task Blacklist(BlacklistType type, ulong id) + { + if (_creds.OwnerIds.Contains(id)) + return; + + await using var uow = _db.GetDbContext(); + + await uow + .GetTable() + .InsertAsync(() => new() + { + ItemId = id, + Type = type, + }); + + if (type == BlacklistType.User) + { + await uow.GetTable() + .Where(x => x.UserId == id) + .UpdateAsync(_ => new() + { + CurrencyAmount = 0 + }); + } + + Reload(); + } + + public async Task UnBlacklist(BlacklistType type, ulong id) + { + await using var uow = _db.GetDbContext(); + await uow.GetTable() + .Where(bi => bi.ItemId == id && bi.Type == type) + .DeleteAsync(); + + Reload(); + } + + public async Task BlacklistUsers(IReadOnlyCollection toBlacklist) + { + await using var uow = _db.GetDbContext(); + var bc = uow.GetTable(); + await bc.BulkCopyAsync(toBlacklist.Select(uid => new BlacklistEntry + { + ItemId = uid, + Type = BlacklistType.User + })); + + var blList = toBlacklist.ToList(); + await uow.GetTable() + .Where(x => blList.Contains(x.UserId)) + .UpdateAsync(_ => new() + { + CurrencyAmount = 0 + }); + + Reload(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs new file mode 100644 index 0000000..ed57096 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs @@ -0,0 +1,184 @@ +using CommandLine; +using Ellie.Common.Marmalade; + +namespace EllieBot.Common; + +public sealed class CommandsUtilityService : ICommandsUtilityService, IEService +{ + private readonly CommandHandler _ch; + private readonly IBotStrings _strings; + private readonly DiscordPermOverrideService _dpos; + private readonly IMessageSenderService _sender; + private readonly ILocalization _loc; + private readonly IMarmaladeLoaderService _marmalades; + + public CommandsUtilityService( + CommandHandler ch, + IBotStrings strings, + DiscordPermOverrideService dpos, + IMessageSenderService sender, + ILocalization loc, + IMarmaladeLoaderService marmalades) + { + _ch = ch; + _strings = strings; + _dpos = dpos; + _sender = sender; + _loc = loc; + _marmalades = marmalades; + } + + public EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild) + { + var prefix = _ch.GetPrefix(guild); + + var str = $"**`{prefix + com.Aliases.First()}`**"; + var alias = com.Aliases.Skip(1).FirstOrDefault(); + if (alias is not null) + str += $" **| `{prefix + alias}`**"; + + var culture = _loc.GetCultureInfo(guild); + + var em = _sender.CreateEmbed() + .AddField(str, $"{com.RealSummary(_strings, _marmalades, culture, prefix)}", true); + + _dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides); + var reqs = GetCommandRequirements(com, (GuildPermission?)overrides); + if (reqs.Any()) + em.AddField(GetText(strs.requires, guild), string.Join("\n", reqs)); + + var paramList = _strings.GetCommandStrings(com.Name, culture)?.Params; + em + .WithOkColor() + .AddField(_strings.GetText(strs.usage), + string.Join("\n", com.RealRemarksArr(_strings, _marmalades, culture, prefix).Map(arg => Format.Code(arg)))) + .WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild)); + + if (paramList is not null and not []) + { + var pl = paramList + .Select(x => Format.Code($"{prefix}{com.Name} {x.Keys.Select(y => $"<{y}>").Join(' ')}")) + .Join('\n'); + + em.AddField(GetText(strs.overloads, guild), pl); + } + + var opt = GetEllieOptionType(com.Attributes); + if (opt is not null) + { + var hs = GetCommandOptionHelp(opt); + if (!string.IsNullOrWhiteSpace(hs)) + em.AddField(GetText(strs.options, guild), hs); + } + + return em; + } + + public static string GetCommandOptionHelp(Type opt) + { + var strs = GetCommandOptionHelpList(opt); + + return string.Join("\n", strs); + } + + public static List GetCommandOptionHelpList(Type opt) + { + var strs = opt.GetProperties() + .Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute)) + .Where(x => x is not null) + .Cast() + .Select(x => + { + var toReturn = $"`--{x.LongName}`"; + + if (!string.IsNullOrWhiteSpace(x.ShortName)) + toReturn += $" (`-{x.ShortName}`)"; + + toReturn += $" {x.HelpText} "; + return toReturn; + }) + .ToList(); + + return strs; + } + + public static Type? GetEllieOptionType(IEnumerable attributes) + => attributes + .Select(a => a.GetType()) + .Where(a => a.IsGenericType + && a.GetGenericTypeDefinition() == typeof(EllieOptionsAttribute<>)) + .Select(a => a.GenericTypeArguments[0]) + .FirstOrDefault(); + + public static string[] GetCommandRequirements(CommandInfo cmd, GuildPerm? overrides = null) + { + var toReturn = new List(); + + if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute)) + toReturn.Add("Bot Owner Only"); + + if (cmd.Preconditions.Any(x => x is NoPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is NoPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is NoPublicBotAttribute)) + toReturn.Add("No Public Bot"); + + if (cmd.Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module + .Preconditions + .Any(x => x is OnlyPublicBotAttribute) + || cmd.Module.GetTopLevelModule() + .Preconditions + .Any(x => x is OnlyPublicBotAttribute)) + toReturn.Add("Only Public Bot"); + + var userPermString = cmd.Preconditions + .Where(ca => ca is UserPermAttribute) + .Cast() + .Select(userPerm => + { + if (userPerm.ChannelPermission is { } cPerm) + return GetPreconditionString(cPerm); + + if (userPerm.GuildPermission is { } gPerm) + return GetPreconditionString(gPerm); + + return string.Empty; + }) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Join('\n'); + + if (overrides is null) + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(userPermString); + } + else + { + if (!string.IsNullOrWhiteSpace(userPermString)) + toReturn.Add(Format.Strikethrough(userPermString)); + + toReturn.Add(GetPreconditionString(overrides.Value)); + } + + return toReturn.ToArray(); + } + + public static string GetPreconditionString(ChannelPerm perm) + => (perm + " Channel Permission").Replace("Guild", "Server"); + + public static string GetPreconditionString(GuildPerm perm) + => (perm + " Server Permission").Replace("Guild", "Server"); + + public string GetText(LocStr str, IGuild? guild) + => _strings.GetText(str, guild?.Id); +} + +public interface ICommandsUtilityService +{ + EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs new file mode 100644 index 0000000..2eb7093 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs @@ -0,0 +1,136 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public class DiscordPermOverrideService : IEService, IExecPreCommand, IDiscordPermOverrideService +{ + public int Priority { get; } = int.MaxValue; + private readonly DbService _db; + private readonly IServiceProvider _services; + + private readonly ConcurrentDictionary<(ulong, string), DiscordPermOverride> _overrides; + + public DiscordPermOverrideService(DbService db, IServiceProvider services) + { + _db = db; + _services = services; + using var uow = _db.GetDbContext(); + _overrides = uow.Set() + .AsNoTracking() + .AsEnumerable() + .ToDictionary(o => (o.GuildId ?? 0, o.Command), o => o) + .ToConcurrent(); + } + + public bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm) + { + commandName = commandName.ToLowerInvariant(); + if (_overrides.TryGetValue((guildId, commandName), out var dpo)) + { + perm = dpo.Perm; + return true; + } + + perm = null; + return false; + } + + public Task ExecuteOverrides( + ICommandContext ctx, + CommandInfo command, + GuildPerm perms, + IServiceProvider services) + { + var rupa = new RequireUserPermissionAttribute(perms); + return rupa.CheckPermissionsAsync(ctx, command, services); + } + + public async Task AddOverride(ulong guildId, string commandName, GuildPerm perm) + { + commandName = commandName.ToLowerInvariant(); + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command); + + if (over is null) + { + uow.Set() + .Add(over = new() + { + Command = commandName, + Perm = (EllieBot.Db.GuildPerm)perm, + GuildId = guildId + }); + } + else + over.Perm = (EllieBot.Db.GuildPerm)perm; + + _overrides[(guildId, commandName)] = over; + + await uow.SaveChangesAsync(); + } + + public async Task ClearAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var overrides = await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + + uow.RemoveRange(overrides); + await uow.SaveChangesAsync(); + + foreach (var over in overrides) + _overrides.TryRemove((guildId, over.Command), out _); + } + + public async Task RemoveOverride(ulong guildId, string commandName) + { + commandName = commandName.ToLowerInvariant(); + + await using var uow = _db.GetDbContext(); + var over = await uow.Set() + .AsQueryable() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.GuildId == guildId && x.Command == commandName); + + if (over is null) + return; + + uow.Remove(over); + await uow.SaveChangesAsync(); + + _overrides.TryRemove((guildId, commandName), out _); + } + + public async Task> GetAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null) + { + var result = + await new RequireUserPermissionAttribute((GuildPermission)perm).CheckPermissionsAsync(context, + command, + _services); + + return !result.IsSuccess; + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/FontProvider.cs b/src/EllieBot/_common/Services/Impl/FontProvider.cs new file mode 100644 index 0000000..543f23c --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/FontProvider.cs @@ -0,0 +1,60 @@ +#nullable disable +using SixLabors.Fonts; + +namespace EllieBot.Services; + +public class FontProvider : IEService +{ + public FontFamily DottyFont { get; } + + public FontFamily UniSans { get; } + + public FontFamily NotoSans { get; } + //public FontFamily Emojis { get; } + + /// + /// Font used for .rip command + /// + public Font RipFont { get; } + + public List FallBackFonts { get; } + private readonly FontCollection _fonts; + + public FontProvider() + { + _fonts = new(); + + NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf"); + UniSans = _fonts.Add("data/fonts/Uni Sans.ttf"); + + FallBackFonts = new(); + + //FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf")); + + // try loading some emoji and jap fonts on windows as fallback fonts + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + try + { + var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc"))); + } + catch { } + } + + // any fonts present in data/fonts should be added as fallback fonts + // this will allow support for special characters when drawing text + foreach (var font in Directory.GetFiles(@"data/fonts")) + { + if (font.EndsWith(".ttf")) + FallBackFonts.Add(_fonts.Add(font)); + else if (font.EndsWith(".ttc")) + FallBackFonts.AddRange(_fonts.AddCollection(font)); + } + + RipFont = NotoSans.CreateFont(20, FontStyle.Bold); + DottyFont = FallBackFonts.First(x => x.Name == "dotty"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/IImageCache.cs b/src/EllieBot/_common/Services/Impl/IImageCache.cs new file mode 100644 index 0000000..8c8ff32 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/IImageCache.cs @@ -0,0 +1,17 @@ +namespace EllieBot.Services; + +public interface IImageCache +{ + Task GetHeadsImageAsync(); + Task GetTailsImageAsync(); + Task GetCurrencyImageAsync(); + Task GetXpBackgroundImageAsync(); + Task GetRategirlBgAsync(); + Task GetRategirlDotAsync(); + Task GetDiceAsync(int num); + Task GetSlotEmojiAsync(int number); + Task GetSlotBgAsync(); + Task GetRipBgAsync(); + Task GetRipOverlayAsync(); + Task GetImageDataAsync(Uri url); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/ImagesConfig.cs b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs new file mode 100644 index 0000000..821d717 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs @@ -0,0 +1,19 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Services; + +public sealed class ImagesConfig : ConfigServiceBase +{ + private const string PATH = "data/images.yml"; + + private static readonly TypedKey _changeKey = + new("config.images.updated"); + + public override string Name + => "images"; + + public ImagesConfig(IConfigSeria serializer, IPubSub pubSub) + : base(PATH, serializer, pubSub, _changeKey) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs new file mode 100644 index 0000000..79d501b --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Services; + +public static class RedisImageExtensions +{ + private const string OLD_CDN_URL = "nadeko-pictures.nyc3.digitaloceanspaces.com"; + private const string NEW_CDN_URL = "cdn.nadeko.bot"; + + public static Uri ToNewCdn(this Uri uri) + => new(uri.ToString().Replace(OLD_CDN_URL, NEW_CDN_URL)); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs new file mode 100644 index 0000000..0784fc7 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs @@ -0,0 +1,58 @@ +#nullable disable +using System.Diagnostics; + +namespace EllieBot.Services; + +public class SingleProcessCoordinator : ICoordinator +{ + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + + public SingleProcessCoordinator(IBotCredentials creds, DiscordSocketClient client) + { + _creds = creds; + _client = client; + } + + public bool RestartBot() + { + if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd) + || string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args)) + { + Log.Error("You must set RestartCommand.Cmd and RestartCommand.Args in creds.yml"); + return false; + } + + Process.Start(_creds.RestartCommand.Cmd, _creds.RestartCommand.Args); + _ = Task.Run(async () => + { + await Task.Delay(2000); + Die(); + }); + return true; + } + + public void Die(bool graceful = false) + => Environment.Exit(5); + + public bool RestartShard(int shardId) + => RestartBot(); + + public IList GetAllShardStatuses() + => new[] + { + new ShardStatus + { + ConnectionState = _client.ConnectionState, + GuildCount = _client.Guilds.Count, + LastUpdate = DateTime.UtcNow, + ShardId = _client.ShardId + } + }; + + public int GetGuildCount() + => _client.Guilds.Count; + + public Task Reload() + => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs new file mode 100644 index 0000000..3d102cc --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs @@ -0,0 +1,18 @@ +#nullable disable +using System.Collections; + +namespace EllieBot.Services; + +public class StartingGuildsService : IEnumerable, IEService +{ + private readonly IReadOnlyList _guilds; + + public StartingGuildsService(DiscordSocketClient client) + => _guilds = client.Guilds.Select(x => x.Id).ToList(); + + public IEnumerator GetEnumerator() + => _guilds.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => _guilds.GetEnumerator(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/Impl/StatsService.cs b/src/EllieBot/_common/Services/Impl/StatsService.cs new file mode 100644 index 0000000..ef7ea80 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/StatsService.cs @@ -0,0 +1,222 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using System.Diagnostics; + +namespace EllieBot.Services; + +public sealed class StatsService : IStatsService, IReadyExecutor, IEService +{ + public static string BotVersion + => typeof(Bot).Assembly.GetName().Version?.ToString(3) ?? "Custom"; + + public string Author + => "toastie_t0ast"; + + public double MessagesPerSecond + => MessageCounter / GetUptime().TotalSeconds; + + public long TextChannels + => Interlocked.Read(ref textChannels); + + public long VoiceChannels + => Interlocked.Read(ref voiceChannels); + + public long MessageCounter + => Interlocked.Read(ref messageCounter); + + public long CommandsRan + => Interlocked.Read(ref commandsRan); + + private readonly Process _currentProcess = Process.GetCurrentProcess(); + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DateTime _started; + + private long textChannels; + private long voiceChannels; + private long messageCounter; + private long commandsRan; + + private readonly IHttpClientFactory _httpFactory; + + public StatsService( + DiscordSocketClient client, + CommandHandler cmdHandler, + IBotCredentials creds, + IHttpClientFactory factory) + { + _client = client; + _creds = creds; + _httpFactory = factory; + + _started = DateTime.UtcNow; + _client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter)); + cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan)); + + _client.ChannelCreated += c => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Increment(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Increment(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.ChannelDestroyed += c => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Decrement(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Decrement(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.GuildAvailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.JoinedGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.GuildUnavailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + + _client.LeftGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + } + + private void InitializeChannelCount() + { + var guilds = _client.Guilds; + textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel)); + voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel)); + } + + public async Task OnReadyAsync() + { + InitializeChannelCount(); + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + do + { + if (string.IsNullOrWhiteSpace(_creds.BotListToken)) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "shard_count", _creds.TotalShards.ToString() }, + { "shard_id", _client.ShardId.ToString() }, + { "server_count", _client.Guilds.Count().ToString() } + }); + content.Headers.Clear(); + content.Headers.Add("Content-Type", "application/x-www-form-urlencoded"); + http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken); + + using var res = await http.PostAsync( + new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"), + content); + } + catch (Exception ex) + { + Log.Error(ex, "Error in botlist post"); + } + } while (await timer.WaitForNextTickAsync()); + } + + public TimeSpan GetUptime() + => DateTime.UtcNow - _started; + + public string GetUptimeString(string separator = ", ") + { + var time = GetUptime(); + + if (time.Days > 0) + return $"{time.Days}d {time.Hours}h {time.Minutes}m"; + + if (time.Hours > 0) + return $"{time.Hours}h {time.Minutes}m"; + + if (time.Minutes > 0) + return $"{time.Minutes}m {time.Seconds}s"; + + return $"{time.Seconds}s"; + } + + public double GetPrivateMemoryMegabytes() + { + _currentProcess.Refresh(); + return _currentProcess.PrivateMemorySize64 / 1.Megabytes(); + } + + public GuildInfo GetGuildInfo(string name) + => throw new NotImplementedException(); + + public GuildInfo GetGuildInfo(ulong id) + { + var g = _client.GetGuild(id); + + return new GuildInfo() + { + Id = g.Id, + IconUrl = g.IconUrl, + Name = g.Name, + Owner = g.Owner.Username, + OwnerId = g.OwnerId, + CreatedAt = g.CreatedAt.UtcDateTime, + VoiceChannels = g.VoiceChannels.Count, + TextChannels = g.TextChannels.Count, + Features = g.Features.Value.ToString().Split(","), + Emojis = g.Emotes.ToArray(), + Roles = g.Roles.OrderByDescending(x => x.Position).ToArray(), + MemberCount = g.MemberCount, + }; + } +} diff --git a/src/EllieBot/_common/Services/Impl/YtdlOperation.cs b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs new file mode 100644 index 0000000..f302ab7 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs @@ -0,0 +1,77 @@ +#nullable disable +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace EllieBot.Services; + +public class YtdlOperation +{ + private readonly string _baseArgString; + private readonly bool _isYtDlp; + + public YtdlOperation(string baseArgString, bool isYtDlp = false) + { + _baseArgString = baseArgString; + _isYtDlp = isYtDlp; + } + + private Process CreateProcess(string[] args) + { + var newArgs = args.Map(arg => (object)arg.Replace("\"", "")); + return new() + { + StartInfo = new() + { + FileName = _isYtDlp ? "yt-dlp" : "youtube-dl", + Arguments = string.Format(_baseArgString, newArgs), + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + } + }; + } + + public async Task GetDataAsync(params string[] args) + { + try + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + var str = await process.StandardOutput.ReadToEndAsync(); + var err = await process.StandardError.ReadToEndAsync(); + if (!string.IsNullOrEmpty(err)) + Log.Warning("YTDL warning: {YtdlWarning}", err); + + return str; + } + catch (Win32Exception) + { + Log.Error("youtube-dl is likely not installed. " + "Please install it before running the command again"); + return default; + } + catch (Exception ex) + { + Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message); + return default; + } + } + + public async IAsyncEnumerable EnumerateDataAsync(params string[] args) + { + using var process = CreateProcess(args); + + Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments); + process.Start(); + + string line; + while ((line = await process.StandardOutput.ReadLineAsync()) is not null) + yield return line; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/BotStrings.cs b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs new file mode 100644 index 0000000..45bbd6e --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs @@ -0,0 +1,102 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Services; + +public class BotStrings : IBotStrings +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + private readonly CultureInfo _usCultureInfo = new("en-US"); + + private readonly ILocalization _localization; + private readonly IBotStringsProvider _stringsProvider; + + public BotStrings(ILocalization loc, IBotStringsProvider stringsProvider) + { + _localization = loc; + _stringsProvider = stringsProvider; + } + + private string GetString(string key, CultureInfo cultureInfo) + => _stringsProvider.GetText(cultureInfo.Name, key); + + public string GetText(string key, ulong? guildId = null, params object[] data) + => GetText(key, _localization.GetCultureInfo(guildId), data); + + public string GetText(string key, CultureInfo cultureInfo) + { + var text = GetString(key, cultureInfo); + + if (string.IsNullOrWhiteSpace(text)) + { + Log.Warning("'{Key}' key is missing from '{LanguageName}' response strings. You may ignore this message", + key, + cultureInfo.Name); + text = GetString(key, _usCultureInfo) ?? $"Error: dkey {key} not found!"; + if (string.IsNullOrWhiteSpace(text)) + { + return + "I can't tell you if the command is executed, because there was an error printing out the response." + + $" Key '{key}' is missing from resources. You may ignore this message."; + } + } + + return text; + } + + public string GetText(string key, CultureInfo cultureInfo, params object[] data) + { + try + { + return string.Format(GetText(key, cultureInfo), data); + } + catch (FormatException) + { + Log.Warning( + " Key '{Key}' is not properly formatted in '{LanguageName}' response strings. Please report this", + key, + cultureInfo.Name); + if (cultureInfo.Name != _usCultureInfo.Name) + return GetText(key, _usCultureInfo, data); + return + "I can't tell you if the command is executed, because there was an error printing out the response.\n" + + $"Key '{key}' is not properly formatted. Please report this."; + } + } + + public CommandStrings GetCommandStrings(string commandName, ulong? guildId = null) + => GetCommandStrings(commandName, _localization.GetCultureInfo(guildId)); + + public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo) + { + var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName); + if (cmdStrings is null) + { + if (cultureInfo.Name == _usCultureInfo.Name) + { + Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings. Please report this", + commandName); + + return new CommandStrings() + { + Examples = [""], + Desc = "?", + Params = [] + }; + } + +// Log.Warning(@"'{CommandName}' command strings don't exist in '{LanguageName}' culture. +// This message is safe to ignore, however you can ask in Ellie support server how you can contribute command translations", +// commandName, cultureInfo.Name); + + return GetCommandStrings(commandName, _usCultureInfo); + } + + return cmdStrings; + } + + public void Reload() + => _stringsProvider.Reload(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs new file mode 100644 index 0000000..12b6ba9 --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs @@ -0,0 +1,73 @@ +#nullable disable +using Newtonsoft.Json; +using YamlDotNet.Serialization; + +namespace EllieBot.Services; + +/// +/// Loads strings from the local default filepath +/// +public class LocalFileStringsSource : IStringsSource +{ + private readonly string _responsesPath = "data/strings/responses"; + private readonly string _commandsPath = "data/strings/commands"; + + public LocalFileStringsSource( + string responsesPath = "data/strings/responses", + string commandsPath = "data/strings/commands") + { + _responsesPath = responsesPath; + _commandsPath = commandsPath; + } + + public Dictionary> GetResponseStrings() + { + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_responsesPath)) + { + try + { + var langDict = JsonConvert.DeserializeObject>(File.ReadAllText(file)); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + public Dictionary> GetCommandStrings() + { + var deserializer = new DeserializerBuilder().Build(); + + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_commandsPath)) + { + try + { + var text = File.ReadAllText(file); + var langDict = deserializer.Deserialize>(text); + var localeName = GetLocaleName(file); + outputDict[localeName] = langDict; + } + catch (Exception ex) + { + Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message); + } + } + + return outputDict; + } + + private static string GetLocaleName(string fileName) + { + fileName = Path.GetFileName(fileName); + var dotIndex = fileName.IndexOf('.') + 1; + var secondDotIndex = fileName.LastIndexOf('.'); + return fileName.Substring(dotIndex, secondDotIndex - dotIndex); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs new file mode 100644 index 0000000..6676dd8 --- /dev/null +++ b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Services; + +public class MemoryBotStringsProvider : IBotStringsProvider +{ + private readonly IStringsSource _source; + private IReadOnlyDictionary> responseStrings; + private IReadOnlyDictionary> commandStrings; + + public MemoryBotStringsProvider(IStringsSource source) + { + _source = source; + Reload(); + } + + public string GetText(string localeName, string key) + { + if (responseStrings.TryGetValue(localeName, out var langStrings) && langStrings.TryGetValue(key, out var text)) + return text; + + return null; + } + + public void Reload() + { + responseStrings = _source.GetResponseStrings(); + commandStrings = _source.GetCommandStrings(); + } + + public CommandStrings GetCommandStrings(string localeName, string commandName) + { + if (commandStrings.TryGetValue(localeName, out var langStrings) + && langStrings.TryGetValue(commandName, out var strings)) + return strings; + + return null; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/BotConfigService.cs b/src/EllieBot/_common/Settings/BotConfigService.cs new file mode 100644 index 0000000..952f402 --- /dev/null +++ b/src/EllieBot/_common/Settings/BotConfigService.cs @@ -0,0 +1,73 @@ +#nullable disable +using EllieBot.Common.Configs; +using SixLabors.ImageSharp.PixelFormats; + +namespace EllieBot.Services; + +/// +/// Settings service for bot-wide configuration. +/// +public sealed class BotConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/bot.yml"; + private static readonly TypedKey _changeKey = new("config.bot.updated"); + public override string Name { get; } = "bot"; + + public BotConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("color.ok", bs => bs.Color.Ok, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.error", bs => bs.Color.Error, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("color.pending", bs => bs.Color.Pending, Rgba32.TryParseHex, ConfigPrinters.Color); + AddParsedProp("help.text", bs => bs.HelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("help.dmtext", bs => bs.DmHelpText, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("console.type", bs => bs.ConsoleOutputType, Enum.TryParse, ConfigPrinters.ToString); + AddParsedProp("locale", bs => bs.DefaultLocale, ConfigParsers.Culture, ConfigPrinters.Culture); + AddParsedProp("prefix", bs => bs.Prefix, ConfigParsers.String, ConfigPrinters.ToString); + AddParsedProp("checkforupdates", bs => bs.CheckForUpdates, bool.TryParse, ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 2) + ModifyConfig(c => c.Version = 2); + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.Blocked.Modules = c.Blocked.Modules?.Select(static x + => string.Equals(x, + "ActualCustomReactions", + StringComparison.InvariantCultureIgnoreCase) + ? "ACTUALEXPRESSIONS" + : x) + .Distinct() + .ToHashSet(); + }); + } + + if (data.Version < 4) + ModifyConfig(c => + { + c.Version = 4; + c.CheckForUpdates = true; + }); + + if (data.Version < 5) + ModifyConfig(c => + { + c.Version = 5; + }); + + if (data.Version < 7) + ModifyConfig(c => + { + c.Version = 7; + c.IgnoreOtherBots = true; + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/ConfigParsers.cs b/src/EllieBot/_common/Settings/ConfigParsers.cs new file mode 100644 index 0000000..c32b7ca --- /dev/null +++ b/src/EllieBot/_common/Settings/ConfigParsers.cs @@ -0,0 +1,50 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; + +namespace EllieBot.Services; + +/// +/// Custom setting value parsers for types which don't have them by default +/// +public static class ConfigParsers +{ + /// + /// Default string parser. Passes input to output and returns true. + /// + public static bool String(string input, out string output) + { + output = input; + return true; + } + + public static bool Culture(string input, out CultureInfo output) + { + try + { + output = new(input); + return true; + } + catch + { + output = null; + return false; + } + } + + public static bool InsensitiveEnum(string input, out T output) + where T : struct + => Enum.TryParse(input, true, out output); +} + +public static class ConfigPrinters +{ + public static string ToString(TAny input) + => input.ToString(); + + public static string Culture(CultureInfo culture) + => culture.Name; + + public static string Color(Rgba32 color) + => ((uint)((color.B << 0) | (color.G << 8) | (color.R << 16))).ToString("X6"); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/ConfigServiceBase.cs b/src/EllieBot/_common/Settings/ConfigServiceBase.cs new file mode 100644 index 0000000..bdf76d8 --- /dev/null +++ b/src/EllieBot/_common/Settings/ConfigServiceBase.cs @@ -0,0 +1,201 @@ +using EllieBot.Common.Configs; +using EllieBot.Common.Yml; +using System.Linq.Expressions; +using System.Reflection; + +namespace EllieBot.Services; + +/// +/// Base service for all settings services +/// +/// Type of the settings +public abstract class ConfigServiceBase : IConfigService + where TSettings : ICloneable, new() +{ + // FUTURE config arrays are not copied - they're not protected from mutations + public TSettings Data + => data.Clone(); + + public abstract string Name { get; } + protected readonly string _filePath; + protected readonly IConfigSeria _serializer; + protected readonly IPubSub _pubSub; + private readonly TypedKey _changeKey; + + protected TSettings data; + + private readonly Dictionary> _propSetters = new(); + private readonly Dictionary> _propSelectors = new(); + private readonly Dictionary> _propPrinters = new(); + private readonly Dictionary _propComments = new(); + + /// + /// Initialized an instance of + /// + /// Path to the file where the settings are serialized/deserialized to and from + /// Serializer which will be used + /// Pubsub implementation for signaling when settings are updated + /// Key used to signal changed event + protected ConfigServiceBase( + string filePath, + IConfigSeria serializer, + IPubSub pubSub, + TypedKey changeKey) + { + _filePath = filePath; + _serializer = serializer; + _pubSub = pubSub; + _changeKey = changeKey; + + data = new(); + Load(); + _pubSub.Sub(_changeKey, OnChangePublished); + } + + private void PublishChange() + => _pubSub.Pub(_changeKey, data); + + private ValueTask OnChangePublished(TSettings newData) + { + data = newData; + OnStateUpdate(); + return default; + } + + /// + /// Loads data from disk. If file doesn't exist, it will be created with default values + /// + protected void Load() + { + // if file is deleted, regenerate it with default values + if (!File.Exists(_filePath)) + { + data = new(); + Save(); + } + + data = _serializer.Deserialize(File.ReadAllText(_filePath)); + } + + /// + /// Loads new data and publishes the new state + /// + public void Reload() + { + Load(); + _pubSub.Pub(_changeKey, data); + } + + /// + /// Doesn't do anything by default. This method will be executed after + /// is reloaded from or new data is recieved + /// from the publish event + /// + protected virtual void OnStateUpdate() + { + } + + private void Save() + { + var strData = _serializer.Serialize(data); + File.WriteAllText(_filePath, strData); + } + + protected void AddParsedProp( + string key, + Expression> selector, + SettingParser parser, + Func printer, + Func? checker = null) + { + checker ??= _ => true; + key = key.ToLowerInvariant(); + _propPrinters[key] = obj => printer((TProp)obj); + _propSelectors[key] = () => selector.Compile()(data)!; + _propSetters[key] = Magic(selector, parser, checker); + _propComments[key] = ((MemberExpression)selector.Body).Member.GetCustomAttribute()?.Comment; + } + + private Func Magic( + Expression> selector, + SettingParser parser, + Func checker) + => (target, input) => + { + if (!parser(input, out var value)) + return false; + + if (!checker(value)) + return false; + + object targetObject = target; + var expr = (MemberExpression)selector.Body; + var prop = (PropertyInfo)expr.Member; + + var expressions = new List(); + + while (true) + { + expr = expr.Expression as MemberExpression; + if (expr is null) + break; + + expressions.Add(expr); + } + + foreach (var memberExpression in expressions.AsEnumerable().Reverse()) + { + var localProp = (PropertyInfo)memberExpression.Member; + targetObject = localProp.GetValue(targetObject)!; + } + + prop.SetValue(targetObject, value, null); + return true; + }; + + public IReadOnlyList GetSettableProps() + => _propSetters.Keys.ToList(); + + public string? GetSetting(string prop) + { + prop = prop.ToLowerInvariant(); + if (!_propSelectors.TryGetValue(prop, out var selector) || !_propPrinters.TryGetValue(prop, out var printer)) + return null; + + return printer(selector()); + } + + public string? GetComment(string prop) + { + if (_propComments.TryGetValue(prop, out var comment)) + return comment; + + return null; + } + + private bool SetProperty(TSettings target, string key, string value) + => _propSetters.TryGetValue(key.ToLowerInvariant(), out var magic) && magic(target, value); + + public bool SetSetting(string prop, string newValue) + { + var success = true; + ModifyConfig(bs => + { + success = SetProperty(bs, prop, newValue); + }); + + if (success) + PublishChange(); + + return success; + } + + public void ModifyConfig(Action action) + { + var copy = Data; + action(copy); + data = copy; + Save(); + PublishChange(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/IConfigService.cs b/src/EllieBot/_common/Settings/IConfigService.cs new file mode 100644 index 0000000..69ac966 --- /dev/null +++ b/src/EllieBot/_common/Settings/IConfigService.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Interface that all services which deal with configs should implement +/// +public interface IConfigService +{ + /// + /// Name of the config + /// + public string Name { get; } + + /// + /// Loads new data and publishes the new state + /// + void Reload(); + + /// + /// Gets the list of props you can set + /// + /// List of props + IReadOnlyList GetSettableProps(); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetSetting(string prop); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetComment(string prop); + + /// + /// Sets the value of the specified property + /// + /// Property to set + /// Value to set the property to + /// Success + bool SetSetting(string prop, string newValue); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Settings/SettingParser.cs b/src/EllieBot/_common/Settings/SettingParser.cs new file mode 100644 index 0000000..06a8e75 --- /dev/null +++ b/src/EllieBot/_common/Settings/SettingParser.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Delegate which describes a parser which can convert string input into given data type +/// +/// Data type to convert string to +public delegate bool SettingParser(string input, out TData output); \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartEmbedText.cs b/src/EllieBot/_common/SmartText/SmartEmbedText.cs new file mode 100644 index 0000000..b52c0cb --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartEmbedText.cs @@ -0,0 +1,184 @@ +#nullable disable warnings +using SixLabors.ImageSharp.PixelFormats; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public sealed record SmartEmbedArrayElementText : SmartEmbedTextBase +{ + public string Color { get; init; } = string.Empty; + + public SmartEmbedArrayElementText() + { + + } + + public SmartEmbedArrayElementText(IEmbed eb) : base(eb) + { + Color = eb.Color is { } c ? new Rgba32(c.R, c.G, c.B).ToHex() : string.Empty; + } + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + if (Rgba32.TryParseHex(Color, out var color)) + return embed.WithColor(color.ToDiscordColor()); + + return embed; + } +} + +public sealed record SmartEmbedText : SmartEmbedTextBase +{ + public string? PlainText { get; init; } + + public uint Color { get; init; } = 7458112; + + public SmartEmbedText() + { + } + + private SmartEmbedText(IEmbed eb, string? plainText = null) + : base(eb) + => (PlainText, Color) = (plainText, eb.Color?.RawValue ?? 0); + + public static SmartEmbedText FromEmbed(IEmbed eb, string? plainText = null) + => new(eb, plainText); + + protected override EmbedBuilder GetEmbedInternal() + { + var embed = base.GetEmbedInternal(); + return embed.WithColor(Color); + } +} + +public abstract record SmartEmbedTextBase : SmartText +{ + public string? Title { get; init; } + public string? Description { get; init; } + public string? Url { get; init; } + public string? Thumbnail { get; init; } + public string? Image { get; init; } + + public SmartTextEmbedAuthor? Author { get; init; } + public SmartTextEmbedFooter? Footer { get; init; } + public SmartTextEmbedField[]? Fields { get; init; } + + [JsonIgnore] + public bool IsValid + => !string.IsNullOrWhiteSpace(Title) + || !string.IsNullOrWhiteSpace(Description) + || !string.IsNullOrWhiteSpace(Url) + || !string.IsNullOrWhiteSpace(Thumbnail) + || !string.IsNullOrWhiteSpace(Image) + || (Footer is not null + && (!string.IsNullOrWhiteSpace(Footer.Text) || !string.IsNullOrWhiteSpace(Footer.IconUrl))) + || Fields is { Length: > 0 }; + + protected SmartEmbedTextBase() + { + + } + + protected SmartEmbedTextBase(IEmbed eb) + { + Title = eb.Title; + Description = eb.Description; + Url = eb.Url; + Thumbnail = eb.Thumbnail?.Url; + Image = eb.Image?.Url; + Author = eb.Author is { } ea + ? new() + { + Name = ea.Name, + Url = ea.Url, + IconUrl = ea.IconUrl + } + : null; + Footer = eb.Footer is { } ef + ? new() + { + Text = ef.Text, + IconUrl = ef.IconUrl + } + : null; + + if (eb.Fields.Length > 0) + { + Fields = eb.Fields.Select(field + => new SmartTextEmbedField + { + Inline = field.Inline, + Name = field.Name, + Value = field.Value + }) + .ToArray(); + } + } + + public EmbedBuilder GetEmbed() + => GetEmbedInternal(); + + protected virtual EmbedBuilder GetEmbedInternal() + { + var embed = new EmbedBuilder(); + + if (!string.IsNullOrWhiteSpace(Title)) + embed.WithTitle(Title); + + if (!string.IsNullOrWhiteSpace(Description)) + embed.WithDescription(Description); + + if (Url is not null && Uri.IsWellFormedUriString(Url, UriKind.Absolute)) + embed.WithUrl(Url); + + if (Footer is not null) + { + embed.WithFooter(efb => + { + efb.WithText(Footer.Text); + if (Uri.IsWellFormedUriString(Footer.IconUrl, UriKind.Absolute)) + efb.WithIconUrl(Footer.IconUrl); + }); + } + + if (Thumbnail is not null && Uri.IsWellFormedUriString(Thumbnail, UriKind.Absolute)) + embed.WithThumbnailUrl(Thumbnail); + + if (Image is not null && Uri.IsWellFormedUriString(Image, UriKind.Absolute)) + embed.WithImageUrl(Image); + + if (Author is not null && !string.IsNullOrWhiteSpace(Author.Name)) + { + if (!Uri.IsWellFormedUriString(Author.IconUrl, UriKind.Absolute)) + Author.IconUrl = null; + if (!Uri.IsWellFormedUriString(Author.Url, UriKind.Absolute)) + Author.Url = null; + + embed.WithAuthor(Author.Name, Author.IconUrl, Author.Url); + } + + if (Fields is not null) + { + foreach (var f in Fields) + { + if (!string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value)) + embed.AddField(f.Name, f.Value, f.Inline); + } + } + + return embed; + } + + public void NormalizeFields() + { + if (Fields is { Length: > 0 }) + { + foreach (var f in Fields) + { + f.Name = f.Name.TrimTo(256); + f.Value = f.Value.TrimTo(1024); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs new file mode 100644 index 0000000..3147132 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs @@ -0,0 +1,34 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot; + +public sealed record SmartEmbedTextArray : SmartText +{ + public string Content { get; set; } + public SmartEmbedArrayElementText[] Embeds { get; set; } + + [JsonIgnore] + public bool IsValid + => Embeds?.All(x => x.IsValid) ?? false; + + public EmbedBuilder[] GetEmbedBuilders() + { + if (Embeds is null) + return Array.Empty(); + + return Embeds + .Where(x => x.IsValid) + .Select(em => em.GetEmbed()) + .ToArray(); + } + + public void NormalizeFields() + { + if (Embeds is null) + return; + + foreach(var eb in Embeds) + eb.NormalizeFields(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartPlainText.cs b/src/EllieBot/_common/SmartText/SmartPlainText.cs new file mode 100644 index 0000000..0da4a35 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartPlainText.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot; + +public sealed record SmartPlainText : SmartText +{ + public string Text { get; init; } + + public SmartPlainText(string text) + => Text = text; + + public static implicit operator SmartPlainText(string input) + => new(input); + + public static implicit operator string(SmartPlainText input) + => input.Text; + + public override string ToString() + => Text; +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartText.cs b/src/EllieBot/_common/SmartText/SmartText.cs new file mode 100644 index 0000000..74aee76 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartText.cs @@ -0,0 +1,92 @@ +#nullable disable +using Newtonsoft.Json.Linq; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public abstract record SmartText +{ + [JsonIgnore] + public bool IsEmbed + => this is SmartEmbedText; + + [JsonIgnore] + public bool IsPlainText + => this is SmartPlainText; + + [JsonIgnore] + public bool IsEmbedArray + => this is SmartEmbedTextArray; + + public static implicit operator SmartText(string input) + => new SmartPlainText(input); + + public static SmartText operator +(SmartText text, string input) + => text switch + { + SmartEmbedText set => set with + { + PlainText = set.PlainText + input + }, + SmartPlainText spt => new SmartPlainText(spt.Text + input), + SmartEmbedTextArray arr => arr with + { + Content = arr.Content + input + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText operator +(string input, SmartText text) + => text switch + { + SmartEmbedText set => set with + { + PlainText = input + set.PlainText + }, + SmartPlainText spt => new SmartPlainText(input + spt.Text), + SmartEmbedTextArray arr => arr with + { + Content = input + arr.Content + }, + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static SmartText CreateFrom(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new SmartPlainText(input); + + try + { + var doc = JObject.Parse(input); + var root = doc.Root; + if (root.Type == JTokenType.Object) + { + if (((JObject)root).TryGetValue("embeds", out _)) + { + var arr = root.ToObject(); + + if (arr is null) + return new SmartPlainText(input); + + arr!.NormalizeFields(); + return arr; + } + + var obj = root.ToObject(); + + if (obj is null || !(obj.IsValid || !string.IsNullOrWhiteSpace(obj.PlainText))) + return new SmartPlainText(input); + + obj.NormalizeFields(); + return obj; + } + + return new SmartPlainText(input); + } + catch + { + return new SmartPlainText(input); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs new file mode 100644 index 0000000..9d3cc1c --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs @@ -0,0 +1,16 @@ +#nullable disable +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public class SmartTextEmbedAuthor +{ + public string Name { get; set; } + + [JsonProperty("icon_url")] + [JsonPropertyName("icon_url")] + public string IconUrl { get; set; } + + public string Url { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs new file mode 100644 index 0000000..7989b32 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot; + +public class SmartTextEmbedField +{ + public string Name { get; set; } + public string Value { get; set; } + public bool Inline { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs new file mode 100644 index 0000000..6b06223 --- /dev/null +++ b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs @@ -0,0 +1,14 @@ +#nullable disable +using Newtonsoft.Json; +using System.Text.Json.Serialization; + +namespace EllieBot; + +public class SmartTextEmbedFooter +{ + public string Text { get; set; } + + [JsonProperty("icon_url")] + [JsonPropertyName("icon_url")] + public string IconUrl { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TriviaQuestionModel.cs b/src/EllieBot/_common/TriviaQuestionModel.cs new file mode 100644 index 0000000..ddd92d3 --- /dev/null +++ b/src/EllieBot/_common/TriviaQuestionModel.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class TriviaQuestionModel +{ + public string Category { get; init; } + public string Question { get; init; } + public string ImageUrl { get; init; } + public string AnswerImageUrl { get; init; } + public string Answer { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaderResult.cs b/src/EllieBot/_common/TypeReaderResult.cs new file mode 100644 index 0000000..309ed7b --- /dev/null +++ b/src/EllieBot/_common/TypeReaderResult.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Common.TypeReaders; + +public readonly struct TypeReaderResult +{ + public bool IsSuccess + => _result.IsSuccess; + + public IReadOnlyCollection Values + => _result.Values; + + private readonly Discord.Commands.TypeReaderResult _result; + + private TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => _result = result; + + public static implicit operator TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => new(result); + + public static implicit operator Discord.Commands.TypeReaderResult(in TypeReaderResult wrapper) + => wrapper._result; +} + +public static class TypeReaderResult +{ + public static TypeReaderResult FromError(CommandError error, string reason) + => Discord.Commands.TypeReaderResult.FromError(error, reason); + + public static TypeReaderResult FromSuccess(in T value) + => Discord.Commands.TypeReaderResult.FromSuccess(value); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs new file mode 100644 index 0000000..18d9184 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs @@ -0,0 +1,23 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public class CommandOrExprInfo +{ + public enum Type + { + Normal, + Custom + } + + public string Name { get; set; } + public Type CmdType { get; set; } + + public bool IsCustom + => CmdType == Type.Custom; + + public CommandOrExprInfo(string input, Type type) + { + Name = input; + CmdType = type; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs new file mode 100644 index 0000000..e473e49 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class EmoteTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (!Emote.TryParse(input, out var emote)) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid emote")); + + return new(TypeReaderResult.FromSuccess(emote)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs new file mode 100644 index 0000000..97f9eb4 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs @@ -0,0 +1,49 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildDateTimeTypeReader : EllieTypeReader +{ + private readonly ITimezoneService _gts; + + public GuildDateTimeTypeReader(ITimezoneService gts) + => _gts = gts; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + var gdt = Parse(context.Guild.Id, input); + if (gdt is null) + { + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Input string is in an incorrect format.")); + } + + return new(TypeReaderResult.FromSuccess(gdt)); + } + + private GuildDateTime Parse(ulong guildId, string input) + { + if (!DateTime.TryParse(input, out var dt)) + return null; + + var tz = _gts.GetTimeZoneOrUtc(guildId); + + return new(tz, dt); + } +} + +public class GuildDateTime +{ + public TimeZoneInfo Timezone { get; } + public DateTime CurrentGuildTime { get; } + public DateTime InputTime { get; } + public DateTime InputTimeUtc { get; } + + public GuildDateTime(TimeZoneInfo guildTimezone, DateTime inputTime) + { + var now = DateTime.UtcNow; + Timezone = guildTimezone; + CurrentGuildTime = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Utc, Timezone); + InputTime = inputTime; + InputTimeUtc = TimeZoneInfo.ConvertTime(inputTime, Timezone, TimeZoneInfo.Utc); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs new file mode 100644 index 0000000..9a29f95 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs @@ -0,0 +1,24 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildTypeReader : EllieTypeReader +{ + private readonly DiscordSocketClient _client; + + public GuildTypeReader(DiscordSocketClient client) + => _client = client; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Trim().ToUpperInvariant(); + var guilds = _client.Guilds; + IGuild guild = guilds.FirstOrDefault(g => g.Id.ToString().Trim().ToUpperInvariant() == input) //by id + ?? guilds.FirstOrDefault(g => g.Name.Trim().ToUpperInvariant() == input); //by name + + if (guild is not null) + return new(TypeReaderResult.FromSuccess(guild)); + + return new( + TypeReaderResult.FromError(CommandError.ParseFailed, "No guild by that name or Id found")); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs new file mode 100644 index 0000000..6c4b9d6 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Common.TypeReaders; + +public sealed class GuildUserTypeReader : EllieTypeReader +{ + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (ctx.Guild is null) + return TypeReaderResult.FromError(CommandError.Unsuccessful, "Must be in a guild."); + + input = input.Trim(); + IGuildUser? user = null; + if (MentionUtils.TryParseUser(input, out var id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (ulong.TryParse(input, out id)) + user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload); + + if (user is null) + { + var users = await ctx.Guild.GetUsersAsync(CacheMode.CacheOnly); + user = users.FirstOrDefault(x => x.Username == input) + ?? users.FirstOrDefault(x => + string.Equals(x.ToString(), input, StringComparison.InvariantCultureIgnoreCase)) + ?? users.FirstOrDefault(x => + string.Equals(x.Username, input, StringComparison.InvariantCultureIgnoreCase)); + } + + if (user is null) + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + + return TypeReaderResult.FromSuccess(user); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs new file mode 100644 index 0000000..608a852 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class KwumTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (kwum.TryParse(input, out var val)) + return new(TypeReaderResult.FromSuccess(val)); + + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid kwum")); + } +} + +public sealed class SmartTextTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + => new(TypeReaderResult.FromSuccess(SmartText.CreateFrom(input))); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs new file mode 100644 index 0000000..1aec85b --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs @@ -0,0 +1,27 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders.Models; + +public class PermissionAction +{ + public static PermissionAction Enable + => new(true); + + public static PermissionAction Disable + => new(false); + + public bool Value { get; } + + public PermissionAction(bool value) + => Value = value; + + public override bool Equals(object obj) + { + if (obj is null || GetType() != obj.GetType()) + return false; + + return Value == ((PermissionAction)obj).Value; + } + + public override int GetHashCode() + => Value.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs new file mode 100644 index 0000000..08c69b8 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs @@ -0,0 +1,55 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Common.TypeReaders.Models; + +public class StoopidTime +{ + private static readonly Regex _regex = new( + @"^(?:(?\d)mo)?(?:(?\d{1,2})w)?(?:(?\d{1,2})d)?(?:(?\d{1,4})h)?(?:(?\d{1,5})m)?(?:(?\d{1,6})s)?$", + RegexOptions.Compiled | RegexOptions.Multiline); + + public string Input { get; set; } + public TimeSpan Time { get; set; } + + private StoopidTime() { } + + public static StoopidTime FromInput(string input) + { + var m = _regex.Match(input); + + if (m.Length == 0) + throw new ArgumentException("Invalid string input format."); + + var namesAndValues = new Dictionary(); + + foreach (var groupName in _regex.GetGroupNames()) + { + if (groupName == "0") + continue; + if (!int.TryParse(m.Groups[groupName].Value, out var value)) + { + namesAndValues[groupName] = 0; + continue; + } + + if (value < 1) + throw new ArgumentException($"Invalid {groupName} value."); + + namesAndValues[groupName] = value; + } + + var ts = new TimeSpan((30 * namesAndValues["months"]) + (7 * namesAndValues["weeks"]) + namesAndValues["days"], + namesAndValues["hours"], + namesAndValues["minutes"], + namesAndValues["seconds"]); + if (ts > TimeSpan.FromDays(90)) + throw new ArgumentException("Time is too long."); + + return new() + { + Input = input, + Time = ts + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs new file mode 100644 index 0000000..fbbaff0 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Modules.Permissions; + +namespace EllieBot.Common.TypeReaders; + +public sealed class ModuleTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(module)); + } +} + +public sealed class ModuleOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleOrExprTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule()) + .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input) + ?.Key; + if (module is null && input != "ACTUALEXPRESSIONS" && input != CleverBotResponseStr.CLEVERBOT_RESPONSE) + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(new ModuleOrExpr + { + Name = input + })); + } +} + +public sealed class ModuleOrExpr +{ + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs new file mode 100644 index 0000000..168cceb --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs @@ -0,0 +1,39 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; + +namespace EllieBot.Common.TypeReaders; + +/// +/// Used instead of bool for more flexible keywords for true/false only in the permission module +/// +public sealed class PermissionActionTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.ToUpperInvariant(); + switch (input) + { + case "1": + case "T": + case "TRUE": + case "ENABLE": + case "ENABLED": + case "ALLOW": + case "PERMIT": + case "UNBAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Enable)); + case "0": + case "F": + case "FALSE": + case "DENY": + case "DISABLE": + case "DISABLED": + case "DISALLOW": + case "BAN": + return new(TypeReaderResult.FromSuccess(PermissionAction.Disable)); + default: + return new(TypeReaderResult.FromError(CommandError.ParseFailed, + "Did not receive a valid boolean value")); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs new file mode 100644 index 0000000..77e7c6a --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs @@ -0,0 +1,20 @@ +using Color = SixLabors.ImageSharp.Color; + +#nullable disable +namespace EllieBot.Common.TypeReaders; + +public sealed class Rgba32TypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + input = input.Replace("#", "", StringComparison.InvariantCulture); + try + { + return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input))); + } + catch + { + return ValueTask.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Parameter is not a valid color hex.")); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs new file mode 100644 index 0000000..c5b9418 --- /dev/null +++ b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs @@ -0,0 +1,22 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; + +namespace EllieBot.Common.TypeReaders; + +public sealed class StoopidTimeTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new(TypeReaderResult.FromError(CommandError.Unsuccessful, "Input is empty.")); + try + { + var time = StoopidTime.FromInput(input); + return new(TypeReaderResult.FromSuccess(time)); + } + catch (Exception ex) + { + return new(TypeReaderResult.FromError(CommandError.Exception, ex.Message)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentAttribute.cs b/src/EllieBot/_common/Yml/CommentAttribute.cs new file mode 100644 index 0000000..7c04bea --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common.Yml; + +[AttributeUsage(AttributeTargets.Property)] +public class CommentAttribute : Attribute +{ + public string Comment { get; } + + public CommentAttribute(string comment) + => Comment = comment; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs new file mode 100644 index 0000000..f9f5739 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs @@ -0,0 +1,65 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.TypeInspectors; + +namespace EllieBot.Common.Yml; + +public class CommentGatheringTypeInspector : TypeInspectorSkeleton +{ + private readonly ITypeInspector _innerTypeDescriptor; + + public CommentGatheringTypeInspector(ITypeInspector innerTypeDescriptor) + => _innerTypeDescriptor = innerTypeDescriptor ?? throw new ArgumentNullException(nameof(innerTypeDescriptor)); + + public override IEnumerable GetProperties(Type type, object container) + => _innerTypeDescriptor.GetProperties(type, container).Select(d => new CommentsPropertyDescriptor(d)); + + private sealed class CommentsPropertyDescriptor : IPropertyDescriptor + { + public string Name { get; } + + public Type Type + => _baseDescriptor.Type; + + public Type TypeOverride + { + get => _baseDescriptor.TypeOverride; + set => _baseDescriptor.TypeOverride = value; + } + + public int Order { get; set; } + + public ScalarStyle ScalarStyle + { + get => _baseDescriptor.ScalarStyle; + set => _baseDescriptor.ScalarStyle = value; + } + + public bool CanWrite + => _baseDescriptor.CanWrite; + + private readonly IPropertyDescriptor _baseDescriptor; + + public CommentsPropertyDescriptor(IPropertyDescriptor baseDescriptor) + { + _baseDescriptor = baseDescriptor; + Name = baseDescriptor.Name; + } + + public void Write(object target, object value) + => _baseDescriptor.Write(target, value); + + public T GetCustomAttribute() + where T : Attribute + => _baseDescriptor.GetCustomAttribute(); + + public IObjectDescriptor Read(object target) + { + var comment = _baseDescriptor.GetCustomAttribute(); + return comment is not null + ? new CommentsObjectDescriptor(_baseDescriptor.Read(target), comment.Comment) + : _baseDescriptor.Read(target); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs new file mode 100644 index 0000000..ce54758 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public sealed class CommentsObjectDescriptor : IObjectDescriptor +{ + public string Comment { get; } + + public object Value + => _innerDescriptor.Value; + + public Type Type + => _innerDescriptor.Type; + + public Type StaticType + => _innerDescriptor.StaticType; + + public ScalarStyle ScalarStyle + => _innerDescriptor.ScalarStyle; + + private readonly IObjectDescriptor _innerDescriptor; + + public CommentsObjectDescriptor(IObjectDescriptor innerDescriptor, string comment) + { + _innerDescriptor = innerDescriptor; + Comment = comment; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs new file mode 100644 index 0000000..1c89a95 --- /dev/null +++ b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs @@ -0,0 +1,29 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.ObjectGraphVisitors; + +namespace EllieBot.Common.Yml; + +public class CommentsObjectGraphVisitor : ChainedObjectGraphVisitor +{ + public CommentsObjectGraphVisitor(IObjectGraphVisitor nextVisitor) + : base(nextVisitor) + { + } + + public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context) + { + if (value is CommentsObjectDescriptor commentsDescriptor + && !string.IsNullOrWhiteSpace(commentsDescriptor.Comment)) + { + var parts = commentsDescriptor.Comment.Split('\n'); + + foreach (var part in parts) + context.Emit(new Comment(part.Trim(), false)); + } + + return base.EnterMapping(key, value, context); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs new file mode 100644 index 0000000..e2cfd97 --- /dev/null +++ b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs @@ -0,0 +1,36 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; + +namespace EllieBot.Common.Yml; + + +public class MultilineScalarFlowStyleEmitter : ChainedEventEmitter +{ + public MultilineScalarFlowStyleEmitter(IEventEmitter nextEmitter) + : base(nextEmitter) + { + } + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) + { + if (typeof(string).IsAssignableFrom(eventInfo.Source.Type)) + { + var value = eventInfo.Source.Value as string; + if (!string.IsNullOrEmpty(value)) + { + var isMultiLine = value.IndexOfAny(['\r', '\n', '\x85', '\x2028', '\x2029']) >= 0; + if (isMultiLine) + { + eventInfo = new(eventInfo.Source) + { + Style = ScalarStyle.Literal + }; + } + } + } + + nextEmitter.Emit(eventInfo, emitter); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/Rgba32Converter.cs b/src/EllieBot/_common/Yml/Rgba32Converter.cs new file mode 100644 index 0000000..12f6cf9 --- /dev/null +++ b/src/EllieBot/_common/Yml/Rgba32Converter.cs @@ -0,0 +1,47 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class Rgba32Converter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Rgba32); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = Rgba32.ParseHex(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var color = (Rgba32)value; + var val = (uint)((color.B << 0) | (color.G << 8) | (color.R << 16)); + emitter.Emit(new Scalar(val.ToString("X6").ToLower())); + } +} + +public class CultureInfoConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(CultureInfo); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new CultureInfo(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var ci = (CultureInfo)value; + emitter.Emit(new Scalar(ci.Name)); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/UriConverter.cs b/src/EllieBot/_common/Yml/UriConverter.cs new file mode 100644 index 0000000..66e2ca0 --- /dev/null +++ b/src/EllieBot/_common/Yml/UriConverter.cs @@ -0,0 +1,25 @@ +#nullable disable +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace EllieBot.Common.Yml; + +public class UriConverter : IYamlTypeConverter +{ + public bool Accepts(Type type) + => type == typeof(Uri); + + public object ReadYaml(IParser parser, Type type) + { + var scalar = parser.Consume(); + var result = new Uri(scalar.Value); + return result; + } + + public void WriteYaml(IEmitter emitter, object value, Type type) + { + var uri = (Uri)value; + emitter.Emit(new Scalar(uri.ToString())); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Yml/Yaml.cs b/src/EllieBot/_common/Yml/Yaml.cs new file mode 100644 index 0000000..c4779cc --- /dev/null +++ b/src/EllieBot/_common/Yml/Yaml.cs @@ -0,0 +1,30 @@ +#nullable disable +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Common.Yml; + +public class Yaml +{ + public static ISerializer Serializer + => new SerializerBuilder() + .WithTypeInspector(inner => new CommentGatheringTypeInspector(inner)) + .DisableAliases() + .WithEmissionPhaseObjectGraphVisitor(args + => new CommentsObjectGraphVisitor(args.InnerVisitor)) + .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .Build(); + + public static IDeserializer Deserializer + => new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithTypeConverter(new Rgba32Converter()) + .WithTypeConverter(new CultureInfoConverter()) + .WithTypeConverter(new UriConverter()) + .IgnoreUnmatchedProperties() + .Build(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs new file mode 100644 index 0000000..890bab3 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Extensions; + +public static class BotCredentialsExtensions +{ + public static bool IsOwner(this IBotCredentials creds, IUser user) + => creds.IsOwner(user.Id); + + public static bool IsOwner(this IBotCredentials creds, ulong userId) + => creds.OwnerIds.Contains(userId); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs new file mode 100644 index 0000000..7f90d78 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Extensions; + +public static class CommandContextExtensions +{ + private static readonly Emoji _okEmoji = new Emoji("✅"); + private static readonly Emoji _warnEmoji = new Emoji("⚠️"); + private static readonly Emoji _errorEmoji = new Emoji("❌"); + + public static Task ReactAsync(this ICommandContext ctx, MsgType type) + { + var emoji = type switch + { + MsgType.Error => _errorEmoji, + MsgType.Pending => _warnEmoji, + MsgType.Ok => _okEmoji, + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; + + return ctx.Message.AddReactionAsync(emoji); + } + + public static Task OkAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Ok); + + public static Task ErrorAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Error); + + public static Task WarningAsync(this ICommandContext ctx) + => ctx.ReactAsync(MsgType.Pending); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/DbExtensions.cs b/src/EllieBot/_common/_Extensions/DbExtensions.cs new file mode 100644 index 0000000..58a9abd --- /dev/null +++ b/src/EllieBot/_common/_Extensions/DbExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Extensions; + +public static class DbExtensions +{ + public static DiscordUser GetOrCreateUser(this DbContext ctx, IUser original, Func, IQueryable>? includes = null) + => ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/Extensions.cs b/src/EllieBot/_common/_Extensions/Extensions.cs new file mode 100644 index 0000000..27341ad --- /dev/null +++ b/src/EllieBot/_common/_Extensions/Extensions.cs @@ -0,0 +1,237 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using Ellie.Common.Marmalade; + +namespace EllieBot.Extensions; + +public static class Extensions +{ + private static readonly Regex _urlRegex = + new(@"^(https?|ftp)://(?[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled); + + /// + /// Converts to + /// + /// The to convert. + /// The . + public static DateOnly ToDateOnly(this DateTime dateTime) + => DateOnly.FromDateTime(dateTime); + + /// + /// Determines if is before today + /// + /// The to check. + /// True if is before today. + public static bool IsBeforeToday(this DateTime date) + => date < DateTime.UtcNow.Date; + + public static Task EditAsync(this IUserMessage msg, SmartText text) + => text switch + { + SmartEmbedText set => msg.ModifyAsync(x => + { + x.Embed = set.IsValid ? set.GetEmbed().Build() : null; + x.Content = set.PlainText?.SanitizeMentions() ?? ""; + }), + SmartEmbedTextArray set => msg.ModifyAsync(x => + { + x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build()); + x.Content = set.Content?.SanitizeMentions() ?? ""; + }), + SmartPlainText spt => msg.ModifyAsync(x => + { + x.Content = spt.Text.SanitizeMentions(); + x.Embed = null; + }), + _ => throw new ArgumentOutOfRangeException(nameof(text)) + }; + + public static ulong[] GetGuildIds(this DiscordSocketClient client) + => client.Guilds + .Map(x => x.Id); + + /// + /// Generates a string in the format HHH:mm if timespan is >= 2m. + /// Generates a string in the format 00:mm:ss if timespan is less than 2m. + /// + /// Timespan to convert to string + /// Formatted duration string + public static string ToPrettyStringHm(this TimeSpan span) + { + if(span > TimeSpan.FromHours(24)) + return $"{span.Days:00}d:{span.Hours:00}h"; + + if (span > TimeSpan.FromMinutes(2)) + return $"{span.Hours:00}h:{span.Minutes:00}m"; + + return $"{span.Minutes:00}m:{span.Seconds:00}s"; + } + + public static double Megabytes(this int mb) + => mb * 1024d * 1024; + + public static TimeSpan Hours(this int hours) + => TimeSpan.FromHours(hours); + + public static TimeSpan Minutes(this int minutes) + => TimeSpan.FromMinutes(minutes); + + public static TimeSpan Days(this int days) + => TimeSpan.FromDays(days); + + public static TimeSpan Seconds(this int seconds) + => TimeSpan.FromSeconds(seconds); + + public static bool TryGetUrlPath(this string input, out string path) + { + var match = _urlRegex.Match(input); + if (match.Success) + { + path = match.Groups["path"].Value; + return true; + } + + path = string.Empty; + return false; + } + + public static IEmote ToIEmote(this string emojiStr) + => Emote.TryParse(emojiStr, out var maybeEmote) ? maybeEmote : new Emoji(emojiStr); + + + /// + /// First 10 characters of teh bot token. + /// + public static string RedisKey(this IBotCredentials bc) + => bc.Token[..10]; + + public static bool IsAuthor(this IMessage msg, IDiscordClient client) + => msg.Author?.Id == client.CurrentUser.Id; + + public static string RealSummary( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderService marmalades, + CultureInfo culture, + string prefix) + { + string description; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, and then name of the command for which + // the description should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + description = marmalades.GetCommandDescription(marmaladeName, cmd.Summary, culture); + } + else + { + description = strings.GetCommandStrings(cmd.Summary, culture).Desc; + } + + return string.Format(description, prefix); + } + + public static string[] RealRemarksArr( + this CommandInfo cmd, + IBotStrings strings, + IMarmaladeLoaderService marmalades, + CultureInfo culture, + string prefix) + { + string[] args; + if (cmd.Remarks?.StartsWith("marmalade///") ?? false) + { + // command method name is kept in Summary + // marmalade/// is kept in remarks + // this way I can find the name of the marmalade, + // and command for which data should be loaded + var marmaladeName = cmd.Remarks.Split("///")[1]; + args = marmalades.GetCommandExampleArgs(marmaladeName, cmd.Summary, culture); + } + else + { + args = strings.GetCommandStrings(cmd.Summary, culture).Examples; + } + + return args.Map(arg => GetFullUsage(cmd.Aliases.First(), arg, prefix)); + } + + private static string GetFullUsage(string commandName, string args, string prefix) + => $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd(); + + public static EmbedBuilder AddPaginatedFooter(this EmbedBuilder embed, int curPage, int? lastPage) + { + if (lastPage is not null) + return embed.WithFooter($"{curPage + 1} / {lastPage + 1}"); + + return embed.WithFooter((curPage + 1).ToString()); + } + + // public static EmbedBuilder WithOkColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Ok); + // + // public static EmbedBuilder WithPendingColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Pending); + // + // public static EmbedBuilder WithErrorColor(this EmbedBuilder eb) + // => eb.WithColor(EmbedColor.Error); + // + public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null) + { + Task.Run(async () => + { + await Task.Delay((int)(seconds * 1000)); + if (logService is not null) + logService.AddDeleteIgnore(msg.Id); + + try + { + await msg.DeleteAsync(); + } + catch + { + } + }); + return msg; + } + + public static ModuleInfo GetTopLevelModule(this ModuleInfo module) + { + while (module.Parent is not null) + module = module.Parent; + + return module; + } + + public static string GetGroupName(this ModuleInfo module) + => module.Name.Replace("Commands", "", StringComparison.InvariantCulture); + + public static async Task> GetMembersAsync(this IRole role) + { + var users = await role.Guild.GetUsersAsync(CacheMode.CacheOnly); + return users.Where(u => u.RoleIds.Contains(role.Id)); + } + + public static string ToJson(this T any, JsonSerializerOptions? options = null) + => JsonSerializer.Serialize(any, options); + + public static Stream ToStream(this IEnumerable bytes, bool canWrite = false) + { + var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite); + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + public static IEnumerable GetRoles(this IGuildUser user) + => user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null); + + public static void Lap(this Stopwatch sw, string checkpoint) + { + Log.Information("Checkpoint {CheckPoint}: {Time}ms", checkpoint, sw.Elapsed.TotalMilliseconds); + sw.Restart(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs new file mode 100644 index 0000000..e8414dd --- /dev/null +++ b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs @@ -0,0 +1,97 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = Discord.Color; + +namespace EllieBot.Extensions; + +public static class ImagesharpExtensions +{ + // https://github.com/SixLabors/Samples/blob/master/ImageSharp/AvatarWithRoundedCorner/Program.cs + public static IImageProcessingContext ApplyRoundedCorners(this IImageProcessingContext ctx, float cornerRadius) + { + var size = ctx.GetCurrentSize(); + var corners = BuildCorners(size.Width, size.Height, cornerRadius); + + ctx.SetGraphicsOptions(new GraphicsOptions + { + Antialias = true, + // enforces that any part of this shape that has color is punched out of the background + AlphaCompositionMode = PixelAlphaCompositionMode.DestOut + }); + + foreach (var c in corners) + ctx = ctx.Fill(SixLabors.ImageSharp.Color.Red, c); + + return ctx; + } + + private static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius) + { + // first create a square + var rect = new RectangularPolygon(-0.5f, -0.5f, cornerRadius, cornerRadius); + + // then cut out of the square a circle so we are left with a corner + var cornerTopLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius)); + + // corner is now a corner shape positions top left + //lets make 3 more positioned correctly, we can do that by translating the original around the center of the image + + var rightPos = imageWidth - cornerTopLeft.Bounds.Width + 1; + var bottomPos = imageHeight - cornerTopLeft.Bounds.Height + 1; + + // move it across the width of the image - the width of the shape + var cornerTopRight = cornerTopLeft.RotateDegree(90).Translate(rightPos, 0); + var cornerBottomLeft = cornerTopLeft.RotateDegree(-90).Translate(0, bottomPos); + var cornerBottomRight = cornerTopLeft.RotateDegree(180).Translate(rightPos, bottomPos); + + return new PathCollection(cornerTopLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight); + } + + public static Color ToDiscordColor(this Rgba32 color) + => new(color.R, color.G, color.B); + + public static MemoryStream ToStream(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + img.SaveAsGif(imageStream); + else + { + img.SaveAsPng(imageStream, + new() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } + + public static async Task ToStreamAsync(this Image img, IImageFormat? format = null) + { + var imageStream = new MemoryStream(); + if (format?.Name == "GIF") + { + await img.SaveAsGifAsync(imageStream); + } + else + { + await img.SaveAsPngAsync(imageStream, + new PngEncoder() + { + ColorType = PngColorType.RgbWithAlpha, + CompressionLevel = PngCompressionLevel.DefaultCompression + }); + } + + imageStream.Position = 0; + return imageStream; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs new file mode 100644 index 0000000..018359e --- /dev/null +++ b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Extensions; + +public static class LinkedListExtensions +{ + public static LinkedListNode? FindNode(this LinkedList list, Func predicate) + { + var node = list.First; + while (node is not null) + { + if (predicate(node.Value)) + return node; + + node = node.Next; + } + + return null; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/NumberExtensions.cs b/src/EllieBot/_common/_Extensions/NumberExtensions.cs new file mode 100644 index 0000000..3e0f703 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/NumberExtensions.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Extensions; + +public static class NumberExtensions +{ + public static DateTimeOffset ToUnixTimestamp(this double number) + => new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs new file mode 100644 index 0000000..49be90e --- /dev/null +++ b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Extensions; + +public static class ReflectionExtensions +{ + public static bool IsAssignableToGenericType(this Type givenType, Type genericType) + { + var interfaceTypes = givenType.GetInterfaces(); + + foreach (var it in interfaceTypes) + { + if (it.IsGenericType && it.GetGenericTypeDefinition() == genericType) + return true; + } + + if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType) + return true; + + var baseType = givenType.BaseType; + if (baseType == null) return false; + + return IsAssignableToGenericType(baseType, genericType); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs new file mode 100644 index 0000000..a0cd408 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs @@ -0,0 +1,57 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace EllieBot.Extensions; + +public static class Rgba32Extensions +{ + public static Image Merge(this IEnumerable> images) + => images.Merge(out _); + + public static Image Merge(this IEnumerable> images, out IImageFormat format) + { + format = PngFormat.Instance; + + void DrawFrame(IList> imgArray, Image imgFrame, int frameNumber) + { + var xOffset = 0; + for (var i = 0; i < imgArray.Count; i++) + { + using var frame = imgArray[i].Frames.CloneFrame(frameNumber % imgArray[i].Frames.Count); + var offset = xOffset; + imgFrame.Mutate(x => x.DrawImage(frame, new Point(offset, 0), new GraphicsOptions())); + xOffset += imgArray[i].Bounds().Width; + } + } + + var imgs = images.ToList(); + var frames = imgs.Max(x => x.Frames.Count); + + var width = imgs.Sum(img => img.Width); + var height = imgs.Max(img => img.Height); + var canvas = new Image(width, height); + if (frames == 1) + { + DrawFrame(imgs, canvas, 0); + return canvas; + } + + format = GifFormat.Instance; + for (var j = 0; j < frames; j++) + { + using var imgFrame = new Image(width, height); + DrawFrame(imgs, imgFrame, j); + + var frameToAdd = imgFrame.Frames[0]; + frameToAdd.Metadata.GetGifMetadata().DisposalMethod = GifDisposalMethod.RestoreToBackground; + canvas.Frames.AddFrame(frameToAdd); + } + + canvas.Frames.RemoveFrame(0); + return canvas; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs new file mode 100644 index 0000000..1b99fa7 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Extensions; + +public static class SocketMessageComponentExtensions +{ + public static async Task RespondAsync( + this SocketMessageComponent ch, + IMessageSenderService sender, + string text, + MsgType type, + bool ephemeral = false) + { + var embed = sender.CreateEmbed().WithDescription(text); + + embed = (type switch + { + MsgType.Error => embed.WithErrorColor(), + MsgType.Ok => embed.WithOkColor(), + MsgType.Pending => embed.WithPendingColor(), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }); + + await ch.RespondAsync(embeds: [embed.Build()], ephemeral: ephemeral); + } + + // embed title and optional footer overloads + + public static Task RespondConfirmAsync( + this SocketMessageComponent smc, + IMessageSenderService sender, + string text, + bool ephemeral = false) + => smc.RespondAsync(sender, text, MsgType.Ok, ephemeral); +} \ No newline at end of file diff --git a/src/EllieBot/_common/_Extensions/UserExtensions.cs b/src/EllieBot/_common/_Extensions/UserExtensions.cs new file mode 100644 index 0000000..8f81f61 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/UserExtensions.cs @@ -0,0 +1,21 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Extensions; + +public static class UserExtensions +{ + // This method is used by everything that fetches the avatar from a user + public static Uri RealAvatarUrl(this IUser usr, ushort size = 256) + => usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size)); + + // This method is only used for the xp card + public static Uri? RealAvatarUrl(this DiscordUser usr) + { + if (!string.IsNullOrWhiteSpace(usr.AvatarId)) + return new Uri(CDN.GetUserAvatarUrl(usr.UserId, usr.AvatarId, 128, ImageFormat.Png)); + + return Uri.TryCreate(CDN.GetDefaultUserAvatarUrl(usr.UserId), UriKind.Absolute, out var uri) + ? uri + : null; + } +} \ No newline at end of file -- 2.43.0 From 34eb87b13d3f2a774fa5e336bf295b71f6ad52e0 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:45:23 +1200 Subject: [PATCH 017/340] Updated base project files --- src/EllieBot/Bot.cs | 230 ++++++++++++--------------- src/EllieBot/Directory.Build.props | 7 - src/EllieBot/EllieBot.csproj | 245 +++++++++++++++-------------- 3 files changed, 226 insertions(+), 256 deletions(-) delete mode 100644 src/EllieBot/Directory.Build.props diff --git a/src/EllieBot/Bot.cs b/src/EllieBot/Bot.cs index d6ab91a..1ce8774 100644 --- a/src/EllieBot/Bot.cs +++ b/src/EllieBot/Bot.cs @@ -1,28 +1,26 @@ #nullable disable +using DryIoc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using EllieBot.Common.Configs; using EllieBot.Common.ModuleBehaviors; -using EllieBot.Db; -using EllieBot.Modules.Utility; -using EllieBot.Services.Database.Models; +using EllieBot.Db.Models; using System.Collections.Immutable; using System.Diagnostics; -using System.Net; using System.Reflection; using RunMode = Discord.Commands.RunMode; namespace EllieBot; -public sealed class Bot +public sealed class Bot : IBot { public event Func JoinedGuild = delegate { return Task.CompletedTask; }; public DiscordSocketClient Client { get; } - public ImmutableArray AllGuildConfigs { get; private set; } + public IReadOnlyCollection AllGuildConfigs { get; private set; } - private IServiceProvider Services { get; set; } + private IContainer Services { get; set; } - public string Mention { get; private set; } public bool IsReady { get; private set; } public int ShardId { get; set; } @@ -31,18 +29,19 @@ public sealed class Bot private readonly DbService _db; private readonly IBotCredsProvider _credsProvider; + + private readonly Assembly[] _loadedAssemblies; // private readonly InteractionService _interactionService; public Bot(int shardId, int? totalShards, string credPath = null) { - if (shardId < 0) - throw new ArgumentOutOfRangeException(nameof(shardId)); + ArgumentOutOfRangeException.ThrowIfLessThan(shardId, 0); ShardId = shardId; _credsProvider = new BotCredsProvider(totalShards, credPath); _creds = _credsProvider.GetCreds(); - _db = new(_credsProvider); + _db = new EllieDbService(_credsProvider); var messageCacheSize = #if GLOBAL_ELLIE @@ -51,9 +50,9 @@ public sealed class Bot 50; #endif - if(!_creds.UsePrivilegedIntents) + if (!_creds.UsePrivilegedIntents) Log.Warning("You are not using privileged intents. Some features will not work properly"); - + Client = new(new() { MessageCacheSize = messageCacheSize, @@ -81,10 +80,14 @@ public sealed class Bot // _interactionService = new(Client.Rest); Client.Log += Client_Log; + _loadedAssemblies = + [ + typeof(Bot).Assembly // bot + ]; } - public List GetCurrentGuildIds() + public IReadOnlyList GetCurrentGuildIds() => Client.Guilds.Select(x => x.Id).ToList(); private void AddServices() @@ -96,120 +99,89 @@ public sealed class Bot using (var uow = _db.GetDbContext()) { uow.EnsureUserCreated(bot.Id, bot.Username, bot.Discriminator, bot.AvatarId); - AllGuildConfigs = uow.GuildConfigs.GetAllGuildConfigs(startingGuildIdList).ToImmutableArray(); + AllGuildConfigs = uow.Set().GetAllGuildConfigs(startingGuildIdList).ToImmutableArray(); } - var svcs = new ServiceCollection().AddTransient(_ => _credsProvider.GetCreds()) // bot creds - .AddSingleton(_credsProvider) - .AddSingleton(_db) // database - .AddSingleton(Client) // discord socket client - .AddSingleton(_commandService) - // .AddSingleton(_interactionService) - .AddSingleton(this) - .AddSingleton() - .AddSingleton() - .AddConfigServices() - .AddConfigMigrators() - .AddMemoryCache() - // music - .AddMusic() - // cache - .AddCache(_creds); - + // var svcs = new StandardKernel(new NinjectSettings() + // { + // // ThrowOnGetServiceNotFound = true, + // ActivationCacheDisabled = true, + // }); - svcs.AddHttpClient(); - svcs.AddHttpClient("memelist") - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler - { - AllowAutoRedirect = false - }); - - svcs.AddHttpClient("google:search") - .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() - { - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate - }); + var svcs = new Container(); - if (Environment.GetEnvironmentVariable("ELLIE_IS_COORDINATED") != "1") + // this is required in order for medusa unloading to work + // svcs.Components.Remove(); + // svcs.Components.Add(); + + svcs.AddSingleton(_ => _credsProvider.GetCreds()); + svcs.AddSingleton(_db); + svcs.AddSingleton(_credsProvider); + svcs.AddSingleton(Client); + svcs.AddSingleton(_commandService); + svcs.AddSingleton(this); + svcs.AddSingleton(this); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(new MemoryCache(new MemoryCacheOptions())); + svcs.AddSingleton(); + svcs.AddSingleton(); + + + foreach (var a in _loadedAssemblies) + { + svcs.AddConfigServices(a) + .AddLifetimeServices(a); + } + + svcs.AddMusic() + .AddCache(_creds) + .AddHttpClients(); + + if (Environment.GetEnvironmentVariable("ELLIEBOT_IS_COORDINATED") != "1") + { svcs.AddSingleton(); + } else { - svcs.AddSingleton() - .AddSingleton(x => x.GetRequiredService()) - .AddSingleton(x => x.GetRequiredService()); + svcs.AddSingleton(); + svcs.AddSingleton(_ => svcs.GetRequiredService()); + svcs.AddSingleton(_ => svcs.GetRequiredService()); } - svcs.Scan(scan => scan.FromAssemblyOf() - .AddClasses(classes => classes.AssignableToAny( - // services - typeof(IEService), - - // behaviours - typeof(IExecOnMessage), - typeof(IInputTransformer), - typeof(IExecPreCommand), - typeof(IExecPostCommand), - typeof(IExecNoCommand)) - .WithoutAttribute() -#if GLOBAL_ELLIE - .WithoutAttribute() -#endif - ) - .AsSelfWithInterfaces() - .WithSingletonLifetime()); + svcs.AddSingleton(svcs); //initialize Services - Services = svcs.BuildServiceProvider(); + Services = svcs; Services.GetRequiredService().Initialize(); - Services.GetRequiredService(); - if (Client.ShardId == 0) - ApplyConfigMigrations(); - - _ = LoadTypeReaders(typeof(Bot).Assembly); + foreach (var a in _loadedAssemblies) + { + LoadTypeReaders(a); + } sw.Stop(); - Log.Information( "All services loaded in {ServiceLoadTime:F2}s", sw.Elapsed.TotalSeconds); + Log.Information("All services loaded in {ServiceLoadTime:F2}s", sw.Elapsed.TotalSeconds); } - private void ApplyConfigMigrations() + private void LoadTypeReaders(Assembly assembly) { - // execute all migrators - var migrators = Services.GetServices(); - foreach (var migrator in migrators) - migrator.EnsureMigrated(); - } - - private IEnumerable LoadTypeReaders(Assembly assembly) - { - Type[] allTypes; - try - { - allTypes = assembly.GetTypes(); - } - catch (ReflectionTypeLoadException ex) - { - Log.Warning(ex.LoaderExceptions[0], "Error getting types"); - return Enumerable.Empty(); - } - - var filteredTypes = allTypes.Where(x => x.IsSubclassOf(typeof(TypeReader)) + var filteredTypes = assembly.GetExportedTypes() + .Where(x => x.IsSubclassOf(typeof(TypeReader)) && x.BaseType?.GetGenericArguments().Length > 0 && !x.IsAbstract); - var toReturn = new List(); foreach (var ft in filteredTypes) { - var x = (TypeReader)ActivatorUtilities.CreateInstance(Services, ft); var baseType = ft.BaseType; if (baseType is null) continue; - var typeArgs = baseType.GetGenericArguments(); - _commandService.AddTypeReader(typeArgs[0], x); - toReturn.Add(x); - } - return toReturn; + var typeReader = (TypeReader)ActivatorUtilities.CreateInstance(Services, ft); + var typeArgs = baseType.GetGenericArguments(); + _commandService.AddTypeReader(typeArgs[0], typeReader); + } } private async Task LoginAsync(string token) @@ -249,10 +221,10 @@ public sealed class Bot LoginErrorHandler.Handle(ex); Helpers.ReadErrorAndExit(4); } - + await clientReady.Task.ConfigureAwait(false); Client.Ready -= SetClientReady; - + Client.JoinedGuild += Client_JoinedGuild; Client.LeftGuild += Client_LeftGuild; @@ -286,12 +258,11 @@ public sealed class Bot { if (ShardId == 0) await _db.SetupAsync(); - + var sw = Stopwatch.StartNew(); await LoginAsync(_creds.Token); - Mention = Client.CurrentUser.Mention; Log.Information("Shard {ShardId} loading services...", Client.ShardId); try { @@ -310,7 +281,11 @@ public sealed class Bot // start handling messages received in commandhandler await commandHandler.StartHandling(); - await _commandService.AddModulesAsync(typeof(Bot).Assembly, Services); + foreach (var a in _loadedAssemblies) + { + await _commandService.AddModulesAsync(a, Services); + } + // await _interactionService.AddModulesAsync(typeof(Bot).Assembly, Services); IsReady = true; @@ -364,29 +339,30 @@ public sealed class Bot if (arg.Exception is { InnerException: WebSocketClosedException { CloseCode: 4014 } }) { - Log.Error(@" -Login failed. - -*** Please enable privileged intents *** - -Certain Ellie features require Discord's privileged gateway intents. -These include greeting and goodbye messages, as well as creating the Owner message channels for DM forwarding. - -How to enable privileged intents: -1. Head over to the Discord Developer Portal https://discord.com/developers/applications/ -2. Select your Application. -3. Click on `Bot` in the left side navigation panel, and scroll down to the intents section. -4. Enable all intents. -5. Restart your bot. - -Read this only if your bot is in 100 or more servers: - -You'll need to apply to use the intents with Discord, but for small selfhosts, all that is required is enabling the intents in the developer portal. -Yes, this is a new thing from Discord, as of October 2020. No, there's nothing we can do about it. Yes, we're aware it worked before. -While waiting for your bot to be accepted, you can change the 'usePrivilegedIntents' inside your creds.yml to 'false', although this will break many of the ellie's features"); + Log.Error(""" + Login failed. + + *** Please enable privileged intents *** + + Certain Ellie features require Discord's privileged gateway intents. + These include greeting and goodbye messages, as well as creating the Owner message channels for DM forwarding. + + How to enable privileged intents: + 1. Head over to the Discord Developer Portal https://discord.com/developers/applications/ + 2. Select your Application. + 3. Click on `Bot` in the left side navigation panel, and scroll down to the intents section. + 4. Enable all intents. + 5. Restart your bot. + + Read this only if your bot is in 100 or more servers: + + You'll need to apply to use the intents with Discord, but for small selfhosts, all that is required is enabling the intents in the developer portal. + Yes, this is a new thing from Discord, as of October 2020. No, there's nothing we can do about it. Yes, we're aware it worked before. + While waiting for your bot to be accepted, you can change the 'usePrivilegedIntents' inside your creds.yml to 'false', although this will break many of the ellie's features + """); return Task.CompletedTask; } - + #if GLOBAL_ELLIE || DEBUG if (arg.Exception is not null) Log.Warning(arg.Exception, "{ErrorSource} | {ErrorMessage}", arg.Source, arg.Message); diff --git a/src/EllieBot/Directory.Build.props b/src/EllieBot/Directory.Build.props deleted file mode 100644 index 1623cb0..0000000 --- a/src/EllieBot/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - all - - - \ No newline at end of file diff --git a/src/EllieBot/EllieBot.csproj b/src/EllieBot/EllieBot.csproj index 5f4dbb0..2c605d4 100644 --- a/src/EllieBot/EllieBot.csproj +++ b/src/EllieBot/EllieBot.csproj @@ -1,142 +1,143 @@  - - net6.0 - preview - enable - true - true + net8.0 + enable + true + en + 5.0.8 - - $(MSBuildProjectDirectory) - exe - ellie_icon.ico + + $(MSBuildProjectDirectory) + exe + ellie_icon.ico - - - - CS1066 - + + + + CS1066;CS8981 - - - all - True - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + true + embedded - - - - - - - - - - - - - - - - - - - - - - - - + - - all - True - + + + all + True + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - + + + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + - + + + + + + + - - - + + + + + + + + + + - - + + + + + + + - - - - - + - - - - - - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - - - - Protos\coordinator.proto - - - PreserveNewest - - - PreserveNewest - - - Always - - + - - 4.0.0 - $(VersionPrefix).$(VersionSuffix) - $(VersionPrefix) - + + + - - - false - GLOBAL_ELLIE - $(NoWarn);CS1573;CS1591 - true - portable - false - + + + + + + + + + + + + + + + + + + + + + + Protos\coordinator.proto + + + true + PreserveNewest + + + true + Always + + + true + Always + + + + + + false + GLOBAL_ELLIE + $(NoWarn);CS1573;CS1591 + true + portable + false + -- 2.43.0 From a61b98a0f53e165549b036869f6e1019010b72fd Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:46:10 +1200 Subject: [PATCH 018/340] Updated services files --- .../Services/Impl/BotCredsProvider.cs | 204 ++++++++++++++++ .../Services/Impl/GoogleApiService.cs | 229 ++++++++++++++++++ .../GoogleApiService_SupportedLanguages.cs | 160 ++++++++++++ src/EllieBot/Services/Impl/ImageCache.cs | 83 +++++++ src/EllieBot/Services/Impl/LocalDataCache.cs | 108 +++++++++ src/EllieBot/Services/Impl/Localization.cs | 121 +++++++++ .../Services/Impl/PubSub/JsonSeria.cs | 27 +++ .../Services/Impl/PubSub/RedisPubSub.cs | 57 +++++ .../Services/Impl/PubSub/YamlSeria.cs | 39 +++ src/EllieBot/Services/Impl/RedisBotCache.cs | 119 +++++++++ .../Services/Impl/RedisBotStringsProvider.cs | 91 +++++++ .../Services/Impl/RemoteGrpcCoordinator.cs | 132 ++++++++++ 12 files changed, 1370 insertions(+) create mode 100644 src/EllieBot/Services/Impl/BotCredsProvider.cs create mode 100644 src/EllieBot/Services/Impl/GoogleApiService.cs create mode 100644 src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs create mode 100644 src/EllieBot/Services/Impl/ImageCache.cs create mode 100644 src/EllieBot/Services/Impl/LocalDataCache.cs create mode 100644 src/EllieBot/Services/Impl/Localization.cs create mode 100644 src/EllieBot/Services/Impl/PubSub/JsonSeria.cs create mode 100644 src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs create mode 100644 src/EllieBot/Services/Impl/PubSub/YamlSeria.cs create mode 100644 src/EllieBot/Services/Impl/RedisBotCache.cs create mode 100644 src/EllieBot/Services/Impl/RedisBotStringsProvider.cs create mode 100644 src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs diff --git a/src/EllieBot/Services/Impl/BotCredsProvider.cs b/src/EllieBot/Services/Impl/BotCredsProvider.cs new file mode 100644 index 0000000..3d2638e --- /dev/null +++ b/src/EllieBot/Services/Impl/BotCredsProvider.cs @@ -0,0 +1,204 @@ +#nullable disable +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; +using EllieBot.Common.Yml; +using Newtonsoft.Json; + +namespace EllieBot.Services; + +public sealed class BotCredsProvider : IBotCredsProvider +{ + private const string CREDS_FILE_NAME = "creds.yml"; + private const string CREDS_EXAMPLE_FILE_NAME = "creds_example.yml"; + + private string CredsPath { get; } + + private string CredsExamplePath { get; } + + private readonly int? _totalShards; + + + private readonly Creds _creds = new(); + private readonly IConfigurationRoot _config; + + + private readonly object _reloadLock = new(); + private readonly IDisposable _changeToken; + + public BotCredsProvider(int? totalShards = null, string credPath = null) + { + _totalShards = totalShards; + + if (!string.IsNullOrWhiteSpace(credPath)) + { + CredsPath = credPath; + CredsExamplePath = Path.Combine(Path.GetDirectoryName(credPath), CREDS_EXAMPLE_FILE_NAME); + } + else + { + CredsPath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_FILE_NAME); + CredsExamplePath = Path.Combine(Directory.GetCurrentDirectory(), CREDS_EXAMPLE_FILE_NAME); + } + + try + { + if (!File.Exists(CredsExamplePath)) + File.WriteAllText(CredsExamplePath, Yaml.Serializer.Serialize(_creds)); + } + catch + { + // this can fail in docker containers + } + + MigrateCredentials(); + + if (!File.Exists(CredsPath)) + { + Log.Warning( + "{CredsPath} is missing. Attempting to load creds from environment variables prefixed with 'EllieBot_'. Example is in {CredsExamplePath}", + CredsPath, + CredsExamplePath); + } + + try + { + _config = new ConfigurationBuilder().AddYamlFile(CredsPath, false, true) + .AddEnvironmentVariables("EllieBot_") + .Build(); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + } + + _changeToken = ChangeToken.OnChange(() => _config.GetReloadToken(), Reload); + Reload(); + } + + public void Reload() + { + lock (_reloadLock) + { + _creds.OwnerIds.Clear(); + _config.Bind(_creds); + + if (string.IsNullOrWhiteSpace(_creds.Token)) + { + Log.Error("Token is missing from creds.yml or Environment variables.\nAdd it and restart the program"); + Helpers.ReadErrorAndExit(5); + return; + } + + if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd) + || string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args)) + { + if (Environment.OSVersion.Platform == PlatformID.Unix) + { + _creds.RestartCommand = new RestartConfig() + { + Args = "dotnet", + Cmd = "EllieBot.dll -- {0}" + }; + } + else + { + _creds.RestartCommand = new RestartConfig() + { + Args = "EllieBot.exe", + Cmd = "{0}" + }; + } + } + + if (string.IsNullOrWhiteSpace(_creds.RedisOptions)) + _creds.RedisOptions = "127.0.0.1,syncTimeout=3000"; + + // replace the old generated key with the shared key + if (string.IsNullOrWhiteSpace(_creds.CoinmarketcapApiKey) + || _creds.CoinmarketcapApiKey.StartsWith("e79ec505-0913")) + _creds.CoinmarketcapApiKey = "3077537c-7dfb-4d97-9a60-56fc9a9f5035"; + + _creds.TotalShards = _totalShards ?? _creds.TotalShards; + } + } + + public void ModifyCredsFile(Action func) + { + var ymlData = File.ReadAllText(CREDS_FILE_NAME); + var creds = Yaml.Deserializer.Deserialize(ymlData); + + func(creds); + + ymlData = Yaml.Serializer.Serialize(creds); + File.WriteAllText(CREDS_FILE_NAME, ymlData); + } + + private string OldCredsJsonPath + => Path.Combine(Directory.GetCurrentDirectory(), "credentials.json"); + + private string OldCredsJsonBackupPath + => Path.Combine(Directory.GetCurrentDirectory(), "credentials.json.bak"); + + private void MigrateCredentials() + { + if (File.Exists(OldCredsJsonPath)) + { + Log.Information("Migrating old creds..."); + var jsonCredentialsFileText = File.ReadAllText(OldCredsJsonPath); + var oldCreds = JsonConvert.DeserializeObject(jsonCredentialsFileText); + + if (oldCreds is null) + { + Log.Error("Error while reading old credentials file. Make sure that the file is formatted correctly"); + return; + } + + var creds = new Creds + { + Version = 1, + Token = oldCreds.Token, + OwnerIds = oldCreds.OwnerIds.Distinct().ToHashSet(), + GoogleApiKey = oldCreds.GoogleApiKey, + RapidApiKey = oldCreds.MashapeKey, + OsuApiKey = oldCreds.OsuApiKey, + CleverbotApiKey = oldCreds.CleverbotApiKey, + TotalShards = oldCreds.TotalShards <= 1 ? 1 : oldCreds.TotalShards, + Patreon = new Creds.PatreonSettings(oldCreds.PatreonAccessToken, null, null, oldCreds.PatreonCampaignId), + Votes = new Creds.VotesSettings(oldCreds.VotesUrl, oldCreds.VotesToken, string.Empty, string.Empty), + BotListToken = oldCreds.BotListToken, + RedisOptions = oldCreds.RedisOptions, + LocationIqApiKey = oldCreds.LocationIqApiKey, + TimezoneDbApiKey = oldCreds.TimezoneDbApiKey, + CoinmarketcapApiKey = oldCreds.CoinmarketcapApiKey + }; + + File.Move(OldCredsJsonPath, OldCredsJsonBackupPath, true); + File.WriteAllText(CredsPath, Yaml.Serializer.Serialize(creds)); + + Log.Warning( + "Data from credentials.json has been moved to creds.yml\nPlease inspect your creds.yml for correctness"); + } + + if (File.Exists(CREDS_FILE_NAME)) + { + var creds = Yaml.Deserializer.Deserialize(File.ReadAllText(CREDS_FILE_NAME)); + if (creds.Version <= 5) + { + creds.BotCache = BotCacheImplemenation.Redis; + } + if (creds.Version <= 6) + { + creds.Version = 7; + File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds)); + } + } + } + + public IBotCredentials GetCreds() + { + lock (_reloadLock) + { + return _creds; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/GoogleApiService.cs b/src/EllieBot/Services/Impl/GoogleApiService.cs new file mode 100644 index 0000000..9d4a494 --- /dev/null +++ b/src/EllieBot/Services/Impl/GoogleApiService.cs @@ -0,0 +1,229 @@ +#nullable disable +using Google; +using Google.Apis.Services; +using Google.Apis.Urlshortener.v1; +using Google.Apis.YouTube.v3; +using Newtonsoft.Json.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Xml; + +namespace EllieBot.Services; + +public sealed partial class GoogleApiService : IGoogleApiService, IEService +{ + private static readonly Regex + _plRegex = new(@"(?:youtu\.be\/|list=)(?[\da-zA-Z\-_]*)", RegexOptions.Compiled); + + + private readonly YouTubeService _yt; + private readonly UrlshortenerService _sh; + + //private readonly Regex YtVideoIdRegex = new Regex(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?[a-zA-Z0-9_-]{6,11})", RegexOptions.Compiled); + private readonly IBotCredsProvider _creds; + private readonly IHttpClientFactory _httpFactory; + + public GoogleApiService(IBotCredsProvider creds, IHttpClientFactory factory) : this() + { + _creds = creds; + _httpFactory = factory; + + var bcs = new BaseClientService.Initializer + { + ApplicationName = "Ellie Bot", + ApiKey = _creds.GetCreds().GoogleApiKey + }; + + _yt = new(bcs); + _sh = new(bcs); + } + + public async Task> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1) + { + if (string.IsNullOrWhiteSpace(keywords)) + throw new ArgumentNullException(nameof(keywords)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + var match = _plRegex.Match(keywords); + if (match.Length > 1) + return new[] { match.Groups["id"].Value }; + var query = _yt.Search.List("snippet"); + query.MaxResults = count; + query.Type = "playlist"; + query.Q = keywords; + + return (await query.ExecuteAsync()).Items.Select(i => i.Id.PlaylistId); + } + + public async Task> GetRelatedVideosAsync(string id, int count = 2, string user = null) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentNullException(nameof(id)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + var query = _yt.Search.List("snippet"); + query.MaxResults = count; + query.Q = id; + // query.RelatedToVideoId = id; + query.Type = "video"; + query.QuotaUser = user; + // bad workaround as there's no replacement for related video querying right now. + // Query youtube with the id of the video, take a second video in the results + // skip the first one as that's probably the same video. + return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId).Skip(1); + } + + public async Task> GetVideoLinksByKeywordAsync(string keywords, int count = 1) + { + if (string.IsNullOrWhiteSpace(keywords)) + throw new ArgumentNullException(nameof(keywords)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + var query = _yt.Search.List("snippet"); + query.MaxResults = count; + query.Q = keywords; + query.Type = "video"; + query.SafeSearch = SearchResource.ListRequest.SafeSearchEnum.Strict; + return (await query.ExecuteAsync()).Items.Select(i => "https://www.youtube.com/watch?v=" + i.Id.VideoId); + } + + public async Task> GetVideoInfosByKeywordAsync( + string keywords, + int count = 1) + { + if (string.IsNullOrWhiteSpace(keywords)) + throw new ArgumentNullException(nameof(keywords)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + var query = _yt.Search.List("snippet"); + query.MaxResults = count; + query.Q = keywords; + query.Type = "video"; + return (await query.ExecuteAsync()).Items.Select(i + => (i.Snippet.Title.TrimTo(50), + i.Id.VideoId, + "https://www.youtube.com/watch?v=" + i.Id.VideoId, + i.Snippet.Thumbnails.High.Url)); + } + + public Task ShortenUrl(Uri url) + => ShortenUrl(url.ToString()); + + public async Task ShortenUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + throw new ArgumentNullException(nameof(url)); + + if (string.IsNullOrWhiteSpace(_creds.GetCreds().GoogleApiKey)) + return url; + + try + { + var response = await _sh.Url.Insert(new() + { + LongUrl = url + }) + .ExecuteAsync(); + return response.Id; + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == HttpStatusCode.Forbidden) + { + return url; + } + catch (Exception ex) + { + Log.Warning(ex, "Error shortening URL"); + return url; + } + } + + public async Task> GetPlaylistTracksAsync(string playlistId, int count = 50) + { + if (string.IsNullOrWhiteSpace(playlistId)) + throw new ArgumentNullException(nameof(playlistId)); + + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + + string nextPageToken = null; + + var toReturn = new List(count); + + do + { + var toGet = count > 50 ? 50 : count; + count -= toGet; + + var query = _yt.PlaylistItems.List("contentDetails"); + query.MaxResults = toGet; + query.PlaylistId = playlistId; + query.PageToken = nextPageToken; + + var data = await query.ExecuteAsync(); + + toReturn.AddRange(data.Items.Select(i => i.ContentDetails.VideoId)); + nextPageToken = data.NextPageToken; + } while (count > 0 && !string.IsNullOrWhiteSpace(nextPageToken)); + + return toReturn; + } + + public async Task> GetVideoDurationsAsync(IEnumerable videoIds) + { + var videoIdsList = videoIds as List ?? videoIds.ToList(); + + var toReturn = new Dictionary(); + + if (!videoIdsList.Any()) + return toReturn; + var remaining = videoIdsList.Count; + + do + { + var toGet = remaining > 50 ? 50 : remaining; + remaining -= toGet; + + var q = _yt.Videos.List("contentDetails"); + q.Id = string.Join(",", videoIdsList.Take(toGet)); + videoIdsList = videoIdsList.Skip(toGet).ToList(); + var items = (await q.ExecuteAsync()).Items; + foreach (var i in items) + toReturn.Add(i.Id, XmlConvert.ToTimeSpan(i.ContentDetails.Duration)); + } while (remaining > 0); + + return toReturn; + } + + public async Task Translate(string sourceText, string sourceLanguage, string targetLanguage) + { + string text; + + if (!Languages.ContainsKey(sourceLanguage) || !Languages.ContainsKey(targetLanguage)) + throw new ArgumentException(nameof(sourceLanguage) + "/" + nameof(targetLanguage)); + + + var url = new Uri(string.Format( + "https://translate.googleapis.com/translate_a/single?client=gtx&sl={0}&tl={1}&dt=t&q={2}", + ConvertToLanguageCode(sourceLanguage), + ConvertToLanguageCode(targetLanguage), + WebUtility.UrlEncode(sourceText))); + using (var http = _httpFactory.CreateClient()) + { + http.DefaultRequestHeaders.Add("user-agent", + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36"); + text = await http.GetStringAsync(url); + } + + return string.Concat(JArray.Parse(text)[0].Select(x => x[0])); + } + + private string ConvertToLanguageCode(string language) + { + Languages.TryGetValue(language, out var mode); + return mode; + } +} + diff --git a/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs b/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs new file mode 100644 index 0000000..b4aa70a --- /dev/null +++ b/src/EllieBot/Services/Impl/GoogleApiService_SupportedLanguages.cs @@ -0,0 +1,160 @@ +namespace EllieBot.Services; + +public sealed partial class GoogleApiService +{ + private const string SUPPORTED = """ + afrikaans af + albanian sq + amharic am + arabic ar + armenian hy + assamese as + aymara ay + azerbaijani az + bambara bm + basque eu + belarusian be + bengali bn + bhojpuri bho + bosnian bs + bulgarian bg + catalan ca + cebuano ceb + chinese zh-CN + chinese-trad zh-TW + corsican co + croatian hr + czech cs + danish da + dhivehi dv + dogri doi + dutch nl + english en + esperanto eo + estonian et + ewe ee + filipino fil + finnish fi + french fr + frisian fy + galician gl + georgian ka + german de + greek el + guarani gn + gujarati gu + haitian ht + hausa ha + hawaiian haw + hebrew he + hindi hi + hmong hmn + hungarian hu + icelandic is + igbo ig + ilocano ilo + indonesian id + irish ga + italian it + japanese ja + javanese jv + kannada kn + kazakh kk + khmer km + kinyarwanda rw + konkani gom + korean ko + krio kri + kurdish ku + kurdish-sor ckb + kyrgyz ky + lao lo + latin la + latvian lv + lingala ln + lithuanian lt + luganda lg + luxembourgish lb + macedonian mk + maithili mai + malagasy mg + malay ms + malayalam ml + maltese mt + maori mi + marathi mr + meiteilon mni-Mtei + mizo lus + mongolian mn + myanmar my + nepali ne + norwegian no + nyanja ny + odia or + oromo om + pashto ps + persian fa + polish pl + portuguese pt + punjabi pa + quechua qu + romanian ro + russian ru + samoan sm + sanskrit sa + scots gd + sepedi nso + serbian sr + sesotho st + shona sn + sindhi sd + sinhala si + slovak sk + slovenian sl + somali so + spanish es + sundanese su + swahili sw + swedish sv + tagalog tl + tajik tg + tamil ta + tatar tt + telugu te + thai th + tigrinya ti + tsonga ts + turkish tr + turkmen tk + twi ak + ukrainian uk + urdu ur + uyghur ug + uzbek uz + vietnamese vi + welsh cy + xhosa xh + yiddish yi + yoruba yo + zulu zu + """; + + + public IReadOnlyDictionary Languages { get; } + + private GoogleApiService() + { + var langs = SUPPORTED.Split("\n") + .Select(x => x.Split(' ')) + .ToDictionary(x => x[0].Trim(), x => x[1].Trim()); + + foreach (var (_, v) in langs.ToArray()) + { + langs.Add(v, v); + } + + Languages = langs; + + } + +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/ImageCache.cs b/src/EllieBot/Services/Impl/ImageCache.cs new file mode 100644 index 0000000..12ec051 --- /dev/null +++ b/src/EllieBot/Services/Impl/ImageCache.cs @@ -0,0 +1,83 @@ +namespace EllieBot.Services; + +public sealed class ImageCache : IImageCache, IEService +{ + private readonly IBotCache _cache; + private readonly ImagesConfig _ic; + private readonly Random _rng; + private readonly IHttpClientFactory _httpFactory; + + public ImageCache( + IBotCache cache, + ImagesConfig ic, + IHttpClientFactory httpFactory) + { + _cache = cache; + _ic = ic; + _httpFactory = httpFactory; + _rng = new EllieRandom(); + } + + private static TypedKey GetImageKey(Uri url) + => new($"image:{url}"); + + public async Task GetImageDataAsync(Uri url) + => await _cache.GetOrAddAsync( + GetImageKey(url), + async () => + { + if (url.IsFile) + { + return await File.ReadAllBytesAsync(url.LocalPath); + } + + using var http = _httpFactory.CreateClient(); + var bytes = await http.GetByteArrayAsync(url); + return bytes; + }, + expiry: TimeSpan.FromHours(48)); + + private async Task GetRandomImageDataAsync(Uri[] urls) + { + if (urls.Length == 0) + return null; + + var url = urls[_rng.Next(0, urls.Length)]; + + var data = await GetImageDataAsync(url); + return data; + } + + public Task GetHeadsImageAsync() + => GetRandomImageDataAsync(_ic.Data.Coins.Heads); + + public Task GetTailsImageAsync() + => GetRandomImageDataAsync(_ic.Data.Coins.Tails); + + public Task GetCurrencyImageAsync() + => GetRandomImageDataAsync(_ic.Data.Currency); + + public Task GetXpBackgroundImageAsync() + => GetImageDataAsync(_ic.Data.Xp.Bg); + + public Task GetRategirlBgAsync() + => GetImageDataAsync(_ic.Data.Rategirl.Matrix); + + public Task GetRategirlDotAsync() + => GetImageDataAsync(_ic.Data.Rategirl.Dot); + + public Task GetDiceAsync(int num) + => GetImageDataAsync(_ic.Data.Dice[num]); + + public Task GetSlotEmojiAsync(int number) + => GetImageDataAsync(_ic.Data.Slots.Emojis[number]); + + public Task GetSlotBgAsync() + => GetImageDataAsync(_ic.Data.Slots.Bg); + + public Task GetRipBgAsync() + => GetImageDataAsync(_ic.Data.Rip.Bg); + + public Task GetRipOverlayAsync() + => GetImageDataAsync(_ic.Data.Rip.Overlay); +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/LocalDataCache.cs b/src/EllieBot/Services/Impl/LocalDataCache.cs new file mode 100644 index 0000000..111fd7a --- /dev/null +++ b/src/EllieBot/Services/Impl/LocalDataCache.cs @@ -0,0 +1,108 @@ +using EllieBot.Common.Pokemon; +using EllieBot.Modules.Games.Common.Trivia; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class LocalDataCache : ILocalDataCache, IEService +{ + private const string POKEMON_ABILITIES_FILE = "data/pokemon/pokemon_abilities.json"; + private const string POKEMON_LIST_FILE = "data/pokemon/pokemon_list.json"; + private const string POKEMON_MAP_PATH = "data/pokemon/name-id_map.json"; + private const string QUESTIONS_FILE = "data/trivia_questions.json"; + + private readonly IBotCache _cache; + + private readonly JsonSerializerOptions _opts = new JsonSerializerOptions() + { + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + PropertyNameCaseInsensitive = true + }; + + public LocalDataCache(IBotCache cache) + => _cache = cache; + + private async Task GetOrCreateCachedDataAsync( + TypedKey key, + string fileName) + => await _cache.GetOrAddAsync(key, + async () => + { + if (!File.Exists(fileName)) + { + Log.Warning($"{fileName} is missing. Relevant data can't be loaded"); + return default; + } + + try + { + await using var stream = File.OpenRead(fileName); + return await JsonSerializer.DeserializeAsync(stream, _opts); + } + catch (Exception ex) + { + Log.Error(ex, + "Error reading {FileName} file: {ErrorMessage}", + fileName, + ex.Message); + + return default; + } + }); + + + private static TypedKey> _pokemonListKey + = new("pokemon:list"); + + public async Task?> GetPokemonsAsync() + => await GetOrCreateCachedDataAsync(_pokemonListKey, POKEMON_LIST_FILE); + + + private static TypedKey> _pokemonAbilitiesKey + = new("pokemon:abilities"); + + public async Task?> GetPokemonAbilitiesAsync() + => await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE); + + + private static TypedKey> _pokeMapKey + = new("pokemon:ab_map2"); // 2 because ab_map was storing arrays + + public async Task?> GetPokemonMapAsync() + => await _cache.GetOrAddAsync(_pokeMapKey, + async () => + { + var fileName = POKEMON_MAP_PATH; + if (!File.Exists(fileName)) + { + Log.Warning($"{fileName} is missing. Relevant data can't be loaded"); + return default; + } + + try + { + await using var stream = File.OpenRead(fileName); + var arr = await JsonSerializer.DeserializeAsync(stream, _opts); + + return (IReadOnlyDictionary?)arr?.ToDictionary(x => x.Id, x => x.Name); + } + catch (Exception ex) + { + Log.Error(ex, + "Error reading {FileName} file: {ErrorMessage}", + fileName, + ex.Message); + + return default; + } + }); + + + private static TypedKey _triviaKey + = new("trivia:questions"); + + public async Task GetTriviaQuestionsAsync() + => await GetOrCreateCachedDataAsync(_triviaKey, QUESTIONS_FILE); +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/Localization.cs b/src/EllieBot/Services/Impl/Localization.cs new file mode 100644 index 0000000..3c2cb5b --- /dev/null +++ b/src/EllieBot/Services/Impl/Localization.cs @@ -0,0 +1,121 @@ +#nullable disable +using EllieBot.Db; +using Newtonsoft.Json; +using System.Globalization; + +namespace EllieBot.Services; + +public class Localization : ILocalization +{ + private static readonly Dictionary _commandData = + JsonConvert.DeserializeObject>( + File.ReadAllText("./data/strings/commands/commands.en-US.json")); + + private readonly ConcurrentDictionary _guildCultureInfos; + + public IDictionary GuildCultureInfos + => _guildCultureInfos; + + public CultureInfo DefaultCultureInfo + => _bss.Data.DefaultLocale; + + private readonly BotConfigService _bss; + private readonly DbService _db; + + public Localization(BotConfigService bss, Bot bot, DbService db) + { + _bss = bss; + _db = db; + + var cultureInfoNames = bot.AllGuildConfigs.ToDictionary(x => x.GuildId, x => x.Locale); + + _guildCultureInfos = new(cultureInfoNames + .ToDictionary(x => x.Key, + x => + { + CultureInfo cultureInfo = null; + try + { + if (x.Value is null) + return null; + cultureInfo = new(x.Value); + } + catch { } + + return cultureInfo; + }) + .Where(x => x.Value is not null)); + } + + public void SetGuildCulture(IGuild guild, CultureInfo ci) + => SetGuildCulture(guild.Id, ci); + + public void SetGuildCulture(ulong guildId, CultureInfo ci) + { + if (ci.Name == _bss.Data.DefaultLocale.Name) + { + RemoveGuildCulture(guildId); + return; + } + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set); + gc.Locale = ci.Name; + uow.SaveChanges(); + } + + _guildCultureInfos.AddOrUpdate(guildId, ci, (_, _) => ci); + } + + public void RemoveGuildCulture(IGuild guild) + => RemoveGuildCulture(guild.Id); + + public void RemoveGuildCulture(ulong guildId) + { + if (_guildCultureInfos.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + gc.Locale = null; + uow.SaveChanges(); + } + } + + public void SetDefaultCulture(CultureInfo ci) + => _bss.ModifyConfig(bs => + { + bs.DefaultLocale = ci; + }); + + public void ResetDefaultCulture() + => SetDefaultCulture(CultureInfo.CurrentCulture); + + public CultureInfo GetCultureInfo(IGuild guild) + => GetCultureInfo(guild?.Id); + + public CultureInfo GetCultureInfo(ulong? guildId) + { + if (guildId is null || !GuildCultureInfos.TryGetValue(guildId.Value, out var info) || info is null) + return _bss.Data.DefaultLocale; + + return info; + } + + public static CommandData LoadCommand(string key) + { + _commandData.TryGetValue(key, out var toReturn); + + if (toReturn is null) + { + return new() + { + Cmd = key, + Desc = key, + Usage = [key] + }; + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs b/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs new file mode 100644 index 0000000..413b8f8 --- /dev/null +++ b/src/EllieBot/Services/Impl/PubSub/JsonSeria.cs @@ -0,0 +1,27 @@ +using EllieBot.Common.JsonConverters; +using System.Text.Json; + +namespace EllieBot.Common; + +public class JsonSeria : ISeria +{ + private readonly JsonSerializerOptions _serializerOptions = new() + { + Converters = + { + new Rgba32Converter(), + new CultureInfoConverter() + } + }; + + public byte[] Serialize(T data) + => JsonSerializer.SerializeToUtf8Bytes(data, _serializerOptions); + + public T? Deserialize(byte[]? data) + { + if (data is null) + return default; + + return JsonSerializer.Deserialize(data, _serializerOptions); + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs b/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs new file mode 100644 index 0000000..fd4a36c --- /dev/null +++ b/src/EllieBot/Services/Impl/PubSub/RedisPubSub.cs @@ -0,0 +1,57 @@ +using StackExchange.Redis; + +namespace EllieBot.Common; + +public sealed class RedisPubSub : IPubSub +{ + private readonly IBotCredentials _creds; + private readonly ConnectionMultiplexer _multi; + private readonly ISeria _serializer; + + public RedisPubSub(ConnectionMultiplexer multi, ISeria serializer, IBotCredentials creds) + { + _multi = multi; + _serializer = serializer; + _creds = creds; + } + + public Task Pub(in TypedKey key, TData data) + where TData : notnull + { + var serialized = _serializer.Serialize(data); + return _multi.GetSubscriber() + .PublishAsync(new RedisChannel($"{_creds.RedisKey()}:{key.Key}", RedisChannel.PatternMode.Literal), + serialized, + CommandFlags.FireAndForget); + } + + public Task Sub(in TypedKey key, Func action) + where TData : notnull + { + var eventName = key.Key; + + async void OnSubscribeHandler(RedisChannel _, RedisValue data) + { + try + { + var dataObj = _serializer.Deserialize(data); + if (dataObj is not null) + await action(dataObj); + else + { + Log.Warning("Publishing event {EventName} with a null value. This is not allowed", + eventName); + } + } + catch (Exception ex) + { + Log.Error("Error handling the event {EventName}: {ErrorMessage}", eventName, ex.Message); + } + } + + return _multi.GetSubscriber() + .SubscribeAsync( + new RedisChannel($"{_creds.RedisKey()}:{eventName}", RedisChannel.PatternMode.Literal), + OnSubscribeHandler); + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs b/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs new file mode 100644 index 0000000..bedd0fa --- /dev/null +++ b/src/EllieBot/Services/Impl/PubSub/YamlSeria.cs @@ -0,0 +1,39 @@ +using EllieBot.Common.Configs; +using EllieBot.Common.Yml; +using System.Text.RegularExpressions; +using YamlDotNet.Serialization; + +namespace EllieBot.Common; + +public class YamlSeria : IConfigSeria +{ + private static readonly Regex _codePointRegex = + new(@"(\\U(?[a-zA-Z0-9]{8})|\\u(?[a-zA-Z0-9]{4})|\\x(?[a-zA-Z0-9]{2}))", + RegexOptions.Compiled); + + private readonly IDeserializer _deserializer; + private readonly ISerializer _serializer; + + public YamlSeria() + { + _serializer = Yaml.Serializer; + _deserializer = Yaml.Deserializer; + } + + public string Serialize(T obj) + where T : notnull + { + var escapedOutput = _serializer.Serialize(obj); + var output = _codePointRegex.Replace(escapedOutput, + me => + { + var str = me.Groups["code"].Value; + var newString = str.UnescapeUnicodeCodePoint(); + return newString; + }); + return output; + } + + public T Deserialize(string data) + => _deserializer.Deserialize(data); +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/RedisBotCache.cs b/src/EllieBot/Services/Impl/RedisBotCache.cs new file mode 100644 index 0000000..fffc727 --- /dev/null +++ b/src/EllieBot/Services/Impl/RedisBotCache.cs @@ -0,0 +1,119 @@ +using OneOf; +using OneOf.Types; +using StackExchange.Redis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Common; + +public sealed class RedisBotCache : IBotCache +{ + private static readonly Type[] _supportedTypes = + [ + typeof(bool), typeof(int), typeof(uint), typeof(long), + typeof(ulong), typeof(float), typeof(double), + typeof(string), typeof(byte[]), typeof(ReadOnlyMemory), typeof(Memory), + typeof(RedisValue) + ]; + + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + AllowTrailingCommas = true, + IgnoreReadOnlyProperties = false, + }; + private readonly ConnectionMultiplexer _conn; + + public RedisBotCache(ConnectionMultiplexer conn) + { + _conn = conn; + } + + public async ValueTask AddAsync(TypedKey key, T value, TimeSpan? expiry = null, bool overwrite = true) + { + // if a null value is passed, remove the key + if (value is null) + { + await RemoveAsync(key); + return false; + } + + var db = _conn.GetDatabase(); + RedisValue val = IsSupportedType(typeof(T)) + ? RedisValue.Unbox(value) + : JsonSerializer.Serialize(value, _opts); + + var success = await db.StringSetAsync(key.Key, + val, + expiry: expiry, + when: overwrite ? When.Always : When.NotExists); + + return success; + } + + public bool IsSupportedType(Type type) + { + if (type.IsGenericType) + { + var typeDef = type.GetGenericTypeDefinition(); + if (typeDef == typeof(Nullable<>)) + return IsSupportedType(type.GenericTypeArguments[0]); + } + + foreach (var t in _supportedTypes) + { + if (type == t) + return true; + } + + return false; + } + + public async ValueTask> GetAsync(TypedKey key) + { + var db = _conn.GetDatabase(); + var val = await db.StringGetAsync(key.Key); + if (val == default) + return new None(); + + if (IsSupportedType(typeof(T))) + return (T)((IConvertible)val).ToType(typeof(T), null); + + return JsonSerializer.Deserialize(val.ToString(), _opts)!; + } + + public async ValueTask RemoveAsync(TypedKey key) + { + var db = _conn.GetDatabase(); + + return await db.KeyDeleteAsync(key.Key); + } + + public async ValueTask GetOrAddAsync(TypedKey key, Func> createFactory, TimeSpan? expiry = null) + { + var result = await GetAsync(key); + + return await result.Match>( + v => Task.FromResult(v), + async _ => + { + var factoryValue = await createFactory(); + + if (factoryValue is null) + return default; + + await AddAsync(key, factoryValue, expiry); + + // get again to make sure it's the cached value + // and not the late factory value, in case there's a race condition + + var newResult = await GetAsync(key); + + // it's fine to do this, it should blow up if something went wrong. + return newResult.Match( + v => v, + _ => default); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs b/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs new file mode 100644 index 0000000..c0bef49 --- /dev/null +++ b/src/EllieBot/Services/Impl/RedisBotStringsProvider.cs @@ -0,0 +1,91 @@ +#nullable disable +using StackExchange.Redis; +using System.Text.Json; +using System.Web; + +namespace EllieBot.Services; + +/// +/// Uses to load strings into redis hash (only on Shard 0) +/// and retrieves them from redis via +/// +public class RedisBotStringsProvider : IBotStringsProvider +{ + private const string COMMANDS_KEY = "commands_v5"; + + private readonly ConnectionMultiplexer _redis; + private readonly IStringsSource _source; + private readonly IBotCredentials _creds; + + public RedisBotStringsProvider( + ConnectionMultiplexer redis, + DiscordSocketClient discordClient, + IStringsSource source, + IBotCredentials creds) + { + _redis = redis; + _source = source; + _creds = creds; + + if (discordClient.ShardId == 0) + Reload(); + } + + public string GetText(string localeName, string key) + { + var value = _redis.GetDatabase().HashGet($"{_creds.RedisKey()}:responses:{localeName}", key); + return value; + } + + public CommandStrings GetCommandStrings(string localeName, string commandName) + { + string examplesStr = _redis.GetDatabase() + .HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", + $"{commandName}::examples"); + if (examplesStr == default) + return null; + + var descStr = _redis.GetDatabase() + .HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::desc"); + if (descStr == default) + return null; + + var ex = examplesStr.Split('&').Map(HttpUtility.UrlDecode); + + var paramsStr = _redis.GetDatabase() + .HashGet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", $"{commandName}::params"); + if (paramsStr == default) + return null; + + return new() + { + Examples = ex, + Params = JsonSerializer.Deserialize[]>(paramsStr), + Desc = descStr + }; + } + + public void Reload() + { + var redisDb = _redis.GetDatabase(); + foreach (var (localeName, localeStrings) in _source.GetResponseStrings()) + { + var hashFields = localeStrings.Select(x => new HashEntry(x.Key, x.Value)).ToArray(); + + redisDb.HashSet($"{_creds.RedisKey()}:responses:{localeName}", hashFields); + } + + foreach (var (localeName, localeStrings) in _source.GetCommandStrings()) + { + var hashFields = localeStrings + .Select(x => new HashEntry($"{x.Key}::examples", + string.Join('&', x.Value.Examples.Map(HttpUtility.UrlEncode)))) + .Concat(localeStrings.Select(x => new HashEntry($"{x.Key}::desc", x.Value.Desc))) + .Concat(localeStrings.Select(x + => new HashEntry($"{x.Key}::params", JsonSerializer.Serialize(x.Value.Params)))) + .ToArray(); + + redisDb.HashSet($"{_creds.RedisKey()}:{COMMANDS_KEY}:{localeName}", hashFields); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs b/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs new file mode 100644 index 0000000..de56a39 --- /dev/null +++ b/src/EllieBot/Services/Impl/RemoteGrpcCoordinator.cs @@ -0,0 +1,132 @@ +#nullable disable +using Grpc.Core; +using Grpc.Net.Client; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Coordinator; + +namespace EllieBot.Services; + +public class RemoteGrpcCoordinator : ICoordinator, IReadyExecutor +{ + private readonly Coordinator.Coordinator.CoordinatorClient _coordClient; + private readonly DiscordSocketClient _client; + + public RemoteGrpcCoordinator(IBotCredentials creds, DiscordSocketClient client) + { + var coordUrl = string.IsNullOrWhiteSpace(creds.CoordinatorUrl) ? "http://localhost:3442" : creds.CoordinatorUrl; + + var channel = GrpcChannel.ForAddress(coordUrl); + _coordClient = new(channel); + _client = client; + } + + public bool RestartBot() + { + _coordClient.RestartAllShards(new()); + + return true; + } + + public void Die(bool graceful) + => _coordClient.Die(new() + { + Graceful = graceful + }); + + public bool RestartShard(int shardId) + { + _coordClient.RestartShard(new() + { + ShardId = shardId + }); + + return true; + } + + public IList GetAllShardStatuses() + { + var res = _coordClient.GetAllStatuses(new()); + + return res.Statuses.ToArray() + .Map(s => new ShardStatus + { + ConnectionState = FromCoordConnState(s.State), + GuildCount = s.GuildCount, + ShardId = s.ShardId, + LastUpdate = s.LastUpdate.ToDateTime() + }); + } + + public int GetGuildCount() + { + var res = _coordClient.GetAllStatuses(new()); + + return res.Statuses.Sum(x => x.GuildCount); + } + + public async Task Reload() + => await _coordClient.ReloadAsync(new()); + + public Task OnReadyAsync() + { + Task.Run(async () => + { + var gracefulImminent = false; + while (true) + { + try + { + var reply = await _coordClient.HeartbeatAsync(new() + { + State = ToCoordConnState(_client.ConnectionState), + GuildCount = + _client.ConnectionState == ConnectionState.Connected ? _client.Guilds.Count : 0, + ShardId = _client.ShardId + }, + deadline: DateTime.UtcNow + TimeSpan.FromSeconds(10)); + gracefulImminent = reply.GracefulImminent; + } + catch (RpcException ex) + { + if (!gracefulImminent) + { + Log.Warning(ex, + "Hearbeat failed and graceful shutdown was not expected: {Message}", + ex.Message); + break; + } + + Log.Information("Coordinator is restarting gracefully. Waiting..."); + await Task.Delay(30_000); + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected heartbeat exception: {Message}", ex.Message); + break; + } + + await Task.Delay(7500); + } + + Environment.Exit(5); + }); + + return Task.CompletedTask; + } + + private ConnState ToCoordConnState(ConnectionState state) + => state switch + { + ConnectionState.Connecting => ConnState.Connecting, + ConnectionState.Connected => ConnState.Connected, + _ => ConnState.Disconnected + }; + + private ConnectionState FromCoordConnState(ConnState state) + => state switch + { + ConnState.Connecting => ConnectionState.Connecting, + ConnState.Connected => ConnectionState.Connected, + _ => ConnectionState.Disconnected + }; +} \ No newline at end of file -- 2.43.0 From 044e4c87d9364980c92543618ac03bf8c55d2856 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:48:54 +1200 Subject: [PATCH 019/340] Added Core repo files --- .dockerignore | 13 ++++++++++++ Dockerfile | 46 +++++++++++++++++++++++++++++++++++++++++++ EllieBot.sln | 17 +++++++++------- NuGet.Config | 6 ++++++ README.md | 2 ++ docker-entrypoint.sh | 28 ++++++++++++++++++++++++++ migrate.ps1 | 9 +++++++++ remove-migrations.ps1 | 3 +++ 8 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 NuGet.Config create mode 100644 docker-entrypoint.sh create mode 100644 migrate.ps1 create mode 100644 remove-migrations.ps1 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c84eea1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Ignore all files +* + +# Don't ignore nugetconfig +!./NuGet.Config + +# Don't ignore src projects +!src/** +!docker-entrypoint.sh + +# ignore bin and obj folders in projects +src/**/bin/* +src/**/obj/* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e69de29..dcdedcf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /source + +COPY src/Ellie.Marmalade/*.csproj src/Ellie.Marmalade/ +COPY src/EllieBot/*.csproj src/EllieBot/ +COPY src/EllieBot.Coordinator/*.csproj src/EllieBot.Coordinator/ +COPY src/EllieBot.Generators/*.csproj src/EllieBot.Generators/ +COPY src/EllieBot.Voice/*.csproj src/EllieBot.Voice/ +COPY NuGet.Config ./ +RUN dotnet restore src/EllieBot/ + +COPY . . +WORKDIR /source/src/EllieBot +RUN set -xe; \ + dotnet --version; \ + dotnet publish -c Release -o /app --no-restore; \ + mv /app/data /app/data_init; \ + rm -Rf libopus* libsodium* opus.* runtimes/win* runtimes/osx* runtimes/linux-arm* runtimes/linux-mips*; \ + find /app -type f -exec chmod -x {} \; ;\ + chmod +x /app/EllieBot + +# final stage/image +FROM mcr.microsoft.com/dotnet/runtime:6.0 +WORKDIR /app + +RUN set -xe; \ + useradd -m ellie; \ + apt-get update; \ + apt-get install -y --no-install-recommends libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 sudo; \ + update-alternatives --install /usr/bin/python python /usr/bin/python3.9 1; \ + echo 'Defaults>ellie env_keep+="ASPNETCORE_* DOTNET_* EllieBot_* shard_id total_shards TZ"' > /etc/sudoers.d/ellie; \ + curl -Lo /usr/local/bin/yt-dlp https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp; \ + chmod a+rx /usr/local/bin/yt-dlp; \ + apt-get autoremove -y; \ + apt-get autoclean -y + +COPY --from=build /app ./ +COPY docker-entrypoint.sh /usr/local/sbin + +ENV shard_id=0 +ENV total_shards=1 +ENV EllieBot__creds=/app/data/creds.yml + +VOLUME [" /app/data "] +ENTRYPOINT [ "/usr/local/sbin/docker-entrypoint.sh" ] +CMD dotnet EllieBot.dll "$shard_id" "$total_shards" diff --git a/EllieBot.sln b/EllieBot.sln index 5a74f26..be95d55 100644 --- a/EllieBot.sln +++ b/EllieBot.sln @@ -10,11 +10,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution CHANGELOG.md = CHANGELOG.md Dockerfile = Dockerfile LICENSE = LICENSE + migrate.ps1 = migrate.ps1 + NuGet.Config = NuGet.Config README.md = README.md + remove-migrations.ps1 = remove-migrations.ps1 TODO.md = TODO.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{BCB21472-84D2-4B63-B5DD-31E6A3EC9791}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot", "src\EllieBot\EllieBot.csproj", "{4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Tests", "src\EllieBot.Tests\EllieBot.Tests.csproj", "{179DF3B3-AD32-4335-8231-9818338DF3A2}" EndProject @@ -26,7 +29,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.VotesApi", "src\El EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ellie.Marmalade", "src\Ellie.Marmalade\Ellie.Marmalade.csproj", "{76AC715D-12FF-4CBE-9585-A861139A2D0C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EllieBot.Voice", "src\EllieBot.Voice\EllieBot.Voice.csproj", "{1D93CE3C-80B4-49C7-A9A2-99988920AAEC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieBot.Voice", "src\EllieBot.Voice\EllieBot.Voice.csproj", "{1D93CE3C-80B4-49C7-A9A2-99988920AAEC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -34,10 +37,10 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791}.Release|Any CPU.Build.0 = Release|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64}.Release|Any CPU.Build.0 = Release|Any CPU {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {179DF3B3-AD32-4335-8231-9818338DF3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU {179DF3B3-AD32-4335-8231-9818338DF3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -67,7 +70,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {BCB21472-84D2-4B63-B5DD-31E6A3EC9791} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} + {4D9001F7-B3E8-48FE-97AA-CFD36DA65A64} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {179DF3B3-AD32-4335-8231-9818338DF3A2} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {A631DDF0-3AD1-4CB9-8458-314B1320868A} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} {CB1A5307-DD85-4795-8A8A-A25D36DADC51} = {B28FB883-9688-41EB-BF5A-945F4A4EB628} diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..7e64704 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + diff --git a/README.md b/README.md index 0f9c1bd..452254f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Ellie +[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page) + ## Small disclaimer All the code in this repo may not be production ready yet and if you want to try and run a version of this by yourself you are on your own. diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..3f3fbd3 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -e; + +data_init=/app/data_init +data=/app/data + +# populate /app/data if empty +for i in $(ls $data_init) +do + if [ ! -e "$data/$i" ]; then + [ -f "$data_init/$i" ] && cp "$data_init/$i" "$data/$i" + [ -d "$data_init/$i" ] && cp -r "$data_init/$i" "$data/$i" + fi +done + +# creds.yml migration +if [ -f /app/creds.yml ]; then + echo "Default location for creds.yml is now /app/data/creds.yml." + echo "Please move your creds.yml and update your docker-compose.yml accordingly." + + export Ellie_creds=/app/creds.yml +fi + +# ensure ellie can write on /app/data +chown -R ellie:ellie "$data" + +# drop to regular user and launch command +exec sudo -u ellie "$@" \ No newline at end of file diff --git a/migrate.ps1 b/migrate.ps1 new file mode 100644 index 0000000..a5ff6c4 --- /dev/null +++ b/migrate.ps1 @@ -0,0 +1,9 @@ +if ($args.Length -eq 0) { + Write-Host "Please provide a migration name." -ForegroundColor Red +} +else { + $migrationName = $args[0] + dotnet ef migrations add $migrationName -c SqliteContext -p src/EllieBot/EllieBot.csproj + dotnet ef migrations add $migrationName -c PostgreSqlContext -p src/EllieBot/EllieBot.csproj + dotnet ef migrations add $migrationName -c MysqlContext -p src/EllieBot/EllieBot.csproj +} \ No newline at end of file diff --git a/remove-migrations.ps1 b/remove-migrations.ps1 new file mode 100644 index 0000000..5445dbb --- /dev/null +++ b/remove-migrations.ps1 @@ -0,0 +1,3 @@ +dotnet ef migrations remove -c SqliteContext -f -p src/EllieBot/EllieBot.csproj +dotnet ef migrations remove -c PostgreSqlContext -f -p src/EllieBot/EllieBot.csproj +dotnet ef migrations remove -c MysqlContext -f -p src/EllieBot/EllieBot.csproj \ No newline at end of file -- 2.43.0 From eebc1acbb329e1b54b14bb3f2a2118819f22c83e Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:50:02 +1200 Subject: [PATCH 020/340] Added Administration module --- .../Modules/Administration/Administration.cs | 499 +++++++ .../Administration/AdministrationService.cs | 206 +++ .../AutoAssignRoleCommands.cs | 60 + .../AutoAssignRoleService.cs | 159 ++ .../Administration/AutoPublishService.cs | 87 ++ .../DangerousCommands/CleanupCommands.cs | 31 + .../DangerousCommands/CleanupService.cs | 106 ++ .../DangerousCommands/DangerousCommands.cs | 164 +++ .../DangerousCommandsService.cs | 103 ++ .../DangerousCommands/_common/CleanupId.cs | 9 + .../_common/ICleanupService.cs | 6 + .../DangerousCommands/_common/KeepReport.cs | 7 + .../DangerousCommands/_common/KeepResult.cs | 6 + .../GameVoiceChannelCommands.cs | 36 + .../GameVoiceChannelService.cs | 127 ++ .../Administration/GreetBye/GreetCommands.cs | 229 +++ .../Administration/GreetBye/GreetGrouper.cs | 71 + .../Administration/GreetBye/GreetService.cs | 662 +++++++++ .../Administration/GreetBye/GreetSettings.cs | 45 + .../Administration/ImageOnlyChannelService.cs | 235 +++ .../Administration/LocalizationCommands.cs | 264 ++++ .../Administration/Mute/MuteCommands.cs | 231 +++ .../Administration/Mute/MuteService.cs | 504 +++++++ .../DiscordPermOverrideCommands.cs | 83 ++ .../PlayingRotate/PlayingRotateCommands.cs | 62 + .../PlayingRotate/PlayingRotateService.cs | 109 ++ .../Administration/Prefix/PrefixCommands.cs | 57 + .../Protection/ProtectionCommands.cs | 292 ++++ .../Protection/ProtectionService.cs | 499 +++++++ .../Protection/ProtectionStats.cs | 52 + .../Protection/PunishQueueItem.cs | 13 + .../Protection/UserSpamStats.cs | 64 + .../Administration/Prune/PruneCommands.cs | 198 +++ .../Administration/Prune/PruneService.cs | 101 ++ .../Role/IReactionRoleService.cs | 52 + .../Role/ReactionRoleCommands.cs | 176 +++ .../Role/ReactionRolesService.cs | 408 ++++++ .../Administration/Role/RoleCommands.cs | 209 +++ .../Administration/Role/StickyRolesService.cs | 139 ++ .../Self/CheckForUpdatesService.cs | 169 +++ .../Administration/Self/SelfCommands.cs | 586 ++++++++ .../Administration/Self/SelfService.cs | 485 ++++++ .../SelfAssignedRolesCommands.cs | 239 +++ .../SelfAssignedRolesService.cs | 234 +++ .../ServerLog/DummyLogCommandService.cs | 25 + .../ServerLog/ServerLogCommandService.cs | 1297 +++++++++++++++++ .../ServerLog/ServerLogCommands.cs | 175 +++ .../Timezone/GuildTimezoneService.cs | 95 ++ .../Timezone/TimeZoneCommands.cs | 78 + .../UserPunish/UserPunishCommands.cs | 960 ++++++++++++ .../UserPunish/UserPunishService.cs | 597 ++++++++ .../Administration/VcRole/VcRoleCommands.cs | 77 + .../Administration/VcRole/VcRoleService.cs | 208 +++ .../_common/SetServerBannerResult.cs | 9 + .../_common/SetServerIconResult.cs | 8 + 55 files changed, 11603 insertions(+) create mode 100644 src/EllieBot/Modules/Administration/Administration.cs create mode 100644 src/EllieBot/Modules/Administration/AdministrationService.cs create mode 100644 src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs create mode 100644 src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs create mode 100644 src/EllieBot/Modules/Administration/AutoPublishService.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs create mode 100644 src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs create mode 100644 src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs create mode 100644 src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs create mode 100644 src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs create mode 100644 src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs create mode 100644 src/EllieBot/Modules/Administration/GreetBye/GreetService.cs create mode 100644 src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs create mode 100644 src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs create mode 100644 src/EllieBot/Modules/Administration/LocalizationCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Mute/MuteCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Mute/MuteService.cs create mode 100644 src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs create mode 100644 src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs create mode 100644 src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs create mode 100644 src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Protection/ProtectionService.cs create mode 100644 src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs create mode 100644 src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs create mode 100644 src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs create mode 100644 src/EllieBot/Modules/Administration/Prune/PruneCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Prune/PruneService.cs create mode 100644 src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs create mode 100644 src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs create mode 100644 src/EllieBot/Modules/Administration/Role/RoleCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Role/StickyRolesService.cs create mode 100644 src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs create mode 100644 src/EllieBot/Modules/Administration/Self/SelfCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Self/SelfService.cs create mode 100644 src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs create mode 100644 src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs create mode 100644 src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs create mode 100644 src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs create mode 100644 src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs create mode 100644 src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs create mode 100644 src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs create mode 100644 src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs create mode 100644 src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs create mode 100644 src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs create mode 100644 src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs create mode 100644 src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs create mode 100644 src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs diff --git a/src/EllieBot/Modules/Administration/Administration.cs b/src/EllieBot/Modules/Administration/Administration.cs new file mode 100644 index 0000000..9e18e8b --- /dev/null +++ b/src/EllieBot/Modules/Administration/Administration.cs @@ -0,0 +1,499 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration._common.results; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration : EllieModule +{ + public enum Channel + { + Channel, + Ch, + Chnl, + Chan + } + + public enum List + { + List = 0, + Ls = 0 + } + + public enum Server + { + Server + } + + public enum State + { + Enable, + Disable, + Inherit + } + + private readonly SomethingOnlyChannelService _somethingOnly; + private readonly AutoPublishService _autoPubService; + + public Administration(SomethingOnlyChannelService somethingOnly, AutoPublishService autoPubService) + { + _somethingOnly = somethingOnly; + _autoPubService = autoPubService; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task ImageOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleImageOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await Response().Confirm(strs.imageonly_enable).SendAsync(); + else + await Response().Pending(strs.imageonly_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageGuild)] + public async Task LinkOnlyChannel(StoopidTime time = null) + { + var newValue = await _somethingOnly.ToggleLinkOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + if (newValue) + await Response().Confirm(strs.linkonly_enable).SendAsync(); + else + await Response().Pending(strs.linkonly_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageChannels)] + [BotPerm(ChannelPerm.ManageChannels)] + public async Task Slowmode(StoopidTime time = null) + { + var seconds = (int?)time?.Time.TotalSeconds ?? 0; + if (time is not null && (time.Time < TimeSpan.FromSeconds(0) || time.Time > TimeSpan.FromHours(6))) + return; + + await ((ITextChannel)ctx.Channel).ModifyAsync(tcp => + { + tcp.SlowModeInterval = seconds; + }); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public async Task Delmsgoncmd(List _) + { + var guild = (SocketGuild)ctx.Guild; + var (enabled, channels) = _service.GetDelMsgOnCmdData(ctx.Guild.Id); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.server_delmsgoncmd)) + .WithDescription(enabled ? "✅" : "❌"); + + var str = string.Join("\n", + channels.Select(x => + { + var ch = guild.GetChannel(x.ChannelId)?.ToString() ?? x.ChannelId.ToString(); + var prefixSign = x.State ? "✅ " : "❌ "; + return prefixSign + ch; + })); + + if (string.IsNullOrWhiteSpace(str)) + str = "-"; + + embed.AddField(GetText(strs.channel_delmsgoncmd), str); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Server _ = Server.Server) + { + if (_service.ToggleDeleteMessageOnCommand(ctx.Guild.Id)) + { + _service.DeleteMessagesOnCommand.Add(ctx.Guild.Id); + await Response().Confirm(strs.delmsg_on).SendAsync(); + } + else + { + _service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id); + await Response().Confirm(strs.delmsg_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Delmsgoncmd(Channel _, State s, ITextChannel ch) + => Delmsgoncmd(_, s, ch.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Delmsgoncmd(Channel _, State s, ulong? chId = null) + { + var actualChId = chId ?? ctx.Channel.Id; + await _service.SetDelMsgOnCmdState(ctx.Guild.Id, actualChId, s); + + if (s == State.Disable) + await Response().Confirm(strs.delmsg_channel_off).SendAsync(); + else if (s == State.Enable) + await Response().Confirm(strs.delmsg_channel_on).SendAsync(); + else + await Response().Confirm(strs.delmsg_channel_inherit).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task Deafen(params IGuildUser[] users) + { + await _service.DeafenUsers(true, users); + await Response().Confirm(strs.deafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.DeafenMembers)] + [BotPerm(GuildPerm.DeafenMembers)] + public async Task UnDeafen(params IGuildUser[] users) + { + await _service.DeafenUsers(false, users); + await Response().Confirm(strs.undeafen).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel) + { + await voiceChannel.DeleteAsync(); + await Response().Confirm(strs.delvoich(Format.Bold(voiceChannel.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreatVoiChanl([Leftover] string channelName) + { + var ch = await ctx.Guild.CreateVoiceChannelAsync(channelName); + await Response().Confirm(strs.createvoich(Format.Bold(ch.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task DelTxtChanl([Leftover] ITextChannel toDelete) + { + await toDelete.DeleteAsync(new RequestOptions() + { + AuditLogReason = $"Deleted by {ctx.User.Username}" + }); + await Response().Confirm(strs.deltextchan(Format.Bold(toDelete.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task CreaTxtChanl([Leftover] string channelName) + { + var txtCh = await ctx.Guild.CreateTextChannelAsync(channelName); + await Response().Confirm(strs.createtextchan(Format.Bold(txtCh.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetTopic([Leftover] string topic = null) + { + var channel = (ITextChannel)ctx.Channel; + topic ??= ""; + await channel.ModifyAsync(c => c.Topic = topic); + await Response().Confirm(strs.set_topic).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task SetChanlName([Leftover] string name) + { + var channel = (ITextChannel)ctx.Channel; + await channel.ModifyAsync(c => c.Name = name); + await Response().Confirm(strs.set_channel_name).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageChannels)] + [BotPerm(GuildPerm.ManageChannels)] + public async Task AgeRestrictToggle() + { + var channel = (ITextChannel)ctx.Channel; + var isEnabled = channel.IsNsfw; + + await channel.ModifyAsync(c => c.IsNsfw = !isEnabled); + + if (isEnabled) + await Response().Confirm(strs.nsfw_set_false).SendAsync(); + else + await Response().Confirm(strs.nsfw_set_true).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [Priority(0)] + public Task Edit(ulong messageId, [Leftover] string text) + => Edit((ITextChannel)ctx.Channel, messageId, text); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task Edit(ITextChannel channel, ulong messageId, [Leftover] string text) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ViewChannel)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + await _service.EditMessage(ctx, channel, messageId, text); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public Task Delete(ulong messageId, StoopidTime time = null) + => Delete((ITextChannel)ctx.Channel, messageId, time); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Delete(ITextChannel channel, ulong messageId, StoopidTime time = null) + => await InternalMessageAction(channel, messageId, time, msg => msg.DeleteAsync()); + + private async Task InternalMessageAction( + ITextChannel channel, + ulong messageId, + StoopidTime time, + Func func) + { + var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel); + var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel); + if (!userPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!botPerms.Has(ChannelPermission.ManageMessages)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + + var msg = await channel.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.msg_not_found).SendAsync(); + return; + } + + if (time is null) + await msg.DeleteAsync(); + else if (time.Time <= TimeSpan.FromDays(7)) + { + _ = Task.Run(async () => + { + await Task.Delay(time.Time); + await msg.DeleteAsync(); + }); + } + else + { + await Response().Error(strs.time_too_long).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.CreatePublicThreads)] + [UserPerm(ChannelPermission.CreatePublicThreads)] + public async Task ThreadCreate([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + await stc.CreateThreadAsync(name, message: ctx.Message.ReferencedMessage); + await ctx.OkAsync(); + } + + [Cmd] + [BotPerm(ChannelPermission.ManageThreads)] + [UserPerm(ChannelPermission.ManageThreads)] + public async Task ThreadDelete([Leftover] string name) + { + if (ctx.Channel is not SocketTextChannel stc) + return; + + var t = stc.Threads.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.InvariantCultureIgnoreCase)); + + if (t is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + await t.DeleteAsync(); + await ctx.OkAsync(); + } + + [Cmd] + [UserPerm(ChannelPerm.ManageMessages)] + public async Task AutoPublish() + { + if (ctx.Channel.GetChannelType() != ChannelType.News) + { + await Response().Error(strs.req_announcement_channel).SendAsync(); + return; + } + + var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id); + + if (newState) + { + await Response().Confirm(strs.autopublish_enable).SendAsync(); + } + else + { + await Response().Confirm(strs.autopublish_disable).SendAsync(); + } + } + + [Cmd] + [UserPerm(GuildPerm.ManageNicknames)] + [BotPerm(GuildPerm.ChangeNickname)] + [Priority(0)] + public async Task SetNick([Leftover] string newNick = null) + { + if (string.IsNullOrWhiteSpace(newNick)) + return; + var curUser = await ctx.Guild.GetCurrentUserAsync(); + await curUser.ModifyAsync(u => u.Nickname = newNick); + + await Response().Confirm(strs.bot_nick(Format.Bold(newNick) ?? "-")).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageNicknames)] + [UserPerm(GuildPerm.ManageNicknames)] + [Priority(1)] + public async Task SetNick(IGuildUser gu, [Leftover] string newNick = null) + { + var sg = (SocketGuild)ctx.Guild; + if (sg.OwnerId == gu.Id + || gu.GetRoles().Max(r => r.Position) >= sg.CurrentUser.GetRoles().Max(r => r.Position)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + await gu.ModifyAsync(u => u.Nickname = newNick); + + await Response() + .Confirm(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-")) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerBanner([Leftover] string img = null) + { + // Tier2 or higher is required to set a banner. + if (ctx.Guild.PremiumTier is PremiumTier.Tier1 or PremiumTier.None) return; + + var result = await _service.SetServerBannerAsync(ctx.Guild, img); + + switch (result) + { + case SetServerBannerResult.Success: + await Response().Confirm(strs.set_srvr_banner).SendAsync(); + break; + case SetServerBannerResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerBannerResult.Toolarge: + await Response().Error(strs.srvr_banner_too_large).SendAsync(); + break; + case SetServerBannerResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPermission.ManageGuild)] + [BotPerm(GuildPermission.ManageGuild)] + public async Task SetServerIcon([Leftover] string img = null) + { + var result = await _service.SetServerIconAsync(ctx.Guild, img); + + switch (result) + { + case SetServerIconResult.Success: + await Response().Confirm(strs.set_srvr_icon).SendAsync(); + break; + case SetServerIconResult.InvalidFileType: + await Response().Error(strs.srvr_banner_invalid).SendAsync(); + break; + case SetServerIconResult.InvalidURL: + await Response().Error(strs.srvr_banner_invalid_url).SendAsync(); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AdministrationService.cs b/src/EllieBot/Modules/Administration/AdministrationService.cs new file mode 100644 index 0000000..de037bd --- /dev/null +++ b/src/EllieBot/Modules/Administration/AdministrationService.cs @@ -0,0 +1,206 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Administration._common.results; + +namespace EllieBot.Modules.Administration.Services; + +public class AdministrationService : IEService +{ + public ConcurrentHashSet DeleteMessagesOnCommand { get; } + public ConcurrentDictionary DeleteMessagesOnCommandChannels { get; } + + private readonly DbService _db; + private readonly IReplacementService _repSvc; + private readonly ILogCommandService _logService; + private readonly IHttpClientFactory _httpFactory; + + public AdministrationService( + IBot bot, + CommandHandler cmdHandler, + DbService db, + IReplacementService repSvc, + ILogCommandService logService, + IHttpClientFactory factory) + { + _db = db; + _repSvc = repSvc; + _logService = logService; + _httpFactory = factory; + + DeleteMessagesOnCommand = new(bot.AllGuildConfigs.Where(g => g.DeleteMessageOnCommand).Select(g => g.GuildId)); + + DeleteMessagesOnCommandChannels = new(bot.AllGuildConfigs.SelectMany(x => x.DelMsgOnCmdChannels) + .ToDictionary(x => x.ChannelId, x => x.State) + .ToConcurrent()); + + cmdHandler.CommandExecuted += DelMsgOnCmd_Handler; + } + + public (bool DelMsgOnCmd, IEnumerable channels) GetDelMsgOnCmdData(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + return (conf.DeleteMessageOnCommand, conf.DelMsgOnCmdChannels); + } + + private Task DelMsgOnCmd_Handler(IUserMessage msg, CommandInfo cmd) + { + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + //wat ?! + if (DeleteMessagesOnCommandChannels.TryGetValue(channel.Id, out var state)) + { + if (state && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + //if state is false, that means do not do it + } + else if (DeleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick") + { + _logService.AddDeleteIgnore(msg.Id); + try { await msg.DeleteAsync(); } + catch { } + } + }); + return Task.CompletedTask; + } + + public bool ToggleDeleteMessageOnCommand(ulong guildId) + { + bool enabled; + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + enabled = conf.DeleteMessageOnCommand = !conf.DeleteMessageOnCommand; + + uow.SaveChanges(); + return enabled; + } + + public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState) + { + await using (var uow = _db.GetDbContext()) + { + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels)); + + var old = conf.DelMsgOnCmdChannels.FirstOrDefault(x => x.ChannelId == chId); + if (newState == Administration.State.Inherit) + { + if (old is not null) + { + conf.DelMsgOnCmdChannels.Remove(old); + uow.Remove(old); + } + } + else + { + if (old is null) + { + old = new() + { + ChannelId = chId + }; + conf.DelMsgOnCmdChannels.Add(old); + } + + old.State = newState == Administration.State.Enable; + DeleteMessagesOnCommandChannels[chId] = newState == Administration.State.Enable; + } + + await uow.SaveChangesAsync(); + } + + if (newState == Administration.State.Disable) + { + } + else if (newState == Administration.State.Enable) + DeleteMessagesOnCommandChannels[chId] = true; + else + DeleteMessagesOnCommandChannels.TryRemove(chId, out _); + } + + public async Task DeafenUsers(bool value, params IGuildUser[] users) + { + if (!users.Any()) + return; + foreach (var u in users) + { + try + { + await u.ModifyAsync(usr => usr.Deaf = value); + } + catch + { + // ignored + } + } + } + + public async Task EditMessage( + ICommandContext context, + ITextChannel chanl, + ulong messageId, + string input) + { + var msg = await chanl.GetMessageAsync(messageId); + + if (msg is not IUserMessage umsg || msg.Author.Id != context.Client.CurrentUser.Id) + return; + + var repCtx = new ReplacementContext(context); + + var text = SmartText.CreateFrom(input); + text = await _repSvc.ReplaceAsync(text, repCtx); + + await umsg.EditAsync(text); + } + + public async Task SetServerBannerAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerBannerResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerBannerResult.InvalidFileType; + + if (sr.GetContentLength() > 8.Megabytes()) + { + return SetServerBannerResult.Toolarge; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Banner = new Image(imageStream)); + return SetServerBannerResult.Success; + } + + public async Task SetServerIconAsync(IGuild guild, string img) + { + if (!IsValidUri(img)) return SetServerIconResult.InvalidURL; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) return SetServerIconResult.InvalidFileType; + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await guild.ModifyAsync(x => x.Icon = new Image(imageStream)); + return SetServerIconResult.Success; + } + + private bool IsValidUri(string img) => !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs new file mode 100644 index 0000000..dc687cc --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class AutoAssignRoleCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (role.Id == ctx.Guild.EveryoneRole.Id) + return; + + // the user can't aar the role which is higher or equal to his highest role + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + return; + } + + var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id); + if (roles.Count == 0) + await Response().Confirm(strs.aar_disabled).SendAsync(); + else if (roles.Contains(role.Id)) + await AutoAssignRole(); + else + await Response().Confirm(strs.aar_role_removed(Format.Bold(role.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task AutoAssignRole() + { + if (!_service.TryGetRoles(ctx.Guild.Id, out var roles)) + { + await Response().Confirm(strs.aar_none).SendAsync(); + return; + } + + var existing = roles.Select(rid => ctx.Guild.GetRole(rid)).Where(r => r is not null).ToList(); + + if (existing.Count != roles.Count) + await _service.SetAarRolesAsync(ctx.Guild.Id, existing.Select(x => x.Id)); + + await Response() + .Confirm(strs.aar_roles( + '\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n"))) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs new file mode 100644 index 0000000..f373d45 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoAssignableRoles/AutoAssignRoleService.cs @@ -0,0 +1,159 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Net; +using System.Threading.Channels; +using LinqToDB; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class AutoAssignRoleService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly DbService _db; + + //guildid/roleid + private readonly ConcurrentDictionary> _autoAssignableRoles; + + private readonly Channel _assignQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + public AutoAssignRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _client = client; + _db = db; + + _autoAssignableRoles = bot.AllGuildConfigs.Where(x => !string.IsNullOrWhiteSpace(x.AutoAssignRoleIds)) + .ToDictionary>(k => k.GuildId, + v => v.GetAutoAssignableRoles()) + .ToConcurrent(); + + _ = Task.Run(async () => + { + while (true) + { + var user = await _assignQueue.Reader.ReadAsync(); + if (!_autoAssignableRoles.TryGetValue(user.Guild.Id, out var savedRoleIds)) + continue; + + try + { + var roleIds = savedRoleIds.Select(roleId => user.Guild.GetRole(roleId)) + .Where(x => x is not null) + .ToList(); + + if (roleIds.Any()) + { + await user.AddRolesAsync(roleIds); + await Task.Delay(250); + } + else + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server the roles dont exist", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + Log.Warning( + "Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server because I don't have role management permissions", + user.Guild.Name, + user.Guild.Id); + + await DisableAarAsync(user.Guild.Id); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in aar. Probably one of the roles doesn't exist"); + } + } + }); + + _client.UserJoined += OnClientOnUserJoined; + _client.RoleDeleted += OnClientRoleDeleted; + } + + private async Task OnClientRoleDeleted(SocketRole role) + { + if (_autoAssignableRoles.TryGetValue(role.Guild.Id, out var roles) && roles.Contains(role.Id)) + await ToggleAarAsync(role.Guild.Id, role.Id); + } + + private async Task OnClientOnUserJoined(SocketGuildUser user) + { + if (_autoAssignableRoles.TryGetValue(user.Guild.Id, out _)) + await _assignQueue.Writer.WriteAsync(user); + } + + public async Task> ToggleAarAsync(ulong guildId, ulong roleId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var roles = gc.GetAutoAssignableRoles(); + if (!roles.Remove(roleId) && roles.Count < 3) + roles.Add(roleId); + + gc.SetAutoAssignableRoles(roles); + await uow.SaveChangesAsync(); + + if (roles.Count > 0) + _autoAssignableRoles[guildId] = roles; + else + _autoAssignableRoles.TryRemove(guildId, out _); + + return roles; + } + + public async Task DisableAarAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + + await uow.Set().AsNoTracking() + .Where(x => x.GuildId == guildId) + .UpdateAsync(_ => new() + { + AutoAssignRoleIds = null + }); + + _autoAssignableRoles.TryRemove(guildId, out _); + + await uow.SaveChangesAsync(); + } + + public async Task SetAarRolesAsync(ulong guildId, IEnumerable newRoles) + { + await using var uow = _db.GetDbContext(); + + var gc = uow.GuildConfigsForId(guildId, set => set); + gc.SetAutoAssignableRoles(newRoles); + + await uow.SaveChangesAsync(); + } + + public bool TryGetRoles(ulong guildId, out IReadOnlyList roles) + => _autoAssignableRoles.TryGetValue(guildId, out roles); +} + +public static class GuildConfigExtensions +{ + public static List GetAutoAssignableRoles(this GuildConfig gc) + { + if (string.IsNullOrWhiteSpace(gc.AutoAssignRoleIds)) + return new(); + + return gc.AutoAssignRoleIds.Split(',').Select(ulong.Parse).ToList(); + } + + public static void SetAutoAssignableRoles(this GuildConfig gc, IEnumerable roles) + => gc.AutoAssignRoleIds = roles.Join(','); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/AutoPublishService.cs b/src/EllieBot/Modules/Administration/AutoPublishService.cs new file mode 100644 index 0000000..8f29495 --- /dev/null +++ b/src/EllieBot/Modules/Administration/AutoPublishService.cs @@ -0,0 +1,87 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class AutoPublishService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredsProvider _creds; + private ConcurrentDictionary _enabled; + + public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds) + { + _db = db; + _client = client; + _creds = creds; + } + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (guild is null) + return; + + if (msg.Channel.GetChannelType() != ChannelType.News) + return; + + if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id) + return; + + await msg.CrosspostAsync(new RequestOptions() + { + RetryMode = RetryMode.AlwaysFail + }); + } + + public async Task OnReadyAsync() + { + var creds = _creds.GetCreds(); + + await using var ctx = _db.GetDbContext(); + var items = await ctx.GetTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + _enabled = items + .ToDictionary(x => x.GuildId, x => x.ChannelId) + .ToConcurrent(); + } + + public async Task ToggleAutoPublish(ulong guildId, ulong channelId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId); + + if (deleted != 0) + { + _enabled.TryRemove(guildId, out _); + return false; + } + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + old => new() + { + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }, + () => new() + { + GuildId = guildId + }); + + _enabled[guildId] = channelId; + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs new file mode 100644 index 0000000..8fee8a3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupCommands.cs @@ -0,0 +1,31 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public partial class Administration +{ + [Group] + public class CleanupCommands : CleanupModuleBase + { + private readonly ICleanupService _svc; + + public CleanupCommands(ICleanupService svc) + => _svc = svc; + + [Cmd] + [OwnerOnly] + [RequireContext(ContextType.DM)] + public async Task CleanupGuildData() + { + var result = await _svc.DeleteMissingGuildDataAsync(); + + if (result is null) + { + await ctx.ErrorAsync(); + return; + } + + await Response() + .Confirm($"{result.GuildCount} guilds' data remain in the database.") + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs new file mode 100644 index 0000000..3555a61 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/CleanupService.cs @@ -0,0 +1,106 @@ +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupService : ICleanupService, IReadyExecutor, IEService +{ + private readonly IPubSub _pubSub; + private TypedKey _keepReportKey = new("cleanup:report"); + private TypedKey _keepTriggerKey = new("cleanup:trigger"); + private readonly DiscordSocketClient _client; + private ConcurrentDictionary guildIds = new(); + private readonly IBotCredsProvider _creds; + private readonly DbService _db; + + public CleanupService( + IPubSub pubSub, + DiscordSocketClient client, + IBotCredsProvider creds, + DbService db) + { + _pubSub = pubSub; + _client = client; + _creds = creds; + _db = db; + } + + public async Task DeleteMissingGuildDataAsync() + { + guildIds = new(); + var totalShards = _creds.GetCreds().TotalShards; + await _pubSub.Pub(_keepTriggerKey, true); + var counter = 0; + while (guildIds.Keys.Count < totalShards) + { + await Task.Delay(1000); + counter++; + + if (counter >= 5) + break; + } + + if (guildIds.Keys.Count < totalShards) + return default; + + var allIds = guildIds.SelectMany(x => x.Value) + .ToArray(); + + await using var ctx = _db.GetDbContext(); + await using var linqCtx = ctx.CreateLinqToDBContext(); + await using var tempTable = linqCtx.CreateTempTable(); + + foreach (var chunk in allIds.Chunk(20000)) + { + await tempTable.BulkCopyAsync(chunk.Select(x => new CleanupId() + { + GuildId = x + })); + } + + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + + await ctx.GetTable() + .Where(x => !tempTable.Select(x => x.GuildId) + .Contains(x.GuildId)) + .DeleteAsync(); + + return new() + { + GuildCount = guildIds.Keys.Count, + }; + } + + private ValueTask OnKeepReport(KeepReport report) + { + guildIds[report.ShardId] = report.GuildIds; + return default; + } + + public async Task OnReadyAsync() + { + await _pubSub.Sub(_keepTriggerKey, OnKeepTrigger); + + if (_client.ShardId == 0) + await _pubSub.Sub(_keepReportKey, OnKeepReport); + } + + private ValueTask OnKeepTrigger(bool arg) + { + _pubSub.Pub(_keepReportKey, + new KeepReport() + { + ShardId = _client.ShardId, + GuildIds = _client.GetGuildIds(), + }); + + return default; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs new file mode 100644 index 0000000..80484c3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommands.cs @@ -0,0 +1,164 @@ +#nullable disable +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Administration.Services; +using EllieBot.Modules.Xp; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + [OwnerOnly] + [NoPublicBot] + public partial class DangerousCommands : CleanupModuleBase + { + private readonly DangerousCommandsService _ds; + private readonly IGamblingCleanupService _gcs; + private readonly IXpCleanupService _xcs; + + public DangerousCommands( + DangerousCommandsService ds, + IGamblingCleanupService gcs, + IXpCleanupService xcs) + { + _ds = ds; + _gcs = gcs; + _xcs = xcs; + } + + [Cmd] + [OwnerOnly] + public Task SqlSelect([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + return Response() + .Paginated() + .Items(result.Results) + .PageSize(20) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithFooter(sql).WithDescription("-"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithFooter(sql) + .WithTitle(string.Join(" ║ ", result.ColumnNames)) + .WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x)))); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SqlSelectCsv([Leftover] string sql) + { + var result = _ds.SelectSql(sql); + + // create a file stream and write the data as csv + using var ms = new MemoryStream(); + await using var sw = new StreamWriter(ms); + await using var csv = new CsvWriter(sw, + new CsvConfiguration(CultureInfo.InvariantCulture) + { + Delimiter = "," + }); + + foreach (var cn in result.ColumnNames) + { + csv.WriteField(cn); + } + + await csv.NextRecordAsync(); + + foreach (var row in result.Results) + { + foreach (var field in row) + { + csv.WriteField(field); + } + + await csv.NextRecordAsync(); + } + + + await csv.FlushAsync(); + ms.Position = 0; + + // send the file + await ctx.Channel.SendFileAsync(ms, $"query_result_{DateTime.UtcNow.Ticks}.csv"); + } + + [Cmd] + [OwnerOnly] + public async Task SqlExec([Leftover] string sql) + { + try + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.sql_confirm_exec)) + .WithDescription(Format.Code(sql)); + + if (!await PromptUserConfirmAsync(embed)) + return; + + var res = await _ds.ExecuteSql(sql); + await Response().Confirm(res.ToString()).SendAsync(); + } + catch (Exception ex) + { + await Response().Error(ex.ToString()).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task PurgeUser(ulong userId) + { + var embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString())))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + await _ds.PurgeUserAsync(userId); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public Task PurgeUser([Leftover] IUser user) + => PurgeUser(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteXp() + => ConfirmActionInternalAsync("Delete Xp", () => _xcs.DeleteXp()); + + + [Cmd] + [OwnerOnly] + public Task DeleteWaifus() + => ConfirmActionInternalAsync("Delete Waifus", () => _gcs.DeleteWaifus()); + + [Cmd] + [OwnerOnly] + public async Task DeleteWaifu(IUser user) + => await DeleteWaifu(user.Id); + + [Cmd] + [OwnerOnly] + public Task DeleteWaifu(ulong userId) + => ConfirmActionInternalAsync($"Delete Waifu {userId}", () => _gcs.DeleteWaifu(userId)); + + + [Cmd] + [OwnerOnly] + public Task DeleteCurrency() + => ConfirmActionInternalAsync("Delete Currency", () => _gcs.DeleteCurrency()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs new file mode 100644 index 0000000..bbea48b --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/DangerousCommandsService.cs @@ -0,0 +1,103 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class DangerousCommandsService : IEService +{ + private readonly DbService _db; + + public DangerousCommandsService(DbService db) + => _db = db; + + public async Task ExecuteSql(string sql) + { + int res; + await using var uow = _db.GetDbContext(); + res = await uow.Database.ExecuteSqlRawAsync(sql); + return res; + } + + public SelectResult SelectSql(string sql) + { + var result = new SelectResult + { + ColumnNames = new(), + Results = new() + }; + + using var uow = _db.GetDbContext(); + var conn = uow.Database.GetDbConnection(); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + using var reader = cmd.ExecuteReader(); + if (reader.HasRows) + { + for (var i = 0; i < reader.FieldCount; i++) + result.ColumnNames.Add(reader.GetName(i)); + while (reader.Read()) + { + var obj = new object[reader.FieldCount]; + reader.GetValues(obj); + result.Results.Add(obj.Select(x => x.ToString()).ToArray()); + } + } + + return result; + } + + public async Task PurgeUserAsync(ulong userId) + { + await using var uow = _db.GetDbContext(); + + // get waifu info + var wi = await uow.Set().FirstOrDefaultAsyncEF(x => x.Waifu.UserId == userId); + + // if it exists, delete waifu related things + if (wi is not null) + { + // remove updates which have new or old as this waifu + await uow.Set().DeleteAsync(wu => wu.New.UserId == userId || wu.Old.UserId == userId); + + // delete all items this waifu owns + await uow.Set().DeleteAsync(x => x.WaifuInfoId == wi.Id); + + // all waifus this waifu claims are released + await uow.Set() + .AsQueryable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(x => new() + { + ClaimerId = null + }); + + // all affinities set to this waifu are reset + await uow.Set() + .AsQueryable() + .Where(x => x.Affinity.UserId == userId) + .UpdateAsync(x => new() + { + AffinityId = null + }); + } + + // delete guild xp + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete currency transactions + await uow.Set().DeleteAsync(x => x.UserId == userId); + + // delete user, currency, and clubs go away with it + await uow.Set().DeleteAsync(u => u.UserId == userId); + } + + public class SelectResult + { + public List ColumnNames { get; set; } + public List Results { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs new file mode 100644 index 0000000..cd6d742 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/CleanupId.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class CleanupId +{ + [Key] + public ulong GuildId { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs new file mode 100644 index 0000000..a396082 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/ICleanupService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public interface ICleanupService +{ + Task DeleteMissingGuildDataAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs new file mode 100644 index 0000000..44ecee2 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepReport.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepReport +{ + public required int ShardId { get; init; } + public required ulong[] GuildIds { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs new file mode 100644 index 0000000..52c8051 --- /dev/null +++ b/src/EllieBot/Modules/Administration/DangerousCommands/_common/KeepResult.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Administration.DangerousCommands; + +public sealed class KeepResult +{ + public required int GuildCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs new file mode 100644 index 0000000..8099573 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelCommands.cs @@ -0,0 +1,36 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GameVoiceChannelCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.MoveMembers)] + public async Task GameVoiceChannel() + { + var vch = ((IGuildUser)ctx.User).VoiceChannel; + + if (vch is null) + { + await Response().Error(strs.not_in_voice).SendAsync(); + return; + } + + var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id); + + if (id is null) + await Response().Confirm(strs.gvc_disabled).SendAsync(); + else + { + _service.GameVoiceChannels.Add(vch.Id); + await Response().Confirm(strs.gvc_enabled(Format.Bold(vch.Name))).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs new file mode 100644 index 0000000..54f9870 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GameVoiceChannel/GameVoiceChannelService.cs @@ -0,0 +1,127 @@ +#nullable disable +using EllieBot.Db; + +namespace EllieBot.Modules.Administration.Services; + +public class GameVoiceChannelService : IEService +{ + public ConcurrentHashSet GameVoiceChannels { get; } + + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public GameVoiceChannelService(DiscordSocketClient client, DbService db, IBot bot) + { + _db = db; + _client = client; + + GameVoiceChannels = new(bot.AllGuildConfigs + .Where(gc => gc.GameVoiceChannel is not null) + .Select(gc => gc.GameVoiceChannel!.Value)); + + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + _client.PresenceUpdated += OnPresenceUpdate; + } + + private Task OnPresenceUpdate(SocketUser socketUser, SocketPresence before, SocketPresence after) + { + _ = Task.Run(async () => + { + try + { + if (socketUser is not SocketGuildUser newUser) + return; + // if the user is in the voice channel and that voice channel is gvc + + if (newUser.VoiceChannel is not { } vc + || !GameVoiceChannels.Contains(vc.Id)) + return; + + //if the activity has changed, and is a playi1ng activity + foreach (var activity in after.Activities) + { + if (activity is { Type: ActivityType.Playing }) + //trigger gvc + { + if (await TriggerGvc(newUser, activity.Name)) + return; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running GuildMemberUpdated in gvc"); + } + }); + return Task.CompletedTask; + } + + public ulong? ToggleGameVoiceChannel(ulong guildId, ulong vchId) + { + ulong? id; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + if (gc.GameVoiceChannel == vchId) + { + GameVoiceChannels.TryRemove(vchId); + id = gc.GameVoiceChannel = null; + } + else + { + if (gc.GameVoiceChannel is not null) + GameVoiceChannels.TryRemove(gc.GameVoiceChannel.Value); + GameVoiceChannels.Add(vchId); + id = gc.GameVoiceChannel = vchId; + } + + uow.SaveChanges(); + return id; + } + + private Task OnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + _ = Task.Run(async () => + { + try + { + if (usr is not SocketGuildUser gUser) + return; + + if (newState.VoiceChannel is null) + return; + + if (!GameVoiceChannels.Contains(newState.VoiceChannel.Id)) + return; + + foreach (var game in gUser.Activities.Select(x => x.Name)) + { + if (await TriggerGvc(gUser, game)) + return; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error running VoiceStateUpdate in gvc"); + } + }); + + return Task.CompletedTask; + } + + private async Task TriggerGvc(SocketGuildUser gUser, string game) + { + if (string.IsNullOrWhiteSpace(game)) + return false; + + game = game.TrimTo(50)!.ToLowerInvariant(); + var vch = gUser.Guild.VoiceChannels.FirstOrDefault(x => x.Name.ToLowerInvariant() == game); + + if (vch is null) + return false; + + await Task.Delay(1000); + await gUser.ModifyAsync(gu => gu.Channel = vch); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs new file mode 100644 index 0000000..53dd058 --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetCommands.cs @@ -0,0 +1,229 @@ +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class GreetCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Boost() + { + var enabled = await _service.ToggleBoost(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.boost_on).SendAsync(); + else + await Response().Pending(strs.boost_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetBoostDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.boostdel_on(timer)).SendAsync(); + else + await Response().Pending(strs.boostdel_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task BoostMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var boostMessage = _service.GetBoostMessage(ctx.Guild.Id); + await Response().Confirm(strs.boostmsg_cur(boostMessage?.SanitizeMentions())).SendAsync(); + return; + } + + var sendBoostEnabled = _service.SetBoostMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.boostmsg_new).SendAsync(); + if (!sendBoostEnabled) + await Response().Pending(strs.boostmsg_enable($"`{prefix}boost`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDel(int timer = 30) + { + if (timer is < 0 or > 600) + return; + + await _service.SetGreetDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.greetdel_on(timer)).SendAsync(); + else + await Response().Pending(strs.greetdel_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Greet() + { + var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.greet_on).SendAsync(); + else + await Response().Pending(strs.greet_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var greetMsg = _service.GetGreetMsg(ctx.Guild.Id); + await Response().Confirm(strs.greetmsg_cur(greetMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendGreetEnabled = _service.SetGreetMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.greetmsg_new).SendAsync(); + + if (!sendGreetEnabled) + await Response().Pending(strs.greetmsg_enable($"`{prefix}greet`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDm() + { + var enabled = await _service.SetGreetDm(ctx.Guild.Id); + + if (enabled) + await Response().Confirm(strs.greetdm_on).SendAsync(); + else + await Response().Confirm(strs.greetdm_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task GreetDmMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var dmGreetMsg = _service.GetDmGreetMsg(ctx.Guild.Id); + await Response().Confirm(strs.greetdmmsg_cur(dmGreetMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendGreetEnabled = _service.SetGreetDmMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.greetdmmsg_new).SendAsync(); + if (!sendGreetEnabled) + await Response().Pending(strs.greetdmmsg_enable($"`{prefix}greetdm`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task Bye() + { + var enabled = await _service.SetBye(ctx.Guild.Id, ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.bye_on).SendAsync(); + else + await Response().Confirm(strs.bye_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeMsg([Leftover] string? text = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + var byeMsg = _service.GetByeMessage(ctx.Guild.Id); + await Response().Confirm(strs.byemsg_cur(byeMsg?.SanitizeMentions())).SendAsync(); + return; + } + + var sendByeEnabled = _service.SetByeMessage(ctx.Guild.Id, ref text); + + await Response().Confirm(strs.byemsg_new).SendAsync(); + if (!sendByeEnabled) + await Response().Pending(strs.byemsg_enable($"`{prefix}bye`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + public async Task ByeDel(int timer = 30) + { + await _service.SetByeDel(ctx.Guild.Id, timer); + + if (timer > 0) + await Response().Confirm(strs.byedel_on(timer)).SendAsync(); + else + await Response().Pending(strs.byedel_off).SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task ByeTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.ByeTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetByeEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.byemsg_enable($"`{prefix}bye`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + await _service.GreetTest((ITextChannel)ctx.Channel, user); + var enabled = _service.GetGreetEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.greetmsg_enable($"`{prefix}greet`")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageGuild)] + [Ratelimit(5)] + public async Task GreetDmTest([Leftover] IGuildUser? user = null) + { + user ??= (IGuildUser)ctx.User; + + var success = await _service.GreetDmTest(user); + if (success) + await ctx.OkAsync(); + else + await ctx.WarningAsync(); + var enabled = _service.GetGreetDmEnabled(ctx.Guild.Id); + if (!enabled) + await Response().Pending(strs.greetdmmsg_enable($"`{prefix}greetdm`")).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs new file mode 100644 index 0000000..a92256e --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetGrouper.cs @@ -0,0 +1,71 @@ +namespace EllieBot.Services; + +public class GreetGrouper +{ + private readonly Dictionary> _group; + private readonly object _locker = new(); + + public GreetGrouper() + => _group = new(); + + + /// + /// Creates a group, if group already exists, adds the specified user + /// + /// Id of the server for which to create group for + /// User to add if group already exists + /// + public bool CreateOrAdd(ulong guildId, T toAddIfExists) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var list)) + { + list.Add(toAddIfExists); + return false; + } + + _group[guildId] = new(); + return true; + } + } + + /// + /// Remove the specified amount of items from the group. If all items are removed, group will be removed. + /// + /// Id of the group + /// Maximum number of items to retrieve + /// Items retrieved + /// Whether the group has no more items left and is deleted + public bool ClearGroup(ulong guildId, int count, out IReadOnlyCollection items) + { + lock (_locker) + { + if (_group.TryGetValue(guildId, out var set)) + { + // if we want more than there are, return everything + if (count >= set.Count) + { + items = set; + _group.Remove(guildId); + return true; + } + + // if there are more in the group than what's needed + // take the requested number, remove them from the set + // and return them + var toReturn = set.TakeWhile(_ => count-- != 0).ToList(); + foreach (var item in toReturn) + set.Remove(item); + + items = toReturn; + // returning falsemeans group is not yet deleted + // because there are items left + return false; + } + + items = Array.Empty(); + return true; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs new file mode 100644 index 0000000..aec7e9e --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetService.cs @@ -0,0 +1,662 @@ +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.Services; + +public class GreetService : IEService, IReadyExecutor +{ + public bool GroupGreets + => _bss.Data.GroupGreets; + + private readonly DbService _db; + + private readonly ConcurrentDictionary _guildConfigsCache; + private readonly DiscordSocketClient _client; + + private readonly GreetGrouper _greets = new(); + private readonly GreetGrouper _byes = new(); + private readonly BotConfigService _bss; + private readonly IReplacementService _repSvc; + private readonly IMessageSenderService _sender; + + public GreetService( + DiscordSocketClient client, + IBot bot, + DbService db, + BotConfigService bss, + IMessageSenderService sender, + IReplacementService repSvc) + { + _db = db; + _client = client; + _bss = bss; + _repSvc = repSvc; + _sender = sender; + + _guildConfigsCache = new(bot.AllGuildConfigs.ToDictionary(g => g.GuildId, GreetSettings.Create)); + + _client.UserJoined += OnUserJoined; + _client.UserLeft += OnUserLeft; + + bot.JoinedGuild += OnBotJoinedGuild; + _client.LeftGuild += OnClientLeftGuild; + + _client.GuildMemberUpdated += ClientOnGuildMemberUpdated; + } + + public async Task OnReadyAsync() + { + while (true) + { + var (conf, user, compl) = await _greetDmQueue.Reader.ReadAsync(); + var res = await GreetDmUserInternal(conf, user); + compl.TrySetResult(res); + await Task.Delay(2000); + } + } + + private Task ClientOnGuildMemberUpdated(Cacheable optOldUser, SocketGuildUser newUser) + { + // if user is a new booster + // or boosted again the same server + if ((optOldUser.Value is { PremiumSince: null } && newUser is { PremiumSince: not null }) + || (optOldUser.Value?.PremiumSince is { } oldDate + && newUser.PremiumSince is { } newDate + && newDate > oldDate)) + { + var conf = GetOrAddSettingsForGuild(newUser.Guild.Id); + if (!conf.SendBoostMessage) + return Task.CompletedTask; + + _ = Task.Run(TriggerBoostMessage(conf, newUser)); + } + + return Task.CompletedTask; + } + + private Func TriggerBoostMessage(GreetSettings conf, SocketGuildUser user) + => async () => + { + var channel = user.Guild.GetTextChannel(conf.BoostMessageChannelId); + if (channel is null) + return; + + await SendBoostMessage(conf, user, channel); + }; + + private async Task SendBoostMessage(GreetSettings conf, IGuildUser user, ITextChannel channel) + { + if (string.IsNullOrWhiteSpace(conf.BoostMessage)) + return false; + + var toSend = SmartText.CreateFrom(conf.BoostMessage); + + try + { + var newContent = await _repSvc.ReplaceAsync(toSend, + new(client: _client, guild: user.Guild, channel: channel, users: user)); + var toDelete = await _sender.Response(channel).Text(newContent).Sanitize(false).SendAsync(); + if (conf.BoostMessageDeleteAfter > 0) + toDelete.DeleteAfter(conf.BoostMessageDeleteAfter); + + return true; + } + catch (Exception ex) + { + Log.Error(ex, "Error sending boost message"); + } + + return false; + } + + private Task OnClientLeftGuild(SocketGuild arg) + { + _guildConfigsCache.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private Task OnBotJoinedGuild(GuildConfig gc) + { + _guildConfigsCache[gc.GuildId] = GreetSettings.Create(gc); + return Task.CompletedTask; + } + + private Task OnUserLeft(SocketGuild guild, SocketUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(guild.Id); + + if (!conf.SendChannelByeMessage) + return; + var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ByeMessageChannelId); + + if (channel is null) //maybe warn the server owner that the channel is missing + return; + + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_byes.CreateOrAdd(guild.Id, user)) + { + // greet single user + await ByeUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _byes.ClearGroup(guild.Id, 5, out var toBye); + await ByeUsers(conf, channel, toBye); + } + } + } + else + await ByeUsers(conf, channel, new[] { user }); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetDmGreetMsg(ulong id) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(id, set => set).DmGreetMessageText; + } + + public string? GetGreetMsg(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelGreetMessageText; + } + + public string? GetBoostMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).BoostMessage; + } + + public GreetSettings GetGreetSettings(ulong gid) + { + if (_guildConfigsCache.TryGetValue(gid, out var gs)) + return gs; + + using var uow = _db.GetDbContext(); + return GreetSettings.Create(uow.GuildConfigsForId(gid, set => set)); + } + + private Task ByeUsers(GreetSettings conf, ITextChannel channel, IUser user) + => ByeUsers(conf, channel, new[] { user }); + + private async Task ByeUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (!users.Any()) + return; + + var repCtx = new ReplacementContext(client: _client, + guild: channel.Guild, + channel: channel, + users: users.ToArray()); + + var text = SmartText.CreateFrom(conf.ChannelByeMessageText); + text = await _repSvc.ReplaceAsync(text, repCtx); + try + { + var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + if (conf.AutoDeleteByeMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteByeMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions + || ex.DiscordCode == DiscordErrorCode.MissingPermissions + || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, + "Missing permissions to send a bye message, the bye message will be disabled on server: {GuildId}", + channel.GuildId); + await SetBye(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding bye message"); + } + } + + private Task GreetUsers(GreetSettings conf, ITextChannel channel, IGuildUser user) + => GreetUsers(conf, channel, new[] { user }); + + private async Task GreetUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection users) + { + if (users.Count == 0) + return; + + var repCtx = new ReplacementContext(client: _client, + guild: channel.Guild, + channel: channel, + users: users.ToArray()); + + var text = SmartText.CreateFrom(conf.ChannelGreetMessageText); + text = await _repSvc.ReplaceAsync(text, repCtx); + try + { + var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + if (conf.AutoDeleteGreetMessagesTimer > 0) + toDelete.DeleteAfter(conf.AutoDeleteGreetMessagesTimer); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions + || ex.DiscordCode == DiscordErrorCode.MissingPermissions + || ex.DiscordCode == DiscordErrorCode.UnknownChannel) + { + Log.Warning(ex, + "Missing permissions to send a bye message, the greet message will be disabled on server: {GuildId}", + channel.GuildId); + await SetGreet(channel.GuildId, channel.Id, false); + } + catch (Exception ex) + { + Log.Warning(ex, "Error embeding greet message"); + } + } + + private readonly Channel<(GreetSettings, IGuildUser, TaskCompletionSource)> _greetDmQueue = + Channel.CreateBounded<(GreetSettings, IGuildUser, TaskCompletionSource)>(new BoundedChannelOptions(60) + { + // The limit of 60 users should be only hit when there's a raid. In that case + // probably the best thing to do is to drop newest (raiding) users + FullMode = BoundedChannelFullMode.DropNewest + }); + + + private async Task GreetDmUser(GreetSettings conf, IGuildUser user) + { + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await _greetDmQueue.Writer.WriteAsync((conf, user, completionSource)); + return await completionSource.Task; + } + + private async Task GreetDmUserInternal(GreetSettings conf, IGuildUser user) + { + try + { + // var rep = new ReplacementBuilder() + // .WithUser(user) + // .WithServer(_client, (SocketGuild)user.Guild) + // .Build(); + + var repCtx = new ReplacementContext(client: _client, guild: user.Guild, users: user); + var smartText = SmartText.CreateFrom(conf.DmGreetMessageText); + smartText = await _repSvc.ReplaceAsync(smartText, repCtx); + + if (smartText is SmartPlainText pt) + { + smartText = new SmartEmbedText() + { + Description = pt.Text + }; + } + + if (smartText is SmartEmbedText set) + { + smartText = set with + { + Footer = CreateFooterSource(user) + }; + } + else if (smartText is SmartEmbedTextArray seta) + { + // if the greet dm message is a text array + var ebElem = seta.Embeds.LastOrDefault(); + if (ebElem is null) + { + // if there are no embeds, add an embed with the footer + smartText = seta with + { + Embeds = + [ + new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + } + ] + }; + } + else + { + // if the maximum amount of embeds is reached, edit the last embed + if (seta.Embeds.Length >= 10) + { + seta.Embeds[^1] = seta.Embeds[^1] with + { + Footer = CreateFooterSource(user) + }; + } + else + { + // if there is less than 10 embeds, add an embed with footer only + seta.Embeds = seta.Embeds.Append(new SmartEmbedArrayElementText() + { + Footer = CreateFooterSource(user) + }) + .ToArray(); + } + } + } + + await _sender.Response(user).Text(smartText).Sanitize(false).SendAsync(); + } + catch + { + return false; + } + + return true; + } + + private static SmartTextEmbedFooter CreateFooterSource(IGuildUser user) + => new() + { + Text = $"This message was sent from {user.Guild} server.", + IconUrl = user.Guild.IconUrl + }; + + private Task OnUserJoined(IGuildUser user) + { + _ = Task.Run(async () => + { + try + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + + if (conf.SendChannelGreetMessage) + { + var channel = await user.Guild.GetTextChannelAsync(conf.GreetMessageChannelId); + if (channel is not null) + { + if (GroupGreets) + { + // if group is newly created, greet that user right away, + // but any user which joins in the next 5 seconds will + // be greeted in a group greet + if (_greets.CreateOrAdd(user.GuildId, user)) + { + // greet single user + await GreetUsers(conf, channel, new[] { user }); + var groupClear = false; + while (!groupClear) + { + await Task.Delay(5000); + groupClear = _greets.ClearGroup(user.GuildId, 5, out var toGreet); + await GreetUsers(conf, channel, toGreet); + } + } + } + else + await GreetUsers(conf, channel, new[] { user }); + } + } + + if (conf.SendDmGreetMessage) + await GreetDmUser(conf, user); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public string? GetByeMessage(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(gid, set => set).ChannelByeMessageText; + } + + public GreetSettings GetOrAddSettingsForGuild(ulong guildId) + { + if (_guildConfigsCache.TryGetValue(guildId, out var settings)) + return settings; + + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set); + settings = GreetSettings.Create(gc); + } + + _guildConfigsCache.TryAdd(guildId, settings); + return settings; + } + + public async Task SetGreet(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelGreetMessage = value ?? !conf.SendChannelGreetMessage; + conf.GreetMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetMessage(ulong guildId, ref string message) + { + message = message.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelGreetMessageText = message; + var greetMsgEnabled = conf.SendChannelGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache.AddOrUpdate(guildId, toAdd, (_, _) => toAdd); + + uow.SaveChanges(); + return greetMsgEnabled; + } + + public async Task SetGreetDm(ulong guildId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendDmGreetMessage = value ?? !conf.SendDmGreetMessage; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetGreetDmMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.DmGreetMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendDmGreetMessage; + } + + public async Task SetBye(ulong guildId, ulong channelId, bool? value = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + var enabled = conf.SendChannelByeMessage = value ?? !conf.SendChannelByeMessage; + conf.ByeMessageChannelId = channelId; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + return enabled; + } + + public bool SetByeMessage(ulong guildId, ref string? message) + { + message = message?.SanitizeMentions(); + + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.ChannelByeMessageText = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendChannelByeMessage; + } + + public async Task SetByeDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteByeMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task SetGreetDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + return; + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.AutoDeleteGreetMessagesTimer = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public bool SetBoostMessage(ulong guildId, ref string message) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessage = message; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + uow.SaveChanges(); + return conf.SendBoostMessage; + } + + public async Task SetBoostDel(ulong guildId, int timer) + { + if (timer is < 0 or > 600) + throw new ArgumentOutOfRangeException(nameof(timer)); + + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + conf.BoostMessageDeleteAfter = timer; + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + + await uow.SaveChangesAsync(); + } + + public async Task ToggleBoost(ulong guildId, ulong channelId, bool? forceState = null) + { + await using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + + if (forceState is not bool fs) + conf.SendBoostMessage = !conf.SendBoostMessage; + else + conf.SendBoostMessage = fs; + + conf.BoostMessageChannelId = channelId; + await uow.SaveChangesAsync(); + + var toAdd = GreetSettings.Create(conf); + _guildConfigsCache[guildId] = toAdd; + return conf.SendBoostMessage; + } + + #region Get Enabled Status + + public bool GetGreetDmEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendDmGreetMessage; + } + + public bool GetGreetEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelGreetMessage; + } + + public bool GetByeEnabled(ulong guildId) + { + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set); + return conf.SendChannelByeMessage; + } + + #endregion + + #region Test Messages + + public Task ByeTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return ByeUsers(conf, channel, user); + } + + public Task GreetTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetUsers(conf, channel, user); + } + + public Task GreetDmTest(IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return GreetDmUser(conf, user); + } + + public Task BoostTest(ITextChannel channel, IGuildUser user) + { + var conf = GetOrAddSettingsForGuild(user.GuildId); + return SendBoostMessage(conf, user, channel); + } + + #endregion +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs new file mode 100644 index 0000000..6e4f8ed --- /dev/null +++ b/src/EllieBot/Modules/Administration/GreetBye/GreetSettings.cs @@ -0,0 +1,45 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Services; + +public class GreetSettings +{ + public int AutoDeleteGreetMessagesTimer { get; set; } + public int AutoDeleteByeMessagesTimer { get; set; } + + public ulong GreetMessageChannelId { get; set; } + public ulong ByeMessageChannelId { get; set; } + + public bool SendDmGreetMessage { get; set; } + public string? DmGreetMessageText { get; set; } + + public bool SendChannelGreetMessage { get; set; } + public string? ChannelGreetMessageText { get; set; } + + public bool SendChannelByeMessage { get; set; } + public string? ChannelByeMessageText { get; set; } + + public bool SendBoostMessage { get; set; } + public string? BoostMessage { get; set; } + public int BoostMessageDeleteAfter { get; set; } + public ulong BoostMessageChannelId { get; set; } + + public static GreetSettings Create(GuildConfig g) + => new() + { + AutoDeleteByeMessagesTimer = g.AutoDeleteByeMessagesTimer, + AutoDeleteGreetMessagesTimer = g.AutoDeleteGreetMessagesTimer, + GreetMessageChannelId = g.GreetMessageChannelId, + ByeMessageChannelId = g.ByeMessageChannelId, + SendDmGreetMessage = g.SendDmGreetMessage, + DmGreetMessageText = g.DmGreetMessageText, + SendChannelGreetMessage = g.SendChannelGreetMessage, + ChannelGreetMessageText = g.ChannelGreetMessageText, + SendChannelByeMessage = g.SendChannelByeMessage, + ChannelByeMessageText = g.ChannelByeMessageText, + SendBoostMessage = g.SendBoostMessage, + BoostMessage = g.BoostMessage, + BoostMessageDeleteAfter = g.BoostMessageDeleteAfter, + BoostMessageChannelId = g.BoostMessageChannelId + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs new file mode 100644 index 0000000..1b45cc6 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ImageOnlyChannelService.cs @@ -0,0 +1,235 @@ +#nullable disable +using LinqToDB; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using System.Net; +using System.Threading.Channels; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class SomethingOnlyChannelService : IExecOnMessage +{ + public int Priority { get; } = 0; + private readonly IMemoryCache _ticketCache; + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly ConcurrentDictionary> _imageOnly; + private readonly ConcurrentDictionary> _linkOnly; + + private readonly Channel _deleteQueue = Channel.CreateBounded( + new BoundedChannelOptions(100) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + + public SomethingOnlyChannelService(IMemoryCache ticketCache, DiscordSocketClient client, DbService db) + { + _ticketCache = ticketCache; + _client = client; + _db = db; + + using var uow = _db.GetDbContext(); + _imageOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Image) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _linkOnly = uow.Set() + .Where(x => x.Type == OnlyChannelType.Link) + .ToList() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, x => new ConcurrentHashSet(x.Select(y => y.ChannelId))) + .ToConcurrent(); + + _ = Task.Run(DeleteQueueRunner); + + _client.ChannelDestroyed += ClientOnChannelDestroyed; + } + + private async Task ClientOnChannelDestroyed(SocketChannel ch) + { + if (ch is not IGuildChannel gch) + return; + + if (_imageOnly.TryGetValue(gch.GuildId, out var channels) && channels.TryRemove(ch.Id)) + await ToggleImageOnlyChannelAsync(gch.GuildId, ch.Id, true); + } + + private async Task DeleteQueueRunner() + { + while (true) + { + var toDelete = await _deleteQueue.Reader.ReadAsync(); + try + { + await toDelete.DeleteAsync(); + await Task.Delay(1000); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + // disable if bot can't delete messages in the channel + await ToggleImageOnlyChannelAsync(((ITextChannel)toDelete.Channel).GuildId, toDelete.Channel.Id, true); + } + } + } + + public async Task ToggleImageOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_imageOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Image); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Image + }); + + if (_linkOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _imageOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ToggleLinkOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false) + { + var newState = false; + await using var uow = _db.GetDbContext(); + if (forceDisable || (_linkOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId))) + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Link); + } + else + { + await uow.Set().DeleteAsync(x => x.ChannelId == channelId); + uow.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + Type = OnlyChannelType.Link + }); + + if (_imageOnly.TryGetValue(guildId, out var chs)) + chs.TryRemove(channelId); + + channels = _linkOnly.GetOrAdd(guildId, new ConcurrentHashSet()); + channels.Add(channelId); + newState = true; + } + + await uow.SaveChangesAsync(); + return newState; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Channel is not ITextChannel tch) + return false; + + if (_imageOnly.TryGetValue(tch.GuildId, out var chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Image); + + if (_linkOnly.TryGetValue(tch.GuildId, out chs) && chs.Contains(msg.Channel.Id)) + return await HandleOnlyChannel(tch, msg, OnlyChannelType.Link); + + return false; + } + + private async Task HandleOnlyChannel(ITextChannel tch, IUserMessage msg, OnlyChannelType type) + { + if (type == OnlyChannelType.Image) + { + if (msg.Attachments.Any(x => x is { Height: > 0, Width: > 0 })) + return false; + } + else + { + if (msg.Content.TryGetUrlPath(out _)) + return false; + } + + var user = await tch.Guild.GetUserAsync(msg.Author.Id) + ?? await _client.Rest.GetGuildUserAsync(tch.GuildId, msg.Author.Id); + + if (user is null) + return false; + + // ignore owner and admin + if (user.Id == tch.Guild.OwnerId || user.GuildPermissions.Administrator) + { + Log.Information("{Type}-Only Channel: Ignoring owner or admin ({ChannelId})", type, msg.Channel.Id); + return false; + } + + // ignore users higher in hierarchy + var botUser = await tch.Guild.GetCurrentUserAsync(); + if (user.GetRoles().Max(x => x.Position) >= botUser.GetRoles().Max(x => x.Position)) + return false; + + if (!botUser.GetPermissions(tch).ManageChannel) + { + if(type == OnlyChannelType.Image) + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + else + await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true); + + return false; + } + + var shouldLock = AddUserTicket(tch.GuildId, msg.Author.Id); + if (shouldLock) + { + await tch.AddPermissionOverwriteAsync(msg.Author, new(sendMessages: PermValue.Deny)); + Log.Warning("{Type}-Only Channel: User {User} [{UserId}] has been banned from typing in the channel [{ChannelId}]", + type, + msg.Author, + msg.Author.Id, + msg.Channel.Id); + } + + try + { + await _deleteQueue.Writer.WriteAsync(msg); + } + catch (Exception ex) + { + Log.Error(ex, "Error deleting message {MessageId} in image-only channel {ChannelId}", msg.Id, tch.Id); + } + + return true; + } + + private bool AddUserTicket(ulong guildId, ulong userId) + { + var old = _ticketCache.GetOrCreate($"{guildId}_{userId}", + entry => + { + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); + return 0; + }); + + _ticketCache.Set($"{guildId}_{userId}", ++old); + + // if this is the third time that the user posts a + // non image in an image-only channel on this server + return old > 2; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/LocalizationCommands.cs b/src/EllieBot/Modules/Administration/LocalizationCommands.cs new file mode 100644 index 0000000..9715d0b --- /dev/null +++ b/src/EllieBot/Modules/Administration/LocalizationCommands.cs @@ -0,0 +1,264 @@ +#nullable disable +using System.Globalization; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class LocalizationCommands : EllieModule + { + private static readonly IReadOnlyDictionary _supportedLocales = new Dictionary + { + { "ar", "العربية" }, + { "zh-TW", "繁體中文, 台灣" }, + { "zh-CN", "简体中文, 中华人民共和国" }, + { "nl-NL", "Nederlands, Nederland" }, + { "en-US", "English, United States" }, + { "fr-FR", "Français, France" }, + { "cs-CZ", "Čeština, Česká republika" }, + { "da-DK", "Dansk, Danmark" }, + { "de-DE", "Deutsch, Deutschland" }, + { "he-IL", "עברית, ישראל" }, + { "hu-HU", "Magyar, Magyarország" }, + { "id-ID", "Bahasa Indonesia, Indonesia" }, + { "it-IT", "Italiano, Italia" }, + { "ja-JP", "日本語, 日本" }, + { "ko-KR", "한국어, 대한민국" }, + { "nb-NO", "Norsk, Norge" }, + { "pl-PL", "Polski, Polska" }, + { "pt-BR", "Português Brasileiro, Brasil" }, + { "ro-RO", "Română, România" }, + { "ru-RU", "Русский, Россия" }, + { "sr-Cyrl-RS", "Српски, Србија" }, + { "es-ES", "Español, España" }, + { "sv-SE", "Svenska, Sverige" }, + { "tr-TR", "Türkçe, Türkiye" }, + { "ts-TS", "Tsundere, You Baka" }, + { "uk-UA", "Українська, Україна" } + }; + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task LanguageSet() + => await Response().Confirm(strs.lang_set_show(Format.Bold(Culture.ToString()), + Format.Bold(Culture.NativeName))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task LanguageSet(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.RemoveGuildCulture(ctx.Guild); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + + _localization.SetGuildCulture(ctx.Guild, ci); + } + + var nativeName = ci.NativeName; + if (ci.Name == "ts-TS") + nativeName = _supportedLocales[ci.Name]; + await Response().Confirm(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(nativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguageSetDefault() + { + var cul = _localization.DefaultCultureInfo; + await Response().Error(strs.lang_set_bot_show(cul, cul.NativeName)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task LanguageSetDefault(string name) + { + try + { + CultureInfo ci; + if (name.Trim().ToLowerInvariant() == "default") + { + _localization.ResetDefaultCulture(); + ci = _localization.DefaultCultureInfo; + } + else + { + ci = new CultureInfo(name); + if (!_supportedLocales.ContainsKey(ci.Name)) + { + await LanguagesList(); + return; + } + _localization.SetDefaultCulture(ci); + } + + await Response().Confirm(strs.lang_set_bot(Format.Bold(ci.ToString()), + Format.Bold(ci.NativeName))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.lang_set_fail).SendAsync(); + } + } + + [Cmd] + public async Task LanguagesList() + => await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.lang_list)) + .WithDescription(string.Join("\n", + _supportedLocales.Select( + x => $"{Format.Code(x.Key),-10} => {x.Value}")))).SendAsync(); + } +} +/* list of language codes for reference. + * taken from https://github.com/dotnet/coreclr/blob/ee5862c6a257e60e263537d975ab6c513179d47f/src/mscorlib/src/System/Globalization/CultureData.cs#L192 + { "029", "en-029" }, + { "AE", "ar-AE" }, + { "AF", "prs-AF" }, + { "AL", "sq-AL" }, + { "AM", "hy-AM" }, + { "AR", "es-AR" }, + { "AT", "de-AT" }, + { "AU", "en-AU" }, + { "AZ", "az-Cyrl-AZ" }, + { "BA", "bs-Latn-BA" }, + { "BD", "bn-BD" }, + { "BE", "nl-BE" }, + { "BG", "bg-BG" }, + { "BH", "ar-BH" }, + { "BN", "ms-BN" }, + { "BO", "es-BO" }, + { "BR", "pt-BR" }, + { "BY", "be-BY" }, + { "BZ", "en-BZ" }, + { "CA", "en-CA" }, + { "CH", "it-CH" }, + { "CL", "es-CL" }, + { "CN", "zh-CN" }, + { "CO", "es-CO" }, + { "CR", "es-CR" }, + { "CS", "sr-Cyrl-CS" }, + { "CZ", "cs-CZ" }, + { "DE", "de-DE" }, + { "DK", "da-DK" }, + { "DO", "es-DO" }, + { "DZ", "ar-DZ" }, + { "EC", "es-EC" }, + { "EE", "et-EE" }, + { "EG", "ar-EG" }, + { "ES", "es-ES" }, + { "ET", "am-ET" }, + { "FI", "fi-FI" }, + { "FO", "fo-FO" }, + { "FR", "fr-FR" }, + { "GB", "en-GB" }, + { "GE", "ka-GE" }, + { "GL", "kl-GL" }, + { "GR", "el-GR" }, + { "GT", "es-GT" }, + { "HK", "zh-HK" }, + { "HN", "es-HN" }, + { "HR", "hr-HR" }, + { "HU", "hu-HU" }, + { "ID", "id-ID" }, + { "IE", "en-IE" }, + { "IL", "he-IL" }, + { "IN", "hi-IN" }, + { "IQ", "ar-IQ" }, + { "IR", "fa-IR" }, + { "IS", "is-IS" }, + { "IT", "it-IT" }, + { "IV", "" }, + { "JM", "en-JM" }, + { "JO", "ar-JO" }, + { "JP", "ja-JP" }, + { "KE", "sw-KE" }, + { "KG", "ky-KG" }, + { "KH", "km-KH" }, + { "KR", "ko-KR" }, + { "KW", "ar-KW" }, + { "KZ", "kk-KZ" }, + { "LA", "lo-LA" }, + { "LB", "ar-LB" }, + { "LI", "de-LI" }, + { "LK", "si-LK" }, + { "LT", "lt-LT" }, + { "LU", "lb-LU" }, + { "LV", "lv-LV" }, + { "LY", "ar-LY" }, + { "MA", "ar-MA" }, + { "MC", "fr-MC" }, + { "ME", "sr-Latn-ME" }, + { "MK", "mk-MK" }, + { "MN", "mn-MN" }, + { "MO", "zh-MO" }, + { "MT", "mt-MT" }, + { "MV", "dv-MV" }, + { "MX", "es-MX" }, + { "MY", "ms-MY" }, + { "NG", "ig-NG" }, + { "NI", "es-NI" }, + { "NL", "nl-NL" }, + { "NO", "nn-NO" }, + { "NP", "ne-NP" }, + { "NZ", "en-NZ" }, + { "OM", "ar-OM" }, + { "PA", "es-PA" }, + { "PE", "es-PE" }, + { "PH", "en-PH" }, + { "PK", "ur-PK" }, + { "PL", "pl-PL" }, + { "PR", "es-PR" }, + { "PT", "pt-PT" }, + { "PY", "es-PY" }, + { "QA", "ar-QA" }, + { "RO", "ro-RO" }, + { "RS", "sr-Latn-RS" }, + { "RU", "ru-RU" }, + { "RW", "rw-RW" }, + { "SA", "ar-SA" }, + { "SE", "sv-SE" }, + { "SG", "zh-SG" }, + { "SI", "sl-SI" }, + { "SK", "sk-SK" }, + { "SN", "wo-SN" }, + { "SV", "es-SV" }, + { "SY", "ar-SY" }, + { "TH", "th-TH" }, + { "TJ", "tg-Cyrl-TJ" }, + { "TM", "tk-TM" }, + { "TN", "ar-TN" }, + { "TR", "tr-TR" }, + { "TT", "en-TT" }, + { "TW", "zh-TW" }, + { "UA", "uk-UA" }, + { "US", "en-US" }, + { "UY", "es-UY" }, + { "UZ", "uz-Cyrl-UZ" }, + { "VE", "es-VE" }, + { "VN", "vi-VN" }, + { "YE", "ar-YE" }, + { "ZA", "af-ZA" }, + { "ZW", "en-ZW" } + */ \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs new file mode 100644 index 0000000..94e797a --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteCommands.cs @@ -0,0 +1,231 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class MuteCommands : EllieModule + { + private async Task VerifyMutePermissions(IGuildUser runnerUser, IGuildUser targetUser) + { + var runnerUserRoles = runnerUser.GetRoles(); + var targetUserRoles = targetUser.GetRoles(); + if (runnerUser.Id != ctx.Guild.OwnerId + && runnerUserRoles.Max(x => x.Position) <= targetUserRoles.Max(x => x.Position)) + { + await Response().Error(strs.mute_perms).SendAsync(); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task MuteRole([Leftover] IRole role = null) + { + if (role is null) + { + var muteRole = await _service.GetMuteRole(ctx.Guild); + await Response().Confirm(strs.mute_role(Format.Code(muteRole.Name))).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position)) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name); + + await Response().Confirm(strs.mute_role_set).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(0)] + public async Task Mute(IGuildUser target, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, target)) + return; + + await _service.MuteUser(target, ctx.User, reason: reason); + await Response().Confirm(strs.user_muted(Format.Bold(target.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + [Priority(1)] + public async Task Mute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, reason: reason); + await Response().Confirm(strs.user_muted_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in mute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)] + public async Task Unmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, reason: reason); + await Response().Confirm(strs.user_unmuted(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task ChatMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception in the chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public async Task ChatMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in chatmute command"); + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task ChatUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.Guild.Id, user.Id, ctx.User, MuteType.Chat, reason); + await Response().Confirm(strs.user_chat_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(0)] + public async Task VoiceMute(IGuildUser user, [Leftover] string reason = "") + { + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.MuteUser(user, ctx.User, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_mute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + [Priority(1)] + public async Task VoiceMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "") + { + if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49)) + return; + try + { + if (!await VerifyMutePermissions((IGuildUser)ctx.User, user)) + return; + + await _service.TimedMute(user, ctx.User, time.Time, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_mute_time(Format.Bold(user.ToString()), + (int)time.Time.TotalMinutes)).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.MuteMembers)] + public async Task VoiceUnmute(IGuildUser user, [Leftover] string reason = "") + { + try + { + await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, MuteType.Voice, reason); + await Response().Confirm(strs.user_voice_unmute(Format.Bold(user.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.mute_error).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Mute/MuteService.cs b/src/EllieBot/Modules/Administration/Mute/MuteService.cs new file mode 100644 index 0000000..a3fbea3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Mute/MuteService.cs @@ -0,0 +1,504 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public enum MuteType +{ + Voice, + Chat, + All +} + +public class MuteService : IEService +{ + public enum TimerType { Mute, Ban, AddRole } + + private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny, + sendMessages: PermValue.Deny, + sendMessagesInThreads: PermValue.Deny, + attachFiles: PermValue.Deny); + + public event Action UserMuted = delegate { }; + public event Action UserUnmuted = delegate { }; + + public ConcurrentDictionary GuildMuteRoles { get; } + public ConcurrentDictionary> MutedUsers { get; } + + public ConcurrentDictionary> UnTimers { get; } = new(); + + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + + public MuteService(DiscordSocketClient client, DbService db, IMessageSenderService sender) + { + _client = client; + _db = db; + _sender = sender; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .AsNoTracking() + .AsSplitQuery() + .Include(x => x.MutedUsers) + .Include(x => x.UnbanTimer) + .Include(x => x.UnmuteTimers) + .Include(x => x.UnroleTimer) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + GuildMuteRoles = configs.Where(c => !string.IsNullOrWhiteSpace(c.MuteRoleName)) + .ToDictionary(c => c.GuildId, c => c.MuteRoleName) + .ToConcurrent(); + + MutedUsers = new(configs.ToDictionary(k => k.GuildId, + v => new ConcurrentHashSet(v.MutedUsers.Select(m => m.UserId)))); + + var max = TimeSpan.FromDays(49); + + foreach (var conf in configs) + { + foreach (var x in conf.UnmuteTimers) + { + TimeSpan after; + if (x.UnmuteAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unmute = x.UnmuteAt - DateTime.UtcNow; + after = unmute > max ? max : unmute; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Mute); + } + + foreach (var x in conf.UnbanTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Ban); + } + + foreach (var x in conf.UnroleTimer) + { + TimeSpan after; + if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow) + after = TimeSpan.FromMinutes(2); + else + { + var unban = x.UnbanAt - DateTime.UtcNow; + after = unban > max ? max : unban; + } + + StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.AddRole, x.RoleId); + } + } + + _client.UserJoined += Client_UserJoined; + } + + UserMuted += OnUserMuted; + UserUnmuted += OnUserUnmuted; + } + + private void OnUserMuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been muted in {user.Guild} server") + .AddField("Mute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + private void OnUserUnmuted( + IGuildUser user, + IUser mod, + MuteType type, + string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + return; + + _ = Task.Run(() => _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithDescription($"You've been unmuted in {user.Guild} server") + .AddField("Unmute Type", type.ToString()) + .AddField("Moderator", mod.ToString()) + .AddField("Reason", reason)) + .SendAsync()); + } + + private Task Client_UserJoined(IGuildUser usr) + { + try + { + MutedUsers.TryGetValue(usr.Guild.Id, out var muted); + + if (muted is null || !muted.Contains(usr.Id)) + return Task.CompletedTask; + _ = Task.Run(() => MuteUser(usr, _client.CurrentUser, reason: "Sticky mute")); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in MuteService UserJoined event"); + } + + return Task.CompletedTask; + } + + public async Task SetMuteRoleAsync(ulong guildId, string name) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + config.MuteRoleName = name; + GuildMuteRoles.AddOrUpdate(guildId, name, (_, _) => name); + await uow.SaveChangesAsync(); + } + + public async Task MuteUser( + IGuildUser usr, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + if (type == MuteType.All) + { + try { await usr.ModifyAsync(x => x.Mute = true); } + catch { } + + var muteRole = await GetMuteRole(usr.Guild); + if (!usr.RoleIds.Contains(muteRole.Id)) + await usr.AddRoleAsync(muteRole); + StopTimer(usr.GuildId, usr.Id, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(usr.Guild.Id, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + config.MutedUsers.Add(new() + { + UserId = usr.Id + }); + if (MutedUsers.TryGetValue(usr.Guild.Id, out var muted)) + muted.Add(usr.Id); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usr.Id); + + await uow.SaveChangesAsync(); + } + + UserMuted(usr, mod, MuteType.All, reason); + } + else if (type == MuteType.Voice) + { + try + { + await usr.ModifyAsync(x => x.Mute = true); + UserMuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + await usr.AddRoleAsync(await GetMuteRole(usr.Guild)); + UserMuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task UnmuteUser( + ulong guildId, + ulong usrId, + IUser mod, + MuteType type = MuteType.All, + string reason = "") + { + var usr = _client.GetGuild(guildId)?.GetUser(usrId); + if (type == MuteType.All) + { + StopTimer(guildId, usrId, TimerType.Mute); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, + set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers)); + var match = new MutedUserId + { + UserId = usrId + }; + var toRemove = config.MutedUsers.FirstOrDefault(x => x.Equals(match)); + if (toRemove is not null) + uow.Remove(toRemove); + if (MutedUsers.TryGetValue(guildId, out var muted)) + muted.TryRemove(usrId); + + config.UnmuteTimers.RemoveWhere(x => x.UserId == usrId); + + await uow.SaveChangesAsync(); + } + + if (usr is not null) + { + try { await usr.ModifyAsync(x => x.Mute = false); } + catch { } + + try { await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); } + catch + { + /*ignore*/ + } + + UserUnmuted(usr, mod, MuteType.All, reason); + } + } + else if (type == MuteType.Voice) + { + if (usr is null) + return; + try + { + await usr.ModifyAsync(x => x.Mute = false); + UserUnmuted(usr, mod, MuteType.Voice, reason); + } + catch { } + } + else if (type == MuteType.Chat) + { + if (usr is null) + return; + await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); + UserUnmuted(usr, mod, MuteType.Chat, reason); + } + } + + public async Task GetMuteRole(IGuild guild) + { + ArgumentNullException.ThrowIfNull(guild); + + const string defaultMuteRoleName = "ellie-mute"; + + var muteRoleName = GuildMuteRoles.GetOrAdd(guild.Id, defaultMuteRoleName); + + var muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName); + if (muteRole is null) + //if it doesn't exist, create it + { + try { muteRole = await guild.CreateRoleAsync(muteRoleName, isMentionable: false); } + catch + { + //if creations fails, maybe the name is not correct, find default one, if doesn't work, create default one + muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName) + ?? await guild.CreateRoleAsync(defaultMuteRoleName, isMentionable: false); + } + } + + foreach (var toOverwrite in await guild.GetTextChannelsAsync()) + { + try + { + if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id + && x.TargetType == PermissionTarget.Role)) + { + await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite); + + await Task.Delay(200); + } + } + catch + { + // ignored + } + } + + return muteRole; + } + + public async Task TimedMute( + IGuildUser user, + IUser mod, + TimeSpan after, + MuteType muteType = MuteType.All, + string reason = "") + { + await MuteUser(user, mod, muteType, reason); // mute the user. This will also remove any previous unmute timers + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnmuteTimers)); + config.UnmuteTimers.Add(new() + { + UserId = user.Id, + UnmuteAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.Mute); // start the timer + } + + public async Task TimedBan( + IGuild guild, + ulong userId, + TimeSpan after, + string reason, + int pruneDays) + { + await guild.AddBanAsync(userId, pruneDays, reason); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.UnbanTimer)); + config.UnbanTimer.Add(new() + { + UserId = userId, + UnbanAt = DateTime.UtcNow + after + }); // add teh unmute timer to the database + await uow.SaveChangesAsync(); + } + + StartUn_Timer(guild.Id, userId, after, TimerType.Ban); // start the timer + } + + public async Task TimedRole( + IGuildUser user, + TimeSpan after, + string reason, + IRole role) + { + await user.AddRoleAsync(role); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnroleTimer)); + config.UnroleTimer.Add(new() + { + UserId = user.Id, + UnbanAt = DateTime.UtcNow + after, + RoleId = role.Id + }); // add teh unmute timer to the database + uow.SaveChanges(); + } + + StartUn_Timer(user.GuildId, user.Id, after, TimerType.AddRole, role.Id); // start the timer + } + + public void StartUn_Timer( + ulong guildId, + ulong userId, + TimeSpan after, + TimerType type, + ulong? roleId = null) + { + //load the unmute timers for this guild + var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>()); + + //unmute timer to be added + var toAdd = new Timer(async _ => + { + if (type == TimerType.Ban) + { + try + { + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); // load the guild + if (guild is not null) + await guild.RemoveBanAsync(userId); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId); + } + } + else if (type == TimerType.AddRole) + { + try + { + if (roleId is null) + return; + + RemoveTimerFromDb(guildId, userId, type); + StopTimer(guildId, userId, type); + var guild = _client.GetGuild(guildId); + var user = guild?.GetUser(userId); + var role = guild?.GetRole(roleId.Value); + if (guild is not null && user is not null && user.Roles.Contains(role)) + await user.RemoveRoleAsync(role); + } + catch (Exception ex) + { + Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId); + } + } + else + { + try + { + // unmute the user, this will also remove the timer from the db + await UnmuteUser(guildId, userId, _client.CurrentUser, reason: "Timed mute expired"); + } + catch (Exception ex) + { + RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db + Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId); + } + } + }, + null, + after, + Timeout.InfiniteTimeSpan); + + //add it, or stop the old one and add this one + userUnTimers.AddOrUpdate((userId, type), + _ => toAdd, + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return toAdd; + }); + } + + public void StopTimer(ulong guildId, ulong userId, TimerType type) + { + if (!UnTimers.TryGetValue(guildId, out var userTimer)) + return; + + if (userTimer.TryRemove((userId, type), out var removed)) + removed.Change(Timeout.Infinite, Timeout.Infinite); + } + + private void RemoveTimerFromDb(ulong guildId, ulong userId, TimerType type) + { + using var uow = _db.GetDbContext(); + object toDelete; + if (type == TimerType.Mute) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnmuteTimers)); + toDelete = config.UnmuteTimers.FirstOrDefault(x => x.UserId == userId); + } + else + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnbanTimer)); + toDelete = config.UnbanTimer.FirstOrDefault(x => x.UserId == userId); + } + + if (toDelete is not null) + uow.Remove(toDelete); + uow.SaveChanges(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs new file mode 100644 index 0000000..cc43b31 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PermOverrides/DiscordPermOverrideCommands.cs @@ -0,0 +1,83 @@ +#nullable disable +using EllieBot.Common.TypeReaders; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class DiscordPermOverrideCommands : EllieModule + { + // override stats, it should require that the user has managessages guild permission + // .po 'stats' add user guild managemessages + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverride(CommandOrExprInfo cmd, params GuildPerm[] perms) + { + if (perms is null || perms.Length == 0) + { + await _service.RemoveOverride(ctx.Guild.Id, cmd.Name); + await Response().Confirm(strs.perm_override_reset).SendAsync(); + return; + } + + var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc); + await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms); + + await Response() + .Confirm(strs.perm_override(Format.Bold(aggregatePerms.ToString()), + Format.Code(cmd.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideReset() + { + var result = await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText(strs.perm_override_all_confirm))); + + if (!result) + return; + + await _service.ClearAllOverrides(ctx.Guild.Id); + + await Response().Confirm(strs.perm_override_all).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task DiscordPermOverrideList(int page = 1) + { + if (--page < 0) + return; + + var allOverrides = await _service.GetAllOverrides(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allOverrides) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.perm_overrides)).WithOkColor(); + + if (items.Count == 0) + eb.WithDescription(GetText(strs.perm_override_page_none)); + else + { + eb.WithDescription(items.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}") + .Join("\n")); + } + + return eb; + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs new file mode 100644 index 0000000..bf4909e --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateCommands.cs @@ -0,0 +1,62 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PlayingRotateCommands : EllieModule + { + [Cmd] + [OwnerOnly] + public async Task RotatePlaying() + { + if (_service.ToggleRotatePlaying()) + await Response().Confirm(strs.ropl_enabled).SendAsync(); + else + await Response().Confirm(strs.ropl_disabled).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task AddPlaying(ActivityType t, [Leftover] string status) + { + await _service.AddPlaying(t, status); + + await Response().Confirm(strs.ropl_added).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ListPlaying() + { + var statuses = _service.GetRotatingStatuses(); + + if (!statuses.Any()) + await Response().Error(strs.ropl_not_set).SendAsync(); + else + { + var i = 1; + await Response() + .Confirm(strs.ropl_list(string.Join("\n\t", + statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}")))) + .SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task RemovePlaying(int index) + { + index -= 1; + + var msg = await _service.RemovePlayingAsync(index); + + if (msg is null) + return; + + await Response().Confirm(strs.reprm(msg)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs new file mode 100644 index 0000000..edf6843 --- /dev/null +++ b/src/EllieBot/Modules/Administration/PlayingRotate/PlayingRotateService.cs @@ -0,0 +1,109 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class PlayingRotateService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bss; + private readonly SelfService _selfService; + private readonly IReplacementService _repService; + // private readonly Replacer _rep; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public PlayingRotateService( + DiscordSocketClient client, + DbService db, + BotConfigService bss, + IEnumerable phProviders, + SelfService selfService, + IReplacementService repService) + { + _db = db; + _bss = bss; + _selfService = selfService; + _repService = repService; + _client = client; + + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + var index = 0; + while (await timer.WaitForNextTickAsync()) + { + try + { + if (!_bss.Data.RotateStatuses) + continue; + + IReadOnlyList rotatingStatuses; + await using (var uow = _db.GetDbContext()) + { + rotatingStatuses = uow.Set().AsNoTracking().OrderBy(x => x.Id).ToList(); + } + + if (rotatingStatuses.Count == 0) + continue; + + var playingStatus = index >= rotatingStatuses.Count + ? rotatingStatuses[index = 0] + : rotatingStatuses[index++]; + + var statusText = await _repService.ReplaceAsync(playingStatus.Status, new (client: _client)); + await _selfService.SetGameAsync(statusText, (ActivityType)playingStatus.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message); + } + } + } + + public async Task RemovePlayingAsync(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + await using var uow = _db.GetDbContext(); + var toRemove = await uow.Set().AsQueryable().AsNoTracking().Skip(index).FirstOrDefaultAsync(); + + if (toRemove is null) + return null; + + uow.Remove(toRemove); + await uow.SaveChangesAsync(); + return toRemove.Status; + } + + public async Task AddPlaying(ActivityType activityType, string status) + { + await using var uow = _db.GetDbContext(); + var toAdd = new RotatingPlayingStatus + { + Status = status, + Type = (EllieBot.Db.DbActivityType)activityType + }; + uow.Add(toAdd); + await uow.SaveChangesAsync(); + } + + public bool ToggleRotatePlaying() + { + var enabled = false; + _bss.ModifyConfig(bs => { enabled = bs.RotateStatuses = !bs.RotateStatuses; }); + return enabled; + } + + public IReadOnlyList GetRotatingStatuses() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().ToList(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs new file mode 100644 index 0000000..3b8ea72 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prefix/PrefixCommands.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PrefixCommands : EllieModule + { + public enum Set + { + Set + } + + [Cmd] + [Priority(1)] + public async Task Prefix() + => await Response().Confirm(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild)))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task Prefix(Set _, [Leftover] string newPrefix) + => Prefix(newPrefix); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task Prefix([Leftover] string toSet) + { + if (string.IsNullOrWhiteSpace(prefix)) + return; + + var oldPrefix = prefix; + var newPrefix = _cmdHandler.SetPrefix(ctx.Guild, toSet); + + await Response().Confirm(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task DefPrefix([Leftover] string toSet = null) + { + if (string.IsNullOrWhiteSpace(toSet)) + { + await Response().Confirm(strs.defprefix_current(_cmdHandler.GetPrefix())).SendAsync(); + return; + } + + var oldPrefix = _cmdHandler.GetPrefix(); + var newPrefix = _cmdHandler.SetDefaultPrefix(toSet); + + await Response().Confirm(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs new file mode 100644 index 0000000..64648b8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionCommands.cs @@ -0,0 +1,292 @@ +#nullable disable +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class ProtectionCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt() + { + if (await _service.TryStopAntiAlt(ctx.Guild.Id)) + { + await Response().Confirm(strs.prot_disable("Anti-Alt")).SendAsync(); + return; + } + + await Response().Confirm(strs.protection_not_running("Anti-Alt")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt( + StoopidTime minAge, + PunishmentAction action, + [Leftover] StoopidTime punishTime = null) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + var punishTimeMinutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + + if (minAgeMinutes < 1 || punishTimeMinutes < 0) + return; + + var minutes = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (action is PunishmentAction.TimeOut && minutes < 1) + minutes = 1; + + await _service.StartAntiAltAsync(ctx.Guild.Id, + minAgeMinutes, + action, + minutes); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntiAlt(StoopidTime minAge, PunishmentAction action, [Leftover] IRole role) + { + var minAgeMinutes = (int)minAge.Time.TotalMinutes; + + if (minAgeMinutes < 1) + return; + + if (action == PunishmentAction.TimeOut) + return; + + await _service.StartAntiAltAsync(ctx.Guild.Id, minAgeMinutes, action, roleId: role.Id); + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiRaid() + { + if (_service.TryStopAntiRaid(ctx.Guild.Id)) + return Response().Confirm(strs.prot_disable("Anti-Raid")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Raid")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiRaid( + int userThreshold, + int seconds, + PunishmentAction action, + [Leftover] StoopidTime punishTime) + => InternalAntiRaid(userThreshold, seconds, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiRaid(int userThreshold, int seconds, PunishmentAction action) + => InternalAntiRaid(userThreshold, seconds, action); + + private async Task InternalAntiRaid( + int userThreshold, + int seconds = 10, + PunishmentAction action = PunishmentAction.Mute, + StoopidTime punishTime = null) + { + if (action == PunishmentAction.AddRole) + { + await Response().Error(strs.punishment_unsupported(action)).SendAsync(); + return; + } + + if (userThreshold is < 2 or > 30) + { + await Response().Error(strs.raid_cnt(2, 30)).SendAsync(); + return; + } + + if (seconds is < 2 or > 300) + { + await Response().Error(strs.raid_time(2, 300)).SendAsync(); + return; + } + + if (punishTime is not null) + { + if (!_service.IsDurationAllowed(action)) + await Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + var time = (int?)punishTime?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if (action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiRaidAsync(ctx.Guild.Id, userThreshold, seconds, action, time); + + if (stats is null) + return; + + await Response() + .Confirm(GetText(strs.prot_enable("Anti-Raid")), + $"{ctx.User.Mention} {GetAntiRaidString(stats)}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public Task AntiSpam() + { + if (_service.TryStopAntiSpam(ctx.Guild.Id)) + return Response().Confirm(strs.prot_disable("Anti-Spam")).SendAsync(); + return Response().Pending(strs.protection_not_running("Anti-Spam")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] IRole role) + { + if (action != PunishmentAction.AddRole) + return Task.CompletedTask; + + return InternalAntiSpam(messageCount, action, null, role); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] StoopidTime punishTime) + => InternalAntiSpam(messageCount, action, punishTime); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(2)] + public Task AntiSpam(int messageCount, PunishmentAction action) + => InternalAntiSpam(messageCount, action); + + private async Task InternalAntiSpam( + int messageCount, + PunishmentAction action, + StoopidTime timeData = null, + IRole role = null) + { + if (messageCount is < 2 or > 10) + return; + + if (timeData is not null) + { + if (!_service.IsDurationAllowed(action)) + await Response().Error(strs.prot_cant_use_time).SendAsync(); + } + + var time = (int?)timeData?.Time.TotalMinutes ?? 0; + if (time is < 0 or > 60 * 24) + return; + + if (action is PunishmentAction.TimeOut && time < 1) + return; + + var stats = await _service.StartAntiSpamAsync(ctx.Guild.Id, messageCount, action, time, role?.Id); + + await Response() + .Confirm(GetText(strs.prot_enable("Anti-Spam")), + $"{ctx.User.Mention} {GetAntiSpamString(stats)}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AntispamIgnore() + { + var added = await _service.AntiSpamIgnoreAsync(ctx.Guild.Id, ctx.Channel.Id); + + if (added is null) + { + await Response().Error(strs.protection_not_running("Anti-Spam")).SendAsync(); + return; + } + + if (added.Value) + await Response().Confirm(strs.spam_ignore("Anti-Spam")).SendAsync(); + else + await Response().Confirm(strs.spam_not_ignore("Anti-Spam")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AntiList() + { + var (spam, raid, alt) = _service.GetAntiStats(ctx.Guild.Id); + + if (spam is null && raid is null && alt is null) + { + await Response().Confirm(strs.prot_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.prot_active)); + + if (spam is not null) + embed.AddField("Anti-Spam", GetAntiSpamString(spam).TrimTo(1024), true); + + if (raid is not null) + embed.AddField("Anti-Raid", GetAntiRaidString(raid).TrimTo(1024), true); + + if (alt is not null) + embed.AddField("Anti-Alt", GetAntiAltString(alt), true); + + await Response().Embed(embed).SendAsync(); + } + + private string GetAntiAltString(AntiAltStats alt) + => GetText(strs.anti_alt_status(Format.Bold(alt.MinAge.ToString(@"dd\d\ hh\h\ mm\m\ ")), + Format.Bold(alt.Action.ToString()), + Format.Bold(alt.Counter.ToString()))); + + private string GetAntiSpamString(AntiSpamStats stats) + { + var settings = stats.AntiSpamSettings; + var ignoredString = string.Join(", ", settings.IgnoredChannels.Select(c => $"<#{c.ChannelId}>")); + + if (string.IsNullOrWhiteSpace(ignoredString)) + ignoredString = "none"; + + var add = string.Empty; + if (settings.MuteTime > 0) + add = $" ({TimeSpan.FromMinutes(settings.MuteTime):hh\\hmm\\m})"; + + return GetText(strs.spam_stats(Format.Bold(settings.MessageThreshold.ToString()), + Format.Bold(settings.Action + add), + ignoredString)); + } + + private string GetAntiRaidString(AntiRaidStats stats) + { + var actionString = Format.Bold(stats.AntiRaidSettings.Action.ToString()); + + if (stats.AntiRaidSettings.PunishDuration > 0) + actionString += $" **({TimeSpan.FromMinutes(stats.AntiRaidSettings.PunishDuration):hh\\hmm\\m})**"; + + return GetText(strs.raid_stats(Format.Bold(stats.AntiRaidSettings.UserThreshold.ToString()), + Format.Bold(stats.AntiRaidSettings.Seconds.ToString()), + actionString)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs new file mode 100644 index 0000000..c28d3c4 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionService.cs @@ -0,0 +1,499 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Threading.Channels; + +namespace EllieBot.Modules.Administration.Services; + +public class ProtectionService : IEService +{ + public event Func OnAntiProtectionTriggered = delegate + { + return Task.CompletedTask; + }; + + private readonly ConcurrentDictionary _antiRaidGuilds = new(); + + private readonly ConcurrentDictionary _antiSpamGuilds = new(); + + private readonly ConcurrentDictionary _antiAltGuilds = new(); + + private readonly DiscordSocketClient _client; + private readonly MuteService _mute; + private readonly DbService _db; + private readonly UserPunishService _punishService; + + private readonly Channel _punishUserQueue = + Channel.CreateUnbounded(new() + { + SingleReader = true, + SingleWriter = false + }); + + public ProtectionService( + DiscordSocketClient client, + IBot bot, + MuteService mute, + DbService db, + UserPunishService punishService) + { + _client = client; + _mute = mute; + _db = db; + _punishService = punishService; + + var ids = client.GetGuildIds(); + using (var uow = db.GetDbContext()) + { + var configs = uow.Set() + .AsQueryable() + .Include(x => x.AntiRaidSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels) + .Include(x => x.AntiAltSetting) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + foreach (var gc in configs) + Initialize(gc); + } + + _client.MessageReceived += HandleAntiSpam; + _client.UserJoined += HandleUserJoined; + + bot.JoinedGuild += _bot_JoinedGuild; + _client.LeftGuild += _client_LeftGuild; + + _ = Task.Run(RunQueue); + } + + private async Task RunQueue() + { + while (true) + { + var item = await _punishUserQueue.Reader.ReadAsync(); + + var muteTime = item.MuteTime; + var gu = item.User; + try + { + await _punishService.ApplyPunishment(gu.Guild, + gu, + _client.CurrentUser, + item.Action, + muteTime, + item.RoleId, + $"{item.Type} Protection"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in punish queue: {Message}", ex.Message); + } + finally + { + await Task.Delay(1000); + } + } + } + + private Task _client_LeftGuild(SocketGuild guild) + { + _ = Task.Run(async () => + { + TryStopAntiRaid(guild.Id); + TryStopAntiSpam(guild.Id); + await TryStopAntiAlt(guild.Id); + }); + return Task.CompletedTask; + } + + private Task _bot_JoinedGuild(GuildConfig gc) + { + using var uow = _db.GetDbContext(); + var gcWithData = uow.GuildConfigsForId(gc.GuildId, + set => set.Include(x => x.AntiRaidSetting) + .Include(x => x.AntiAltSetting) + .Include(x => x.AntiSpamSetting) + .ThenInclude(x => x.IgnoredChannels)); + + Initialize(gcWithData); + return Task.CompletedTask; + } + + private void Initialize(GuildConfig gc) + { + var raid = gc.AntiRaidSetting; + var spam = gc.AntiSpamSetting; + + if (raid is not null) + { + var raidStats = new AntiRaidStats + { + AntiRaidSettings = raid + }; + _antiRaidGuilds[gc.GuildId] = raidStats; + } + + if (spam is not null) + { + _antiSpamGuilds[gc.GuildId] = new() + { + AntiSpamSettings = spam + }; + } + + var alt = gc.AntiAltSetting; + if (alt is not null) + _antiAltGuilds[gc.GuildId] = new(alt); + } + + private Task HandleUserJoined(SocketGuildUser user) + { + if (user.IsBot) + return Task.CompletedTask; + + _antiRaidGuilds.TryGetValue(user.Guild.Id, out var maybeStats); + _antiAltGuilds.TryGetValue(user.Guild.Id, out var maybeAlts); + + if (maybeStats is null && maybeAlts is null) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + if (maybeAlts is { } alts) + { + if (user.CreatedAt != default) + { + var diff = DateTime.UtcNow - user.CreatedAt.UtcDateTime; + if (diff < alts.MinAge) + { + alts.Increment(); + + await PunishUsers(alts.Action, + ProtectionType.Alting, + alts.ActionDurationMinutes, + alts.RoleId, + user); + + return; + } + } + } + + try + { + if (maybeStats is not { } stats || !stats.RaidUsers.Add(user)) + return; + + ++stats.UsersCount; + + if (stats.UsersCount >= stats.AntiRaidSettings.UserThreshold) + { + var users = stats.RaidUsers.ToArray(); + stats.RaidUsers.Clear(); + var settings = stats.AntiRaidSettings; + + await PunishUsers(settings.Action, ProtectionType.Raiding, settings.PunishDuration, null, users); + } + + await Task.Delay(1000 * stats.AntiRaidSettings.Seconds); + + stats.RaidUsers.TryRemove(user); + --stats.UsersCount; + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task HandleAntiSpam(SocketMessage arg) + { + if (arg is not SocketUserMessage msg || msg.Author.IsBot) + return Task.CompletedTask; + + if (msg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + if (!_antiSpamGuilds.TryGetValue(channel.Guild.Id, out var spamSettings) + || spamSettings.AntiSpamSettings.IgnoredChannels.Contains(new() + { + ChannelId = channel.Id + })) + return; + + var stats = spamSettings.UserStats.AddOrUpdate(msg.Author.Id, + _ => new(msg), + (_, old) => + { + old.ApplyNextMessage(msg); + return old; + }); + + if (stats.Count >= spamSettings.AntiSpamSettings.MessageThreshold) + { + if (spamSettings.UserStats.TryRemove(msg.Author.Id, out stats)) + { + var settings = spamSettings.AntiSpamSettings; + await PunishUsers(settings.Action, + ProtectionType.Spamming, + settings.MuteTime, + settings.RoleId, + (IGuildUser)msg.Author); + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task PunishUsers( + PunishmentAction action, + ProtectionType pt, + int muteTime, + ulong? roleId, + params IGuildUser[] gus) + { + Log.Information("[{PunishType}] - Punishing [{Count}] users with [{PunishAction}] in {GuildName} guild", + pt, + gus.Length, + action, + gus[0].Guild.Name); + + foreach (var gu in gus) + { + await _punishUserQueue.Writer.WriteAsync(new() + { + Action = action, + Type = pt, + User = gu, + MuteTime = muteTime, + RoleId = roleId + }); + } + + _ = OnAntiProtectionTriggered(action, pt, gus); + } + + public async Task StartAntiRaidAsync( + ulong guildId, + int userThreshold, + int seconds, + PunishmentAction action, + int minutesDuration) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (action == PunishmentAction.AddRole) + return null; + + if (!IsDurationAllowed(action)) + minutesDuration = 0; + + var stats = new AntiRaidStats + { + AntiRaidSettings = new() + { + Action = action, + Seconds = seconds, + UserThreshold = userThreshold, + PunishDuration = minutesDuration + } + }; + + _antiRaidGuilds.AddOrUpdate(guildId, stats, (_, _) => stats); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = stats.AntiRaidSettings; + await uow.SaveChangesAsync(); + + return stats; + } + + public bool TryStopAntiRaid(ulong guildId) + { + if (_antiRaidGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting)); + + gc.AntiRaidSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool TryStopAntiSpam(ulong guildId) + { + if (_antiSpamGuilds.TryRemove(guildId, out _)) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + + gc.AntiSpamSetting = null; + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task StartAntiSpamAsync( + ulong guildId, + int messageCount, + PunishmentAction action, + int punishDurationMinutes, + ulong? roleId) + { + var g = _client.GetGuild(guildId); + await _mute.GetMuteRole(g); + + if (!IsDurationAllowed(action)) + punishDurationMinutes = 0; + + var stats = new AntiSpamStats + { + AntiSpamSettings = new() + { + Action = action, + MessageThreshold = messageCount, + MuteTime = punishDurationMinutes, + RoleId = roleId + } + }; + + stats = _antiSpamGuilds.AddOrUpdate(guildId, + stats, + (_, old) => + { + stats.AntiSpamSettings.IgnoredChannels = old.AntiSpamSettings.IgnoredChannels; + return stats; + }); + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiSpamSetting)); + + if (gc.AntiSpamSetting is not null) + { + gc.AntiSpamSetting.Action = stats.AntiSpamSettings.Action; + gc.AntiSpamSetting.MessageThreshold = stats.AntiSpamSettings.MessageThreshold; + gc.AntiSpamSetting.MuteTime = stats.AntiSpamSettings.MuteTime; + gc.AntiSpamSetting.RoleId = stats.AntiSpamSettings.RoleId; + } + else + gc.AntiSpamSetting = stats.AntiSpamSettings; + + await uow.SaveChangesAsync(); + return stats; + } + + public async Task AntiSpamIgnoreAsync(ulong guildId, ulong channelId) + { + var obj = new AntiSpamIgnore + { + ChannelId = channelId + }; + bool added; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels)); + var spam = gc.AntiSpamSetting; + if (spam is null) + return null; + + if (spam.IgnoredChannels.Add(obj)) // if adding to db is successful + { + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Add(obj); // add to local cache + + added = true; + } + else + { + var toRemove = spam.IgnoredChannels.First(x => x.ChannelId == channelId); + uow.Set().Remove(toRemove); // remove from db + if (_antiSpamGuilds.TryGetValue(guildId, out var temp)) + temp.AntiSpamSettings.IgnoredChannels.Remove(toRemove); // remove from local cache + + added = false; + } + + await uow.SaveChangesAsync(); + return added; + } + + public (AntiSpamStats, AntiRaidStats, AntiAltStats) GetAntiStats(ulong guildId) + { + _antiRaidGuilds.TryGetValue(guildId, out var antiRaidStats); + _antiSpamGuilds.TryGetValue(guildId, out var antiSpamStats); + _antiAltGuilds.TryGetValue(guildId, out var antiAltStats); + + return (antiSpamStats, antiRaidStats, antiAltStats); + } + + public bool IsDurationAllowed(PunishmentAction action) + { + switch (action) + { + case PunishmentAction.Ban: + case PunishmentAction.Mute: + case PunishmentAction.ChatMute: + case PunishmentAction.VoiceMute: + case PunishmentAction.AddRole: + case PunishmentAction.TimeOut: + return true; + default: + return false; + } + } + + public async Task StartAntiAltAsync( + ulong guildId, + int minAgeMinutes, + PunishmentAction action, + int actionDurationMinutes = 0, + ulong? roleId = null) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = new() + { + Action = action, + ActionDurationMinutes = actionDurationMinutes, + MinAge = TimeSpan.FromMinutes(minAgeMinutes), + RoleId = roleId + }; + + await uow.SaveChangesAsync(); + _antiAltGuilds[guildId] = new(gc.AntiAltSetting); + } + + public async Task TryStopAntiAlt(ulong guildId) + { + if (!_antiAltGuilds.TryRemove(guildId, out _)) + return false; + + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting)); + gc.AntiAltSetting = null; + await uow.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs new file mode 100644 index 0000000..f45db4e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/ProtectionStats.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public enum ProtectionType +{ + Raiding, + Spamming, + Alting +} + +public class AntiRaidStats +{ + public AntiRaidSetting AntiRaidSettings { get; set; } + public int UsersCount { get; set; } + public ConcurrentHashSet RaidUsers { get; set; } = new(); +} + +public class AntiSpamStats +{ + public AntiSpamSetting AntiSpamSettings { get; set; } + public ConcurrentDictionary UserStats { get; set; } = new(); +} + +public class AntiAltStats +{ + public PunishmentAction Action + => _setting.Action; + + public int ActionDurationMinutes + => _setting.ActionDurationMinutes; + + public ulong? RoleId + => _setting.RoleId; + + public TimeSpan MinAge + => _setting.MinAge; + + public int Counter + => counter; + + private readonly AntiAltSetting _setting; + + private int counter; + + public AntiAltStats(AntiAltSetting setting) + => _setting = setting; + + public void Increment() + => Interlocked.Increment(ref counter); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs new file mode 100644 index 0000000..9cff02e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/PunishQueueItem.cs @@ -0,0 +1,13 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public class PunishQueueItem +{ + public PunishmentAction Action { get; set; } + public ProtectionType Type { get; set; } + public int MuteTime { get; set; } + public ulong? RoleId { get; set; } + public IGuildUser User { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs new file mode 100644 index 0000000..ad4a9bf --- /dev/null +++ b/src/EllieBot/Modules/Administration/Protection/UserSpamStats.cs @@ -0,0 +1,64 @@ +#nullable disable +namespace EllieBot.Modules.Administration; + +public sealed class UserSpamStats +{ + public int Count + { + get + { + lock (_applyLock) + { + Cleanup(); + return _messageTracker.Count; + } + } + } + + private string lastMessage; + + private readonly Queue _messageTracker; + + private readonly object _applyLock = new(); + + private readonly TimeSpan _maxTime = TimeSpan.FromMinutes(30); + + public UserSpamStats(IUserMessage msg) + { + lastMessage = msg.Content.ToUpperInvariant(); + _messageTracker = new(); + + ApplyNextMessage(msg); + } + + public void ApplyNextMessage(IUserMessage message) + { + var upperMsg = message.Content.ToUpperInvariant(); + + lock (_applyLock) + { + if (upperMsg != lastMessage || (string.IsNullOrWhiteSpace(upperMsg) && message.Attachments.Any())) + { + // if it's a new message, reset spam counter + lastMessage = upperMsg; + _messageTracker.Clear(); + } + + _messageTracker.Enqueue(DateTime.UtcNow); + } + } + + private void Cleanup() + { + lock (_applyLock) + { + while (_messageTracker.TryPeek(out var dateTime)) + { + if (DateTime.UtcNow - dateTime < _maxTime) + break; + + _messageTracker.Dequeue(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs new file mode 100644 index 0000000..58b586e --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneCommands.cs @@ -0,0 +1,198 @@ +#nullable disable +using CommandLine; +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class PruneCommands : EllieModule + { + private static readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + + public sealed class PruneOptions : IEllieCommandOptions + { + [Option(shortName: 's', + longName: "safe", + Default = false, + HelpText = "Whether pinned messages should be deleted.", + Required = false)] + public bool Safe { get; set; } + + [Option(shortName: 'a', + longName: "after", + Default = null, + HelpText = "Prune only messages after the specified message ID.", + Required = false)] + public ulong? After { get; set; } + + public void NormalizeOptions() + { + } + } + + //deletes her own messages, no perm required + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Prune(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var user = await ctx.Guild.GetCurrentUserAsync(); + + var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id && !x.IsPinned, + progress, + opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, + 100, + x => x.Author.Id == user.Id, + progress, + opts.After); + + ctx.Message.DeleteAfter(3); + await progressMsg.DeleteAsync(); + } + + // prune x + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(1)] + public async Task Prune(int count, params string[] args) + { + count++; + if (count < 1) + return; + + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => !x.IsPinned && x.Id != progressMsg.Id, + progress, + opts.After); + else + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + x => x.Id != progressMsg.Id, + progress, + opts.After); + + await progressMsg.DeleteAsync(); + } + + private IProgress<(int, int)> GetProgressTracker(IUserMessage progressMsg) + { + var progress = new Progress<(int, int)>(async (x) => + { + var (deleted, total) = x; + try + { + await progressMsg.ModifyAsync(props => + { + props.Embed = _sender.CreateEmbed() + .WithPendingColor() + .WithDescription(GetText(strs.prune_progress(deleted, total))) + .Build(); + }); + } + catch + { + } + }); + + return progress; + } + + //prune @user [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public Task Prune(IGuildUser user, int count = 100, params string[] args) + => Prune(user.Id, count, args); + + //prune userid [x] + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + [EllieOptions] + [Priority(0)] + public async Task Prune(ulong userId, int count = 100, params string[] args) + { + if (userId == ctx.User.Id) + count++; + + if (count < 1) + return; + + if (count > 1000) + count = 1000; + + var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args); + + var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync(); + var progress = GetProgressTracker(progressMsg); + + if (opts.Safe) + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned, + progress, + opts.After + ); + } + else + { + await _service.PruneWhere((ITextChannel)ctx.Channel, + count, + m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks, + progress, + opts.After + ); + } + + await progressMsg.DeleteAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(ChannelPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages)] + public async Task PruneCancel() + { + var ok = await _service.CancelAsync(ctx.Guild.Id); + + if (!ok) + { + await Response().Error(strs.prune_not_found).SendAsync(); + return; + } + + + await Response().Confirm(strs.prune_cancelled).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Prune/PruneService.cs b/src/EllieBot/Modules/Administration/Prune/PruneService.cs new file mode 100644 index 0000000..006f9e8 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Prune/PruneService.cs @@ -0,0 +1,101 @@ +#nullable disable +namespace EllieBot.Modules.Administration.Services; + +public class PruneService : IEService +{ + //channelids where prunes are currently occuring + private readonly ConcurrentDictionary _pruningGuilds = new(); + private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14); + private readonly ILogCommandService _logService; + + public PruneService(ILogCommandService logService) + => _logService = logService; + + public async Task PruneWhere( + ITextChannel channel, + int amount, + Func predicate, + IProgress<(int deleted, int total)> progress, + ulong? after = null + ) + { + ArgumentNullException.ThrowIfNull(channel, nameof(channel)); + + var originalAmount = amount; + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + using var cancelSource = new CancellationTokenSource(); + if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource)) + return; + + try + { + var now = DateTime.UtcNow; + IMessage[] msgs; + IMessage lastMessage = null; + + while (amount > 0 && !cancelSource.IsCancellationRequested) + { + var dled = lastMessage is null + ? await channel.GetMessagesAsync(50).FlattenAsync() + : await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync(); + + msgs = dled + .Where(predicate) + .Where(x => after is not ulong a || x.Id > a) + .Take(amount) + .ToArray(); + + if (!msgs.Any()) + return; + + lastMessage = msgs[^1]; + + var bulkDeletable = new List(); + var singleDeletable = new List(); + foreach (var x in msgs) + { + _logService.AddDeleteIgnore(x.Id); + + if (now - x.CreatedAt < _twoWeeks) + bulkDeletable.Add(x); + else + singleDeletable.Add(x); + } + + if (bulkDeletable.Count > 0) + { + await channel.DeleteMessagesAsync(bulkDeletable); + amount -= msgs.Length; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(2000, cancelSource.Token); + } + + foreach (var group in singleDeletable.Chunk(5)) + { + await group.Select(x => x.DeleteAsync()).WhenAll(); + amount -= 5; + progress.Report((originalAmount - amount, originalAmount)); + await Task.Delay(5000, cancelSource.Token); + } + } + } + catch + { + //ignore + } + finally + { + _pruningGuilds.TryRemove(channel.GuildId, out _); + } + } + + public async Task CancelAsync(ulong guildId) + { + if (!_pruningGuilds.TryRemove(guildId, out var source)) + return false; + + await source.CancelAsync(); + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs new file mode 100644 index 0000000..85f7945 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/IReactionRoleService.cs @@ -0,0 +1,52 @@ +#nullable disable +using EllieBot.Modules.Patronage; +using EllieBot.Db.Models; +using OneOf; +using OneOf.Types; + +namespace EllieBot.Modules.Administration.Services; + +public interface IReactionRoleService +{ + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0); + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + Task> GetReactionRolesAsync(ulong guildId); + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + Task RemoveReactionRoles(ulong guildId, ulong messageId); + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + Task RemoveAllReactionRoles(ulong guildId); + + Task> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs new file mode 100644 index 0000000..800512a --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRoleCommands.cs @@ -0,0 +1,176 @@ +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + public partial class ReactionRoleCommands : EllieModule + { + private readonly IReactionRoleService _rero; + + public ReactionRoleCommands(IReactionRoleService rero) + { + _rero = rero; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoAdd( + ulong messageId, + string emoteStr, + IRole role, + int group = 0, + int levelReq = 0) + { + if (group < 0) + return; + + if (levelReq < 0) + return; + + var msg = await ctx.Channel.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + if (ctx.User.Id != ctx.Guild.OwnerId + && ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position) + { + await Response().Error(strs.hierarchy).SendAsync(); + return; + } + + var emote = emoteStr.ToIEmote(); + await msg.AddReactionAsync(emote); + var res = await _rero.AddReactionRole(ctx.Guild, + msg, + emoteStr, + role, + group, + levelReq); + + await res.Match( + _ => ctx.OkAsync(), + fl => + { + _ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser); + return !fl.IsPatronLimit + ? Response().Error(strs.limit_reached(fl.Quota)).SendAsync() + : Response().Pending(strs.feature_limit_reached_owner(fl.Quota, fl.Name)).SendAsync(); + }); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoList(int page = 1) + { + if (--page < 0) + return; + + var allReros = await _rero.GetReactionRolesAsync(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allReros.OrderBy(x => x.Group).ToList()) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed() + .WithOkColor(); + + var content = string.Empty; + foreach (var g in items + .GroupBy(x => x.MessageId) + .OrderBy(x => x.Key)) + { + var messageId = g.Key; + content += + $"[{messageId}](https://discord.com/channels/{ctx.Guild.Id}/{g.First().ChannelId}/{g.Key})\n"; + + var groupGroups = g.GroupBy(x => x.Group); + + foreach (var ggs in groupGroups) + { + content += $"`< {(g.Key == 0 ? ("Not Exclusive (Group 0)") : ($"Group {ggs.Key}"))} >`\n"; + + foreach (var rero in ggs) + { + content += + $"\t{rero.Emote} -> {(ctx.Guild.GetRole(rero.RoleId)?.Mention ?? "")}"; + if (rero.LevelReq > 0) + content += $" (lvl {rero.LevelReq}+)"; + content += '\n'; + } + } + } + + embed.WithDescription(string.IsNullOrWhiteSpace(content) + ? "There are no reaction roles on this server" + : content); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoRemove(ulong messageId) + { + var succ = await _rero.RemoveReactionRoles(ctx.Guild.Id, messageId); + if (succ) + await ctx.OkAsync(); + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ReRoDeleteAll() + { + await _rero.RemoveAllReactionRoles(ctx.Guild.Id); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Ratelimit(60)] + public async Task ReRoTransfer(ulong fromMessageId, ulong toMessageId) + { + var msg = await ctx.Channel.GetMessageAsync(toMessageId); + + if (msg is null) + { + await ctx.ErrorAsync(); + return; + } + + var reactions = await _rero.TransferReactionRolesAsync(ctx.Guild.Id, fromMessageId, toMessageId); + + if (reactions.Count == 0) + { + await ctx.ErrorAsync(); + } + else + { + foreach (var r in reactions) + { + await msg.AddReactionAsync(r); + } + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs new file mode 100644 index 0000000..f1216ef --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/ReactionRolesService.cs @@ -0,0 +1,408 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Patronage; +using EllieBot.Db.Models; +using OneOf.Types; +using OneOf; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class ReactionRolesService : IReadyExecutor, IEService, IReactionRoleService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + + private ConcurrentDictionary> _cache; + private readonly object _cacheLock = new(); + private readonly SemaphoreSlim _assignementLock = new(1, 1); + private readonly IPatronageService _ps; + + private static readonly FeatureLimitKey _reroFLKey = new() + { + Key = "rero:max_count", + PrettyName = "Reaction Role" + }; + + public ReactionRolesService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds, + IPatronageService ps) + { + _db = db; + _ps = ps; + _client = client; + _creds = creds; + _cache = new(); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + var reros = await uow.GetTable() + .Where( + x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId)) + .ToListAsyncLinqToDB(); + + foreach (var group in reros.GroupBy(x => x.MessageId)) + { + _cache[group.Key] = group.ToList(); + } + + _client.ReactionAdded += ClientOnReactionAdded; + _client.ReactionRemoved += ClientOnReactionRemoved; + } + + private async Task<(IGuildUser, IRole)> GetUserAndRoleAsync( + ulong userId, + ReactionRoleV2 rero) + { + var guild = _client.GetGuild(rero.GuildId); + var role = guild?.GetRole(rero.RoleId); + + if (role is null) + return default; + + var user = guild.GetUser(userId) as IGuildUser + ?? await _client.Rest.GetGuildUserAsync(guild.Id, userId); + + if (user is null) + return default; + + return (user, role); + } + + private Task ClientOnReactionRemoved( + Cacheable cmsg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(cmsg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var emote = await GetFixedEmoteAsync(cmsg, r.Emote); + + var rero = reros.FirstOrDefault(x => x.Emote == emote.Name + || x.Emote == emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (user.RoleIds.Contains(role.Id)) + { + await user.RemoveRoleAsync(role.Id, new RequestOptions() + { + AuditLogReason = $"Reaction role" + }); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + + // had to add this because for some reason, reactionremoved event's reaction doesn't have IsAnimated set, + // causing the .ToString() to be wrong on animated custom emotes + private async Task GetFixedEmoteAsync( + Cacheable cmsg, + IEmote inputEmote) + { + // this should only run for emote + if (inputEmote is not Emote e) + return inputEmote; + + // try to get the message and pull + var msg = await cmsg.GetOrDownloadAsync(); + + var emote = msg.Reactions.Keys.FirstOrDefault(x => e.Equals(x)); + return emote ?? inputEmote; + } + + private Task ClientOnReactionAdded( + Cacheable msg, + Cacheable ch, + SocketReaction r) + { + if (!_cache.TryGetValue(msg.Id, out var reros)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var rero = reros.FirstOrDefault(x => x.Emote == r.Emote.Name || x.Emote == r.Emote.ToString()); + if (rero is null) + return; + + var (user, role) = await GetUserAndRoleAsync(r.UserId, rero); + + if (user.IsBot) + return; + + await _assignementLock.WaitAsync(); + try + { + if (!user.RoleIds.Contains(role.Id)) + { + // first check if there is a level requirement + // and if there is, make sure user satisfies it + if (rero.LevelReq > 0) + { + await using var ctx = _db.GetDbContext(); + var levelData = await ctx.GetTable() + .GetLevelDataFor(user.GuildId, user.Id); + + if (levelData.Level < rero.LevelReq) + return; + } + + // remove all other roles from the same group from the user + // execept in group 0, which is a special, non-exclusive group + if (rero.Group != 0) + { + var exclusive = reros + .Where(x => x.Group == rero.Group && x.RoleId != role.Id) + .Select(x => x.RoleId) + .Distinct() + .ToArray(); + + + if (exclusive.Any()) + { + try + { + await user.RemoveRolesAsync(exclusive, + new RequestOptions() + { + AuditLogReason = "Reaction role exclusive group" + }); + } + catch { } + } + + // remove user's previous reaction + try + { + var m = await msg.GetOrDownloadAsync(); + if (m is not null) + { + var reactToRemove = m.Reactions + .FirstOrDefault(x => x.Key.ToString() != r.Emote.ToString()) + .Key; + + if (reactToRemove is not null) + { + await m.RemoveReactionAsync(reactToRemove, user); + } + } + } + catch + { + } + } + + await user.AddRoleAsync(role.Id, new() + { + AuditLogReason = "Reaction role" + }); + } + } + finally + { + _assignementLock.Release(); + } + }); + + return Task.CompletedTask; + } + + /// + /// Adds a single reaction role + /// + /// Guild where to add a reaction role + /// Message to which to add a reaction role + /// + /// + /// + /// + /// The result of the operation + public async Task> AddReactionRole( + IGuild guild, + IMessage msg, + string emote, + IRole role, + int group = 0, + int levelReq = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(group); + + ArgumentOutOfRangeException.ThrowIfNegative(levelReq); + + await using var ctx = _db.GetDbContext(); + + await using var tran = await ctx.Database.BeginTransactionAsync(); + var activeReactionRoles = await ctx.GetTable() + .Where(x => x.GuildId == guild.Id) + .CountAsync(); + + var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50); + if (result.Quota != -1 && activeReactionRoles >= result.Quota) + return result; + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guild.Id, + ChannelId = msg.Channel.Id, + + MessageId = msg.Id, + Emote = emote, + + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + (old) => new() + { + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }, + () => new() + { + MessageId = msg.Id, + Emote = emote, + }); + + await tran.CommitAsync(); + + var obj = new ReactionRoleV2() + { + GuildId = guild.Id, + MessageId = msg.Id, + Emote = emote, + RoleId = role.Id, + Group = group, + LevelReq = levelReq + }; + + lock (_cacheLock) + { + _cache.AddOrUpdate(msg.Id, + _ => [obj], + (_, list) => + { + list.RemoveAll(x => x.Emote == emote); + list.Add(obj); + return list; + }); + } + + return new Success(); + } + + /// + /// Get all reaction roles on the specified server + /// + /// + /// + public async Task> GetReactionRolesAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + /// + /// Remove reaction roles on the specified message + /// + /// + /// + /// + public async Task RemoveReactionRoles(ulong guildId, ulong messageId) + { + // guildid is used for quick index lookup + await using var ctx = _db.GetDbContext(); + var changed = await ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == messageId) + .DeleteAsync(); + + _cache.TryRemove(messageId, out _); + + if (changed == 0) + return false; + + return true; + } + + /// + /// Remove all reaction roles in the specified server + /// + /// + /// + public async Task RemoveAllReactionRoles(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var output = await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .DeleteWithOutputAsync(x => x.MessageId); + + lock (_cacheLock) + { + foreach (var o in output) + { + _cache.TryRemove(o, out _); + } + } + + return output.Length; + } + + public async Task> TransferReactionRolesAsync( + ulong guildId, + ulong fromMessageId, + ulong toMessageId) + { + await using var ctx = _db.GetDbContext(); + var updated = ctx.GetTable() + .Where(x => x.GuildId == guildId && x.MessageId == fromMessageId) + .UpdateWithOutput(old => new() + { + MessageId = toMessageId + }, + (old, neu) => neu); + lock (_cacheLock) + { + if (_cache.TryRemove(fromMessageId, out var data)) + { + if (_cache.TryGetValue(toMessageId, out var newData)) + { + newData.AddRange(data); + } + else + { + _cache[toMessageId] = data; + } + } + } + + return updated.Select(x => x.Emote.ToIEmote()).ToList(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/RoleCommands.cs b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs new file mode 100644 index 0000000..7f5daea --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/RoleCommands.cs @@ -0,0 +1,209 @@ +#nullable disable +using SixLabors.ImageSharp.PixelFormats; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + public partial class RoleCommands : EllieModule + { + public enum Exclude + { + Excl + } + + private readonly IServiceProvider _services; + private StickyRolesService _stickyRoleSvc; + + public RoleCommands(IServiceProvider services, StickyRolesService stickyRoleSvc) + { + _services = services; + _stickyRoleSvc = stickyRoleSvc; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task SetRole(IGuildUser targetUser, [Leftover] IRole roleToAdd) + { + var runnerUser = (IGuildUser)ctx.User; + var runnerMaxRolePosition = runnerUser.GetRoles().Max(x => x.Position); + if (ctx.User.Id != ctx.Guild.OwnerId && runnerMaxRolePosition <= roleToAdd.Position) + return; + try + { + await targetUser.AddRoleAsync(roleToAdd, new RequestOptions() + { + AuditLogReason = $"Added by [{ctx.User.Username}]" + }); + + await Response().Confirm(strs.setrole(Format.Bold(roleToAdd.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in setrole command"); + await Response().Error(strs.setrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveRole(IGuildUser targetUser, [Leftover] IRole roleToRemove) + { + var runnerUser = (IGuildUser)ctx.User; + if (ctx.User.Id != runnerUser.Guild.OwnerId + && runnerUser.GetRoles().Max(x => x.Position) <= roleToRemove.Position) + return; + try + { + await targetUser.RemoveRoleAsync(roleToRemove); + await Response().Confirm(strs.remrole(Format.Bold(roleToRemove.Name), + Format.Bold(targetUser.ToString()))).SendAsync(); + } + catch + { + await Response().Error(strs.remrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RenameRole(IRole roleToEdit, [Leftover] string newname) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= roleToEdit.Position) + return; + try + { + if (roleToEdit.Position > (await ctx.Guild.GetCurrentUserAsync()).GetRoles().Max(r => r.Position)) + { + await Response().Error(strs.renrole_perms).SendAsync(); + return; + } + + await roleToEdit.ModifyAsync(g => g.Name = newname); + await Response().Confirm(strs.renrole).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.renrole_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RemoveAllRoles([Leftover] IGuildUser user) + { + var guser = (IGuildUser)ctx.User; + + var userRoles = user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole).ToList(); + + if (user.Id == ctx.Guild.OwnerId + || (ctx.User.Id != ctx.Guild.OwnerId + && guser.GetRoles().Max(x => x.Position) <= userRoles.Max(x => x.Position))) + return; + try + { + await user.RemoveRolesAsync(userRoles); + await Response().Confirm(strs.rar(Format.Bold(user.ToString()))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rar_err).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task CreateRole([Leftover] string roleName = null) + { + if (string.IsNullOrWhiteSpace(roleName)) + return; + + var r = await ctx.Guild.CreateRoleAsync(roleName, isMentionable: false); + await Response().Confirm(strs.cr(Format.Bold(r.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task DeleteRole([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + await role.DeleteAsync(); + await Response().Confirm(strs.dr(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleHoist([Leftover] IRole role) + { + var newHoisted = !role.IsHoisted; + await role.ModifyAsync(r => r.Hoist = newHoisted); + if (newHoisted) + await Response().Confirm(strs.rolehoist_enabled(Format.Bold(role.Name))).SendAsync(); + else + await Response().Confirm(strs.rolehoist_disabled(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RoleColor([Leftover] IRole role) + => await Response().Confirm("Role Color", role.Color.RawValue.ToString("x6")).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task RoleColor(Color color, [Leftover] IRole role) + { + try + { + var rgba32 = color.ToPixel(); + await role.ModifyAsync(r => r.Color = new Discord.Color(rgba32.R, rgba32.G, rgba32.B)); + await Response().Confirm(strs.rc(Format.Bold(role.Name))).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.rc_perms).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task StickyRoles() + { + var newState = await _stickyRoleSvc.ToggleStickyRoles(ctx.Guild.Id); + + if (newState) + { + await Response().Confirm(strs.sticky_roles_enabled).SendAsync(); + } + else + { + await Response().Confirm(strs.sticky_roles_disabled).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs new file mode 100644 index 0000000..1fcfc15 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Role/StickyRolesService.cs @@ -0,0 +1,139 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; + +namespace EllieBot.Modules.Administration; + +public sealed class StickyRolesService : IEService, IReadyExecutor +{ + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly DbService _db; + private HashSet _stickyRoles = new(); + + public StickyRolesService( + DiscordSocketClient client, + IBotCredentials creds, + DbService db) + { + _client = client; + _creds = creds; + _db = db; + } + + + public async Task OnReadyAsync() + { + await using (var ctx = _db.GetDbContext()) + { + _stickyRoles = (await ctx + .Set() + .ToLinqToDBTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, + _creds.TotalShards, + _client.ShardId)) + .Where(x => x.StickyRoles) + .Select(x => x.GuildId) + .ToListAsync()) + .ToHashSet(); + } + + _client.UserJoined += ClientOnUserJoined; + _client.UserLeft += ClientOnUserLeft; + + // cleanup old ones every hour + // 30 days retention + if (_client.ShardId == 0) + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.DateAdded < DateTime.UtcNow - TimeSpan.FromDays(30)) + .DeleteAsync(); + } + } + } + + private Task ClientOnUserLeft(SocketGuild guild, SocketUser user) + { + if (user is not SocketGuildUser gu) + return Task.CompletedTask; + + if (!_stickyRoles.Contains(guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => await SaveRolesAsync(guild.Id, gu.Id, gu.Roles)); + + return Task.CompletedTask; + } + + private async Task SaveRolesAsync(ulong guildId, ulong userId, IReadOnlyCollection guRoles) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertAsync(() => new() + { + GuildId = guildId, + UserId = userId, + RoleIds = string.Join(',', + guRoles.Where(x => !x.IsEveryone && !x.IsManaged).Select(x => x.Id.ToString())), + DateAdded = DateTime.UtcNow + }); + } + + private Task ClientOnUserJoined(SocketGuildUser user) + { + if (!_stickyRoles.Contains(user.Guild.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var roles = await GetRolesAsync(user.Guild.Id, user.Id); + + await user.AddRolesAsync(roles); + }); + + return Task.CompletedTask; + } + + private async Task GetRolesAsync(ulong guildId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var stickyRolesEntry = await ctx + .GetTable() + .Where(x => x.GuildId == guildId && x.UserId == userId) + .DeleteWithOutputAsync(); + + if (stickyRolesEntry is { Length: > 0 }) + { + return stickyRolesEntry[0].GetRoleIds(); + } + + return []; + } + + public async Task ToggleStickyRoles(ulong guildId, bool? newState = null) + { + await using var ctx = _db.GetDbContext(); + var config = ctx.GuildConfigsForId(guildId, set => set); + + config.StickyRoles = newState ?? !config.StickyRoles; + await ctx.SaveChangesAsync(); + + if (config.StickyRoles) + { + _stickyRoles.Add(guildId); + } + else + { + _stickyRoles.Remove(guildId); + } + + return config.StickyRoles; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs new file mode 100644 index 0000000..8bcf2e1 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/CheckForUpdatesService.cs @@ -0,0 +1,169 @@ +using System.Net.Http.Json; +using System.Text; +using EllieBot.Common.ModuleBehaviors; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Administration.Self; + +public sealed class ToastielabReleaseModel +{ + [JsonPropertyName("tag_name")] + public required string TagName { get; init; } +} +public sealed class CheckForUpdatesService : IEService, IReadyExecutor +{ + private readonly BotConfigService _bcs; + private readonly IBotCredsProvider _bcp; + private readonly IHttpClientFactory _httpFactory; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + + private const string RELEASES_URL = "https://toastielab.dev/api/v1/repos/Emotions-stuff/Ellie/releases"; + + public CheckForUpdatesService( + BotConfigService bcs, + IBotCredsProvider bcp, + IHttpClientFactory httpFactory, + DiscordSocketClient client, + IMessageSenderService sender) + { + _bcs = bcs; + _bcp = bcp; + _httpFactory = httpFactory; + _client = client; + _sender = sender; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + var conf = _bcs.Data; + + if (!conf.CheckForUpdates) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + var gitlabRelease = (await http.GetFromJsonAsync(RELEASES_URL)) + ?.FirstOrDefault(); + + if (gitlabRelease?.TagName is null) + continue; + + var latest = gitlabRelease.TagName; + var latestVersion = Version.Parse(latest); + var lastKnownVersion = GetLastKnownVersion(); + + if (lastKnownVersion is null) + { + UpdateLastKnownVersion(latestVersion); + continue; + } + + if (latestVersion > lastKnownVersion) + { + UpdateLastKnownVersion(latestVersion); + + // pull changelog + var changelog = await http.GetStringAsync("https://toastielab.dev/Emotions-stuff/Ellie/raw/branch/main/CHANGELOG.md"); + + var thisVersionChangelog = GetVersionChangelog(latestVersion, changelog); + + if (string.IsNullOrWhiteSpace(thisVersionChangelog)) + { + Log.Warning("New version {BotVersion} was found but changelog is unavailable", + thisVersionChangelog); + continue; + } + + var creds = _bcp.GetCreds(); + await creds.OwnerIds + .Select(async x => + { + var user = await _client.GetUserAsync(x); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"EllieBot v{latest} Released!") + .WithTitle("Changelog") + .WithUrl("https://toastielab.dev/Emotions-stuff/Ellie/src/branch/main/CHANGELOG.md") + .WithDescription(thisVersionChangelog.TrimTo(4096)) + .WithFooter( + "You may disable these messages by typing '.conf bot checkforupdates false'"); + + await _sender.Response(user).Embed(eb).SendAsync(); + }) + .WhenAll(); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error while checking for new bot release: {ErrorMessage}", ex.Message); + } + } + } + + private string? GetVersionChangelog(Version latestVersion, string changelog) + { + var clSpan = changelog.AsSpan(); + + var sb = new StringBuilder(); + var started = false; + foreach (var line in clSpan.EnumerateLines()) + { + // if we're at the current version, keep reading lines and adding to the output + if (started) + { + // if we got to previous version, end + if (line.StartsWith("## [")) + break; + + // if we're reading a new segment, reformat it to print it better to discord + if (line.StartsWith("### ")) + { + sb.AppendLine(Format.Bold(line.ToString())); + } + else + { + sb.AppendLine(line.ToString()); + } + + continue; + } + + if (line.StartsWith($"## [{latestVersion.ToString()}]")) + { + started = true; + continue; + } + } + + return sb.ToString(); + } + + private const string LAST_KNOWN_VERSION_PATH = "data/last_known_version.txt"; + + private Version? GetLastKnownVersion() + { + if (!File.Exists(LAST_KNOWN_VERSION_PATH)) + return null; + + return Version.TryParse(File.ReadAllText(LAST_KNOWN_VERSION_PATH), out var ver) + ? ver + : null; + } + + private void UpdateLastKnownVersion(Version version) + { + File.WriteAllText("data/last_known_version.txt", version.ToString()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/SelfCommands.cs b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs new file mode 100644 index 0000000..d67ac93 --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfCommands.cs @@ -0,0 +1,586 @@ +#nullable disable +using Discord.Rest; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfCommands : EllieModule + { + public enum SettableUserStatus + { + Online, + Invisible, + Idle, + Dnd + } + + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + private readonly IMarmaladeLoaderService _marmaladeLoader; + private readonly ICoordinator _coord; + private readonly DbService _db; + + public SelfCommands( + DiscordSocketClient client, + DbService db, + IBotStrings strings, + ICoordinator coord, + IMarmaladeLoaderService marmaladeLoader) + { + _client = client; + _db = db; + _strings = strings; + _coord = coord; + _marmaladeLoader = marmaladeLoader; + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public Task CacheUsers() + => CacheUsers(ctx.Guild); + + [Cmd] + [OwnerOnly] + public async Task CacheUsers(IGuild guild) + { + var downloadUsersTask = guild.DownloadUsersAsync(); + var message = await Response().Pending(strs.cache_users_pending).SendAsync(); + + await downloadUsersTask; + + var users = (await guild.GetUsersAsync(CacheMode.CacheOnly)) + .Cast() + .ToList(); + + var (added, updated) = await _service.RefreshUsersAsync(users); + + await message.ModifyAsync(x => + x.Embed = _sender.CreateEmbed() + .WithDescription(GetText(strs.cache_users_done(added, updated))) + .WithOkColor() + .Build() + ); + } + + [Cmd] + [OwnerOnly] + public async Task DoAs(IUser user, [Leftover] string message) + { + if (ctx.User is not IGuildUser { GuildPermissions.Administrator: true }) + return; + + if (ctx.Guild is SocketGuild sg + && ctx.Channel is ISocketMessageChannel ch + && ctx.Message is SocketUserMessage msg) + { + var fakeMessage = new DoAsUserMessage(msg, user, message); + + + await _cmdHandler.TryRunCommand(sg, ch, fakeMessage); + } + else + { + await Response().Error(strs.error_occured).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandAdd([Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = 0 + }; + _service.AddNewAutoCommand(cmd); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.scadd)) + .AddField(GetText(strs.server), + cmd.GuildId is null ? "-" : $"{cmd.GuildName}/{cmd.GuildId}", + true) + .AddField(GetText(strs.channel), $"{cmd.ChannelName}/{cmd.ChannelId}", true) + .AddField(GetText(strs.command_text), cmdText)) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandAdd(int interval, [Leftover] string cmdText) + { + if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + + if (interval < 5) + return; + + var guser = (IGuildUser)ctx.User; + var cmd = new AutoCommand + { + CommandText = cmdText, + ChannelId = ctx.Channel.Id, + ChannelName = ctx.Channel.Name, + GuildId = ctx.Guild?.Id, + GuildName = ctx.Guild?.Name, + VoiceChannelId = guser.VoiceChannel?.Id, + VoiceChannelName = guser.VoiceChannel?.Name, + Interval = interval + }; + _service.AddNewAutoCommand(cmd); + + await Response().Confirm(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetStartupCommands().Skip(page * 5).Take(5).ToList(); + + if (scmds.Count == 0) + await Response().Error(strs.startcmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task AutoCommandsList(int page = 1) + { + if (page-- < 1) + return; + + var scmds = _service.GetAutoCommands().Skip(page * 5).Take(5).ToList(); + if (!scmds.Any()) + await Response().Error(strs.autocmdlist_none).SendAsync(); + else + { + var i = 0; + await Response() + .Confirm(text: string.Join("\n", + scmds.Select(x => $@"```css +#{++i + (page * 5)} +[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")} +[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId} +{GetIntervalText(x.Interval)} +[{GetText(strs.command_text)}]: {x.CommandText}```")), + title: string.Empty, + footer: GetText(strs.page(page + 1))) + .SendAsync(); + } + } + + private string GetIntervalText(int interval) + => $"[{GetText(strs.interval)}]: {interval}"; + + [Cmd] + [OwnerOnly] + public async Task Wait(int miliseconds) + { + if (miliseconds <= 0) + return; + ctx.Message.DeleteAfter(0); + try + { + var msg = await Response().Confirm($"⏲ {miliseconds}ms").SendAsync(); + msg.DeleteAfter(miliseconds / 1000); + } + catch { } + + await Task.Delay(miliseconds); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task AutoCommandRemove([Leftover] int index) + { + if (!_service.RemoveAutoCommand(--index, out _)) + { + await Response().Error(strs.acrm_fail).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task StartupCommandRemove([Leftover] int index) + { + if (!_service.RemoveStartupCommand(--index, out _)) + await Response().Error(strs.scrm_fail).SendAsync(); + else + await Response().Confirm(strs.scrm).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task StartupCommandsClear() + { + _service.ClearStartupCommands(); + + await Response().Confirm(strs.startcmds_cleared).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardMessages() + { + var enabled = _service.ForwardMessages(); + + if (enabled) + await Response().Confirm(strs.fwdm_start).SendAsync(); + else + await Response().Pending(strs.fwdm_stop).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ForwardToAll() + { + var enabled = _service.ForwardToAll(); + + if (enabled) + await Response().Confirm(strs.fwall_start).SendAsync(); + else + await Response().Pending(strs.fwall_stop).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task ForwardToChannel() + { + var enabled = _service.ForwardToChannel(ctx.Channel.Id); + + if (enabled) + await Response().Confirm(strs.fwch_start).SendAsync(); + else + await Response().Pending(strs.fwch_stop).SendAsync(); + } + + [Cmd] + public async Task ShardStats(int page = 1) + { + if (--page < 0) + return; + + var statuses = _coord.GetAllShardStatuses(); + + var status = string.Join(" : ", + statuses.Select(x => (ConnectionStateToEmoji(x), x)) + .GroupBy(x => x.Item1) + .Select(x => $"`{x.Count()} {x.Key}`") + .ToArray()); + + var allShardStrings = statuses.Select(st => + { + var timeDiff = DateTime.UtcNow - st.LastUpdate; + var stateStr = ConnectionStateToEmoji(st); + var maxGuildCountLength = + statuses.Max(x => x.GuildCount).ToString().Length; + return $"`{stateStr} " + + $"| #{st.ShardId.ToString().PadBoth(3)} " + + $"| {timeDiff:mm\\:ss} " + + $"| {st.GuildCount.ToString().PadBoth(maxGuildCountLength)} `"; + }) + .ToArray(); + await Response() + .Paginated() + .Items(allShardStrings) + .PageSize(25) + .CurrentPage(page) + .Page((items, _) => + { + var str = string.Join("\n", items); + + if (string.IsNullOrWhiteSpace(str)) + str = GetText(strs.no_shards_on_page); + + return _sender.CreateEmbed().WithOkColor().WithDescription($"{status}\n\n{str}"); + }) + .SendAsync(); + } + + private static string ConnectionStateToEmoji(ShardStatus status) + { + var timeDiff = DateTime.UtcNow - status.LastUpdate; + return status.ConnectionState switch + { + ConnectionState.Disconnected => "🔻", + _ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ", + ConnectionState.Connected => "✅", + _ => " ⏳" + }; + } + + [Cmd] + [OwnerOnly] + public async Task RestartShard(int shardId) + { + var success = _coord.RestartShard(shardId); + if (success) + await Response().Confirm(strs.shard_reconnecting(Format.Bold("#" + shardId))).SendAsync(); + else + await Response().Error(strs.no_shard_id).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task Leave([Leftover] string guildStr) + => _service.LeaveGuild(guildStr); + + [Cmd] + [OwnerOnly] + public async Task DeleteEmptyServers() + { + await ctx.Channel.TriggerTypingAsync(); + + var toLeave = _client.Guilds + .Where(s => s.MemberCount == 1 && s.Users.Count == 1) + .ToList(); + + foreach (var server in toLeave) + { + try + { + await server.DeleteAsync(); + Log.Information("Deleted server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error leaving server {ServerName} [{ServerId}]", + server.Name, + server.Id); + } + } + + await Response().Confirm(strs.deleted_x_servers(toLeave.Count)).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task Die(bool graceful = false) + { + try + { + await _client.SetStatusAsync(UserStatus.Invisible); + _ = _client.StopAsync(); + await Response().Confirm(strs.shutting_down).SendAsync(); + } + catch + { + // ignored + } + + await Task.Delay(2000); + _coord.Die(graceful); + } + + [Cmd] + [OwnerOnly] + public async Task Restart() + { + var success = _coord.RestartBot(); + if (!success) + { + await Response().Error(strs.restart_fail).SendAsync(); + return; + } + + try { await Response().Confirm(strs.restarting).SendAsync(); } + catch { } + } + + [Cmd] + [OwnerOnly] + public async Task SetName([Leftover] string newName) + { + if (string.IsNullOrWhiteSpace(newName)) + return; + + try + { + await _client.CurrentUser.ModifyAsync(u => u.Username = newName); + } + catch (RateLimitedException) + { + Log.Warning("You've been ratelimited. Wait 2 hours to change your name"); + } + + await Response().Confirm(strs.bot_name(Format.Bold(newName))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetStatus([Leftover] SettableUserStatus status) + { + await _client.SetStatusAsync(SettableUserStatusToUserStatus(status)); + + await Response().Confirm(strs.bot_status(Format.Bold(status.ToString()))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetAvatar([Leftover] string img = null) + { + var success = await _service.SetAvatar(img); + + if (success) + await Response().Confirm(strs.set_avatar).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetBanner([Leftover] string img = null) + { + var success = await _service.SetBanner(img); + + if (success) + await Response().Confirm(strs.set_banner).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetGame(ActivityType type, [Leftover] string game = null) + { + // var rep = new ReplacementBuilder().WithDefault(Context).Build(); + + var repCtx = new ReplacementContext(ctx); + await _service.SetGameAsync(game is null ? game : await repSvc.ReplaceAsync(game, repCtx), type); + + await Response().Confirm(strs.set_game).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task SetStream(string url, [Leftover] string name = null) + { + name ??= ""; + + await _service.SetStreamAsync(name, url); + + await Response().Confirm(strs.set_stream).SendAsync(); + } + + public enum SendWhere + { + User = 0, + U = 0, + Usr = 0, + + Channel = 1, + Ch = 1, + Chan = 1, + } + + [Cmd] + [OwnerOnly] + public async Task Send(SendWhere to, ulong id, [Leftover] SmartText text) + { + var ch = to switch + { + SendWhere.User => await ((await _client.Rest.GetUserAsync(id))?.CreateDMChannelAsync() + ?? Task.FromResult(null)), + SendWhere.Channel => await _client.Rest.GetChannelAsync(id) as IMessageChannel, + _ => null + }; + + if (ch is null) + { + await Response().Error(strs.invalid_format).SendAsync(); + return; + } + + + var repCtx = new ReplacementContext(ctx); + text = await repSvc.ReplaceAsync(text, repCtx); + await Response().Channel(ch).Text(text).SendAsync(); + + await ctx.OkAsync();; + } + + [Cmd] + [OwnerOnly] + public async Task StringsReload() + { + _strings.Reload(); + await _marmaladeLoader.ReloadStrings(); + await Response().Confirm(strs.bot_strings_reloaded).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task CoordReload() + { + await _coord.Reload(); + await ctx.OkAsync(); + } + + private static UserStatus SettableUserStatusToUserStatus(SettableUserStatus sus) + { + switch (sus) + { + case SettableUserStatus.Online: + return UserStatus.Online; + case SettableUserStatus.Invisible: + return UserStatus.Invisible; + case SettableUserStatus.Idle: + return UserStatus.AFK; + case SettableUserStatus.Dnd: + return UserStatus.DoNotDisturb; + } + + return UserStatus.Online; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Self/SelfService.cs b/src/EllieBot/Modules/Administration/Self/SelfService.cs new file mode 100644 index 0000000..82eb68c --- /dev/null +++ b/src/EllieBot/Modules/Administration/Self/SelfService.cs @@ -0,0 +1,485 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Collections.Immutable; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class SelfService : IExecNoCommand, IReadyExecutor, IEService +{ + private readonly CommandHandler _cmdHandler; + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + + private readonly IBotCredentials _creds; + + private ImmutableDictionary ownerChannels = + new Dictionary().ToImmutableDictionary(); + + private ConcurrentDictionary> autoCommands = new(); + + private readonly IHttpClientFactory _httpFactory; + private readonly BotConfigService _bss; + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + + //keys + private readonly TypedKey _activitySetKey; + private readonly TypedKey _guildLeaveKey; + + public SelfService( + DiscordSocketClient client, + CommandHandler cmdHandler, + DbService db, + IBotStrings strings, + IBotCredentials creds, + IHttpClientFactory factory, + BotConfigService bss, + IPubSub pubSub, + IMessageSenderService sender) + { + _cmdHandler = cmdHandler; + _db = db; + _strings = strings; + _client = client; + _creds = creds; + _httpFactory = factory; + _bss = bss; + _pubSub = pubSub; + _sender = sender; + _activitySetKey = new("activity.set"); + _guildLeaveKey = new("guild.leave"); + + HandleStatusChanges(); + + _pubSub.Sub(_guildLeaveKey, + async input => + { + var guildStr = input.ToString().Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(guildStr)) + return; + + var server = _client.Guilds.FirstOrDefault(g => g.Id.ToString() == guildStr + || g.Name.Trim().ToUpperInvariant() == guildStr); + if (server is null) + return; + + if (server.OwnerId != _client.CurrentUser.Id) + { + await server.LeaveAsync(); + Log.Information("Left server {Name} [{Id}]", server.Name, server.Id); + } + else + { + await server.DeleteAsync(); + Log.Information("Deleted server {Name} [{Id}]", server.Name, server.Id); + } + }); + } + + public async Task OnReadyAsync() + { + await using var uow = _db.GetDbContext(); + + autoCommands = uow.Set().AsNoTracking() + .Where(x => x.Interval >= 5) + .AsEnumerable() + .GroupBy(x => x.GuildId) + .ToDictionary(x => x.Key, + y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent()) + .ToConcurrent(); + + var startupCommands = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + foreach (var cmd in startupCommands) + { + try + { + await ExecuteCommand(cmd); + } + catch + { + } + } + + if (_client.ShardId == 0) + await LoadOwnerChannels(); + } + + private Timer TimerFromAutoCommand(AutoCommand x) + => new(async obj => await ExecuteCommand((AutoCommand)obj), x, x.Interval * 1000, x.Interval * 1000); + + private async Task ExecuteCommand(AutoCommand cmd) + { + try + { + if (cmd.GuildId is null) + return; + + var guildShard = (int)((cmd.GuildId.Value >> 22) % (ulong)_creds.TotalShards); + if (guildShard != _client.ShardId) + return; + var prefix = _cmdHandler.GetPrefix(cmd.GuildId); + //if someone already has .die as their startup command, ignore it + if (cmd.CommandText.StartsWith(prefix + "die", StringComparison.InvariantCulture)) + return; + await _cmdHandler.ExecuteExternal(cmd.GuildId, cmd.ChannelId, cmd.CommandText); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in SelfService ExecuteCommand"); + } + } + + public void AddNewAutoCommand(AutoCommand cmd) + { + using (var uow = _db.GetDbContext()) + { + uow.Set().Add(cmd); + uow.SaveChanges(); + } + + if (cmd.Interval >= 5) + { + var autos = autoCommands.GetOrAdd(cmd.GuildId, new ConcurrentDictionary()); + autos.AddOrUpdate(cmd.Id, + _ => TimerFromAutoCommand(cmd), + (_, old) => + { + old.Change(Timeout.Infinite, Timeout.Infinite); + return TimerFromAutoCommand(cmd); + }); + } + } + + public IEnumerable GetStartupCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval == 0).OrderBy(x => x.Id).ToList(); + } + + public IEnumerable GetAutoCommands() + { + using var uow = _db.GetDbContext(); + return uow.Set().AsNoTracking().Where(x => x.Interval >= 5).OrderBy(x => x.Id).ToList(); + } + + private async Task LoadOwnerChannels() + { + var channels = await _creds.OwnerIds.Select(id => + { + var user = _client.GetUser(id); + if (user is null) + return Task.FromResult(null); + + return user.CreateDMChannelAsync(); + }) + .WhenAll(); + + ownerChannels = channels.Where(x => x is not null) + .ToDictionary(x => x.Recipient.Id, x => x) + .ToImmutableDictionary(); + + if (!ownerChannels.Any()) + { + Log.Warning( + "No owner channels created! Make sure you've specified the correct OwnerId in the creds.yml file and invited the bot to a Discord server"); + } + else + { + Log.Information("Created {OwnerChannelCount} out of {TotalOwnerChannelCount} owner message channels", + ownerChannels.Count, + _creds.OwnerIds.Count); + } + } + + public Task LeaveGuild(string guildStr) + => _pubSub.Pub(_guildLeaveKey, guildStr); + + // forwards dms + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + var bs = _bss.Data; + if (msg.Channel is IDMChannel && bs.ForwardMessages && (ownerChannels.Any() || bs.ForwardToChannel is not null)) + { + var title = _strings.GetText(strs.dm_from) + $" [{msg.Author}]({msg.Author.Id})"; + + var attachamentsTxt = _strings.GetText(strs.attachments); + + var toSend = msg.Content; + + if (msg.Attachments.Count > 0) + { + toSend += $"\n\n{Format.Code(attachamentsTxt)}:\n" + + string.Join("\n", msg.Attachments.Select(a => a.ProxyUrl)); + } + + if (bs.ForwardToAllOwners) + { + var allOwnerChannels = ownerChannels.Values; + + foreach (var ownerCh in allOwnerChannels.Where(ch => ch.Recipient.Id != msg.Author.Id)) + { + try + { + await _sender.Response(ownerCh).Confirm(title, toSend).SendAsync(); + } + catch + { + Log.Warning("Can't contact owner with id {OwnerId}", ownerCh.Recipient.Id); + } + } + } + else if (bs.ForwardToChannel is ulong cid) + { + try + { + if (_client.GetChannel(cid) is ITextChannel ch) + await _sender.Response(ch).Confirm(title, toSend).SendAsync(); + } + catch + { + Log.Warning("Error forwarding message to the channel"); + } + } + else + { + var firstOwnerChannel = ownerChannels.Values.First(); + if (firstOwnerChannel.Recipient.Id != msg.Author.Id) + { + try + { + await _sender.Response(firstOwnerChannel).Confirm(title, toSend).SendAsync(); + } + catch + { + // ignored + } + } + } + } + } + + public bool RemoveStartupCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval == 0).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + uow.SaveChanges(); + return true; + } + + return false; + } + + public bool RemoveAutoCommand(int index, out AutoCommand cmd) + { + using var uow = _db.GetDbContext(); + cmd = uow.Set().AsNoTracking().Where(x => x.Interval >= 5).Skip(index).FirstOrDefault(); + + if (cmd is not null) + { + uow.Remove(cmd); + if (autoCommands.TryGetValue(cmd.GuildId, out var autos)) + { + if (autos.TryRemove(cmd.Id, out var timer)) + timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + uow.SaveChanges(); + return true; + } + + return false; + } + + public async Task SetAvatar(string img) + { + if (string.IsNullOrWhiteSpace(img)) + return false; + + if (!Uri.IsWellFormedUriString(img, UriKind.Absolute)) + return false; + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + if (!sr.IsImage()) + return false; + + // i can't just do ReadAsStreamAsync because dicord.net's image poops itself + var imgData = await sr.Content.ReadAsByteArrayAsync(); + await using var imgStream = imgData.ToStream(); + await _client.CurrentUser.ModifyAsync(u => u.Avatar = new Image(imgStream)); + + return true; + } + + public async Task SetBanner(string img) + { + if (string.IsNullOrWhiteSpace(img)) + { + return false; + } + + if (!Uri.IsWellFormedUriString(img, UriKind.Absolute)) + { + return false; + } + + var uri = new Uri(img); + + using var http = _httpFactory.CreateClient(); + using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); + + if (!sr.IsImage()) + { + return false; + } + + if (sr.GetContentLength() > 8.Megabytes()) + { + return false; + } + + await using var imageStream = await sr.Content.ReadAsStreamAsync(); + + await _client.CurrentUser.ModifyAsync(x => x.Banner = new Image(imageStream)); + return true; + } + + + public void ClearStartupCommands() + { + using var uow = _db.GetDbContext(); + var toRemove = uow.Set().AsNoTracking().Where(x => x.Interval == 0); + + uow.Set().RemoveRange(toRemove); + uow.SaveChanges(); + } + + public bool ForwardMessages() + { + var isForwarding = false; + _bss.ModifyConfig(config => { isForwarding = config.ForwardMessages = !config.ForwardMessages; }); + + return isForwarding; + } + + public bool ForwardToAll() + { + var isToAll = false; + _bss.ModifyConfig(config => { isToAll = config.ForwardToAllOwners = !config.ForwardToAllOwners; }); + return isToAll; + } + + public bool ForwardToChannel(ulong? channelId) + { + using var uow = _db.GetDbContext(); + + _bss.ModifyConfig(config => + { + config.ForwardToChannel = channelId == config.ForwardToChannel + ? null + : channelId; + }); + + return channelId is not null; + } + + private void HandleStatusChanges() + => _pubSub.Sub(_activitySetKey, + async data => + { + try + { + await _client.SetGameAsync(data.Name, data.Link, data.Type); + } + catch (Exception ex) + { + Log.Warning(ex, "Error setting activity"); + } + }); + + public Task SetGameAsync(string game, ActivityType type) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = game, + Link = null, + Type = type + }); + + public Task SetStreamAsync(string name, string link) + => _pubSub.Pub(_activitySetKey, + new() + { + Name = name, + Link = link, + Type = ActivityType.Streaming + }); + + private sealed class ActivityPubData + { + public string Name { get; init; } + public string Link { get; init; } + public ActivityType Type { get; init; } + } + + + /// + /// Adds the specified to the database. If a database user with placeholder name + /// and discriminator is present in , their name and discriminator get updated accordingly. + /// + /// This database context. + /// The users to add or update in the database. + /// A tuple with the amount of new users added and old users updated. + public async Task<(long UsersAdded, long UsersUpdated)> RefreshUsersAsync(List users) + { + await using var ctx = _db.GetDbContext(); + var presentDbUsers = await ctx.GetTable() + .Select(x => new { x.UserId, x.Username, x.Discriminator }) + .Where(x => users.Select(y => y.Id).Contains(x.UserId)) + .ToArrayAsyncEF(); + + var usersToAdd = users + .Where(x => !presentDbUsers.Select(x => x.UserId).Contains(x.Id)) + .Select(x => new DiscordUser() + { + UserId = x.Id, + AvatarId = x.AvatarId, + Username = x.Username, + Discriminator = x.Discriminator + }); + + var added = (await ctx.BulkCopyAsync(usersToAdd)).RowsCopied; + var toUpdateUserIds = presentDbUsers + .Where(x => x.Username == "Unknown" && x.Discriminator == "????") + .Select(x => x.UserId) + .ToArray(); + + foreach (var user in users.Where(x => toUpdateUserIds.Contains(x.Id))) + { + await ctx.GetTable() + .Where(x => x.UserId == user.Id) + .UpdateAsync(x => new DiscordUser() + { + Username = user.Username, + Discriminator = user.Discriminator, + + // .award tends to set AvatarId and DateAdded to NULL, so account for that. + AvatarId = user.AvatarId, + DateAdded = x.DateAdded ?? DateTime.UtcNow + }); + } + + return (added, toUpdateUserIds.Length); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs new file mode 100644 index 0000000..70581f7 --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesCommands.cs @@ -0,0 +1,239 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; +using System.Text; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class SelfAssignedRolesCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.ManageMessages)] + public async Task AdSarm() + { + var newVal = _service.ToggleAdSarm(ctx.Guild.Id); + + if (newVal) + await Response().Confirm(strs.adsarm_enable(prefix)).SendAsync(); + else + await Response().Confirm(strs.adsarm_disable(prefix)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(1)] + public Task Asar([Leftover] IRole role) + => Asar(0, role); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Asar(int group, [Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var succ = _service.AddNew(ctx.Guild.Id, role, group); + + if (succ) + { + await Response() + .Confirm(strs.role_added(Format.Bold(role.Name), Format.Bold(group.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.role_in_list(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [Priority(0)] + public async Task Sargn(int group, [Leftover] string name = null) + { + var set = await _service.SetNameAsync(ctx.Guild.Id, group, name); + + if (set) + { + await Response() + .Confirm(strs.group_name_added(Format.Bold(group.ToString()), Format.Bold(name))) + .SendAsync(); + } + else + await Response().Confirm(strs.group_name_removed(Format.Bold(group.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + public async Task Rsar([Leftover] IRole role) + { + var guser = (IGuildUser)ctx.User; + if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position) + return; + + var success = _service.RemoveSar(role.Guild.Id, role.Id); + if (!success) + await Response().Error(strs.self_assign_not).SendAsync(); + else + await Response().Confirm(strs.self_assign_rem(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Lsar(int page = 1) + { + if (--page < 0) + return; + + var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild); + + await Response() + .Paginated() + .Items(roles.OrderBy(x => x.Model.Group).ToList()) + .PageSize(20) + .CurrentPage(page) + .Page((items, _) => + { + var rolesStr = new StringBuilder(); + var roleGroups = items + .GroupBy(x => x.Model.Group) + .OrderBy(x => x.Key); + + foreach (var kvp in roleGroups) + { + string groupNameText; + if (!groups.TryGetValue(kvp.Key, out var name)) + groupNameText = Format.Bold(GetText(strs.self_assign_group(kvp.Key))); + else + groupNameText = Format.Bold($"{kvp.Key} - {name.TrimTo(25, true)}"); + + rolesStr.AppendLine("\t\t\t\t ⟪" + groupNameText + "⟫"); + foreach (var (model, role) in kvp.AsEnumerable()) + { + if (role is null) + { + } + else + { + // first character is invisible space + if (model.LevelRequirement == 0) + rolesStr.AppendLine("‌‌ " + role.Name); + else + rolesStr.AppendLine("‌‌ " + role.Name + $" (lvl {model.LevelRequirement}+)"); + } + } + + rolesStr.AppendLine(); + } + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(Format.Bold(GetText(strs.self_assign_list(roles.Count())))) + .WithDescription(rolesStr.ToString()) + .WithFooter(exclusive + ? GetText(strs.self_assign_are_exclusive) + : GetText(strs.self_assign_are_not_exclusive)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task Togglexclsar() + { + var areExclusive = _service.ToggleEsar(ctx.Guild.Id); + if (areExclusive) + await Response().Confirm(strs.self_assign_excl).SendAsync(); + else + await Response().Confirm(strs.self_assign_no_excl).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task RoleLevelReq(int level, [Leftover] IRole role) + { + if (level < 0) + return; + + var succ = _service.SetLevelReq(ctx.Guild.Id, role, level); + + if (!succ) + { + await Response().Error(strs.self_assign_not).SendAsync(); + return; + } + + await Response() + .Confirm(strs.self_assign_level_req(Format.Bold(role.Name), + Format.Bold(level.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iam([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete, extra) = await _service.Assign(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.AssignResult.ErrNotAssignable) + msg = await Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq) + msg = await Response().Error(strs.self_assign_not_level(Format.Bold(extra.ToString()))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave) + msg = await Response().Error(strs.self_assign_already(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_success(Format.Bold(role.Name))).SendAsync(); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Iamnot([Leftover] IRole role) + { + var guildUser = (IGuildUser)ctx.User; + + var (result, autoDelete) = await _service.Remove(guildUser, role); + + IUserMessage msg; + if (result == SelfAssignedRolesService.RemoveResult.ErrNotAssignable) + msg = await Response().Error(strs.self_assign_not).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave) + msg = await Response().Error(strs.self_assign_not_have(Format.Bold(role.Name))).SendAsync(); + else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms) + msg = await Response().Error(strs.self_assign_perms).SendAsync(); + else + msg = await Response().Confirm(strs.self_assign_remove(Format.Bold(role.Name))).SendAsync(); + + if (autoDelete) + { + msg.DeleteAfter(3); + ctx.Message.DeleteAfter(3); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs new file mode 100644 index 0000000..1305835 --- /dev/null +++ b/src/EllieBot/Modules/Administration/SelfAssignableRoles/SelfAssignedRolesService.cs @@ -0,0 +1,234 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class SelfAssignedRolesService : IEService +{ + public enum AssignResult + { + Assigned, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrAlreadyHave, // you already have that role (error) + ErrNotPerms, // bot doesn't have perms (error) + ErrLvlReq // you are not required level (error) + } + + public enum RemoveResult + { + Removed, // successfully removed + ErrNotAssignable, // not assignable (error) + ErrNotHave, // you don't have a role you want to remove (error) + ErrNotPerms // bot doesn't have perms (error) + } + + private readonly DbService _db; + + public SelfAssignedRolesService(DbService db) + => _db = db; + + public bool AddNew(ulong guildId, IRole role, int group) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + if (roles.Any(s => s.RoleId == role.Id && s.GuildId == role.Guild.Id)) + return false; + + uow.Set().Add(new() + { + Group = group, + RoleId = role.Id, + GuildId = role.Guild.Id + }); + uow.SaveChanges(); + return true; + } + + public bool ToggleAdSarm(ulong guildId) + { + bool newval; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + newval = config.AutoDeleteSelfAssignedRoleMessages = !config.AutoDeleteSelfAssignedRoleMessages; + uow.SaveChanges(); + return newval; + } + + public async Task<(AssignResult Result, bool AutoDelete, object extra)> Assign(IGuildUser guildUser, IRole role) + { + LevelStats userLevelData; + await using (var uow = _db.GetDbContext()) + { + var stats = uow.GetOrCreateUserXpStats(guildUser.Guild.Id, guildUser.Id); + userLevelData = new(stats.Xp + stats.AwardedXp); + } + + var (autoDelete, exclusive, roles) = GetAdAndRoles(guildUser.Guild.Id); + + var theRoleYouWant = roles.FirstOrDefault(r => r.RoleId == role.Id); + if (theRoleYouWant is null) + return (AssignResult.ErrNotAssignable, autoDelete, null); + if (theRoleYouWant.LevelRequirement > userLevelData.Level) + return (AssignResult.ErrLvlReq, autoDelete, theRoleYouWant.LevelRequirement); + if (guildUser.RoleIds.Contains(role.Id)) + return (AssignResult.ErrAlreadyHave, autoDelete, null); + + var roleIds = roles.Where(x => x.Group == theRoleYouWant.Group).Select(x => x.RoleId).ToArray(); + if (exclusive) + { + var sameRoles = guildUser.RoleIds.Where(r => roleIds.Contains(r)); + + foreach (var roleId in sameRoles) + { + var sameRole = guildUser.Guild.GetRole(roleId); + if (sameRole is not null) + { + try + { + await guildUser.RemoveRoleAsync(sameRole); + await Task.Delay(300); + } + catch + { + // ignored + } + } + } + } + + try + { + await guildUser.AddRoleAsync(role); + } + catch (Exception ex) + { + return (AssignResult.ErrNotPerms, autoDelete, ex); + } + + return (AssignResult.Assigned, autoDelete, null); + } + + public async Task SetNameAsync(ulong guildId, int group, string name) + { + var set = false; + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, y => y.Include(x => x.SelfAssignableRoleGroupNames)); + var toUpdate = gc.SelfAssignableRoleGroupNames.FirstOrDefault(x => x.Number == group); + + if (string.IsNullOrWhiteSpace(name)) + { + if (toUpdate is not null) + gc.SelfAssignableRoleGroupNames.Remove(toUpdate); + } + else if (toUpdate is null) + { + gc.SelfAssignableRoleGroupNames.Add(new() + { + Name = name, + Number = group + }); + set = true; + } + else + { + toUpdate.Name = name; + set = true; + } + + await uow.SaveChangesAsync(); + + return set; + } + + public async Task<(RemoveResult Result, bool AutoDelete)> Remove(IGuildUser guildUser, IRole role) + { + var (autoDelete, _, roles) = GetAdAndRoles(guildUser.Guild.Id); + + if (roles.FirstOrDefault(r => r.RoleId == role.Id) is null) + return (RemoveResult.ErrNotAssignable, autoDelete); + if (!guildUser.RoleIds.Contains(role.Id)) + return (RemoveResult.ErrNotHave, autoDelete); + try + { + await guildUser.RemoveRoleAsync(role); + } + catch (Exception) + { + return (RemoveResult.ErrNotPerms, autoDelete); + } + + return (RemoveResult.Removed, autoDelete); + } + + public bool RemoveSar(ulong guildId, ulong roleId) + { + bool success; + using var uow = _db.GetDbContext(); + success = uow.Set().DeleteByGuildAndRoleId(guildId, roleId); + uow.SaveChanges(); + return success; + } + + public (bool AutoDelete, bool Exclusive, IReadOnlyCollection) GetAdAndRoles(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var autoDelete = gc.AutoDeleteSelfAssignedRoleMessages; + var exclusive = gc.ExclusiveSelfAssignedRoles; + var roles = uow.Set().GetFromGuild(guildId); + + return (autoDelete, exclusive, roles); + } + + public bool SetLevelReq(ulong guildId, IRole role, int level) + { + using var uow = _db.GetDbContext(); + var roles = uow.Set().GetFromGuild(guildId); + var sar = roles.FirstOrDefault(x => x.RoleId == role.Id); + if (sar is not null) + { + sar.LevelRequirement = level; + uow.SaveChanges(); + } + else + return false; + + return true; + } + + public bool ToggleEsar(ulong guildId) + { + bool areExclusive; + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + + areExclusive = config.ExclusiveSelfAssignedRoles = !config.ExclusiveSelfAssignedRoles; + uow.SaveChanges(); + return areExclusive; + } + + public (bool Exclusive, IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> Roles, IDictionary + GroupNames + ) GetRoles(IGuild guild) + { + var exclusive = false; + + IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> roles; + IDictionary groupNames; + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.SelfAssignableRoleGroupNames)); + exclusive = gc.ExclusiveSelfAssignedRoles; + groupNames = gc.SelfAssignableRoleGroupNames.ToDictionary(x => x.Number, x => x.Name); + var roleModels = uow.Set().GetFromGuild(guild.Id); + roles = roleModels.Select(x => (Model: x, Role: guild.GetRole(x.RoleId))) + .ToList(); + uow.Set().RemoveRange(roles.Where(x => x.Role is null).Select(x => x.Model).ToArray()); + uow.SaveChanges(); + } + + return (exclusive, roles.Where(x => x.Role is not null).ToList(), groupNames); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs new file mode 100644 index 0000000..3cdd4d3 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/DummyLogCommandService.cs @@ -0,0 +1,25 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public sealed class DummyLogCommandService : ILogCommandService +#if GLOBAL_ELLIE +, INService +#endif +{ + public void AddDeleteIgnore(ulong xId) + { + } + + public Task LogServer(ulong guildId, ulong channelId, bool actionValue) + => Task.CompletedTask; + + public bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType) + => false; + + public LogSetting? GetGuildLogSettings(ulong guildId) + => default; + + public bool Log(ulong guildId, ulong? channelId, LogType type) + => false; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs new file mode 100644 index 0000000..6ea5345 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommandService.cs @@ -0,0 +1,1297 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public sealed class LogCommandService : ILogCommandService, IReadyExecutor +#if !GLOBAL_ELLIE + , IEService // don't load this service on global ellie +#endif +{ + public ConcurrentDictionary GuildLogSettings { get; } + + private ConcurrentDictionary> PresenceUpdates { get; } = new(); + private readonly DiscordSocketClient _client; + + private readonly IBotStrings _strings; + private readonly DbService _db; + private readonly MuteService _mute; + private readonly ProtectionService _prot; + private readonly GuildTimezoneService _tz; + private readonly IMemoryCache _memoryCache; + + private readonly ConcurrentHashSet _ignoreMessageIds = []; + private readonly UserPunishService _punishService; + private readonly IMessageSenderService _sender; + + public LogCommandService( + DiscordSocketClient client, + IBotStrings strings, + DbService db, + MuteService mute, + ProtectionService prot, + GuildTimezoneService tz, + IMemoryCache memoryCache, + UserPunishService punishService, + IMessageSenderService sender) + { + _client = client; + _memoryCache = memoryCache; + _sender = sender; + _strings = strings; + _db = db; + _mute = mute; + _prot = prot; + _tz = tz; + _punishService = punishService; + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set().AsQueryable() + .AsNoTracking() + .Where(x => guildIds.Contains(x.GuildId)) + .Include(ls => ls.LogIgnores) + .ToList(); + + GuildLogSettings = configs.ToDictionary(ls => ls.GuildId).ToConcurrent(); + } + + //_client.MessageReceived += _client_MessageReceived; + _client.MessageUpdated += _client_MessageUpdated; + _client.MessageDeleted += _client_MessageDeleted; + _client.UserBanned += _client_UserBanned; + _client.UserUnbanned += _client_UserUnbanned; + _client.UserJoined += _client_UserJoined; + _client.UserLeft += _client_UserLeft; + // _client.PresenceUpdated += _client_UserPresenceUpdated; + _client.UserVoiceStateUpdated += _client_UserVoiceStateUpdated; + _client.GuildMemberUpdated += _client_GuildUserUpdated; + _client.PresenceUpdated += _client_PresenceUpdated; + _client.UserUpdated += _client_UserUpdated; + _client.ChannelCreated += _client_ChannelCreated; + _client.ChannelDestroyed += _client_ChannelDestroyed; + _client.ChannelUpdated += _client_ChannelUpdated; + _client.RoleDeleted += _client_RoleDeleted; + + _client.ThreadCreated += _client_ThreadCreated; + _client.ThreadDeleted += _client_ThreadDeleted; + + _mute.UserMuted += MuteCommands_UserMuted; + _mute.UserUnmuted += MuteCommands_UserUnmuted; + + _prot.OnAntiProtectionTriggered += TriggeredAntiProtection; + + _punishService.OnUserWarned += PunishServiceOnOnUserWarned; + } + + private async Task _client_PresenceUpdated(SocketUser user, SocketPresence? before, SocketPresence? after) + { + if (user is not SocketGuildUser gu) + return; + + if (!GuildLogSettings.TryGetValue(gu.Guild.Id, out var logSetting) + || before is null + || after is null + || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == gu.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + + if (!user.IsBot + && logSetting.LogUserPresenceId is not null + && (logChannel = + await TryGetLogChannel(gu.Guild, logSetting, LogType.UserPresence)) is not null) + { + if (before.Status != after.Status) + { + var str = "🎭" + + Format.Code(PrettyCurrentTime(gu.Guild)) + + GetText(logChannel.Guild, + strs.user_status_change("👤" + Format.Bold(gu.Username), + Format.Bold(after.Status.ToString()))); + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + else if (before.Activities.FirstOrDefault()?.Name != after.Activities.FirstOrDefault()?.Name) + { + var str = + $"👾`{PrettyCurrentTime(gu.Guild)}`👤__**{gu.Username}**__ is now playing **{after.Activities.FirstOrDefault()?.Name ?? "-"}**."; + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + } + } + + private Task _client_ThreadDeleted(Cacheable sch) + { + _ = Task.Run(async () => + { + try + { + if (!sch.HasValue) + return; + + var ch = sch.Value; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadDeletedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadDeleted)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_deleted); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🗑 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ThreadCreated(SocketThreadChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ThreadCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ThreadCreated)) is null) + return; + + var title = GetText(logChannel.Guild, strs.thread_created); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + + return Task.CompletedTask; + } + + public async Task OnReadyAsync() + => await Task.WhenAll(PresenceUpdateTask(), IgnoreMessageIdsClearTask()); + + private async Task IgnoreMessageIdsClearTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + _ignoreMessageIds.Clear(); + } + + private async Task PresenceUpdateTask() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var keys = PresenceUpdates.Keys.ToList(); + + await keys.Select(channel => + { + if (!((SocketGuild)channel.Guild).CurrentUser.GetPermissions(channel).SendMessages) + return Task.CompletedTask; + + if (PresenceUpdates.TryRemove(channel, out var msgs)) + { + var title = GetText(channel.Guild, strs.presence_updates); + var desc = string.Join(Environment.NewLine, msgs); + return _sender.Response(channel).Confirm(title, desc.TrimTo(2048)!).SendAsync(); + } + + return Task.CompletedTask; + }) + .WhenAll(); + } + catch + { + } + } + } + + public LogSetting? GetGuildLogSettings(ulong guildId) + { + GuildLogSettings.TryGetValue(guildId, out var logSetting); + return logSetting; + } + + public void AddDeleteIgnore(ulong messageId) + => _ignoreMessageIds.Add(messageId); + + public bool LogIgnore(ulong gid, ulong itemId, IgnoredItemType itemType) + { + using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(gid); + var removed = logSetting.LogIgnores.RemoveAll(x => x.ItemType == itemType && itemId == x.LogItemId); + + if (removed == 0) + { + var toAdd = new IgnoredLogItem + { + LogItemId = itemId, + ItemType = itemType + }; + logSetting.LogIgnores.Add(toAdd); + } + + uow.SaveChanges(); + GuildLogSettings.AddOrUpdate(gid, logSetting, (_, _) => logSetting); + return removed > 0; + } + + private string GetText(IGuild guild, LocStr str) + => _strings.GetText(str, guild.Id); + + private string PrettyCurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + return $"【{time:HH:mm:ss}】"; + } + + private string CurrentTime(IGuild? g) + { + var time = DateTime.UtcNow; + if (g is not null) + time = TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(g.Id)); + + return $"{time:HH:mm:ss}"; + } + + public async Task LogServer(ulong guildId, ulong channelId, bool value) + { + await using var uow = _db.GetDbContext(); + var logSetting = uow.LogSettingsFor(guildId); + + logSetting.LogOtherId = logSetting.MessageUpdatedId = logSetting.MessageDeletedId = logSetting.UserJoinedId = + logSetting.UserLeftId = logSetting.UserBannedId = logSetting.UserUnbannedId = logSetting.UserUpdatedId = + logSetting.ChannelCreatedId = logSetting.ChannelDestroyedId = logSetting.ChannelUpdatedId = + logSetting.LogUserPresenceId = logSetting.LogVoicePresenceId = logSetting.UserMutedId = + logSetting.ThreadCreatedId = logSetting.ThreadDeletedId + = logSetting.LogWarnsId = value ? channelId : null; + await uow.SaveChangesAsync(); + GuildLogSettings.AddOrUpdate(guildId, _ => logSetting, (_, _) => logSetting); + } + + + private async Task PunishServiceOnOnUserWarned(Warning arg) + { + if (!GuildLogSettings.TryGetValue(arg.GuildId, out var logSetting) || logSetting.LogWarnsId is null) + return; + + var g = _client.GetGuild(arg.GuildId); + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserWarned)) is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"⚠️ User Warned") + .WithDescription($"<@{arg.UserId}> | {arg.UserId}") + .AddField("Mod", arg.Moderator) + .AddField("Reason", string.IsNullOrWhiteSpace(arg.Reason) ? "-" : arg.Reason, true) + .WithFooter(CurrentTime(g)); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + + private Task _client_UserUpdated(SocketUser before, SocketUser uAfter) + { + _ = Task.Run(async () => + { + try + { + if (uAfter is not SocketGuildUser after) + return; + + var g = after.Guild; + + if (!GuildLogSettings.TryGetValue(g.Id, out var logSetting) || logSetting.UserUpdatedId is null || logSetting.LogIgnores.Any(ilc => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserUpdated)) is null) + return; + + var embed = _sender.CreateEmbed(); + + if (before.Username != after.Username) + { + embed.WithTitle("👥 " + GetText(g, strs.username_changed)) + .WithDescription($"{before.Username} | {before.Id}") + .AddField("Old Name", $"{before.Username}", true) + .AddField("New Name", $"{after.Username}", true) + .WithFooter(CurrentTime(g)) + .WithOkColor(); + } + else if (before.AvatarId != after.AvatarId) + { + embed.WithTitle("👥" + GetText(g, strs.avatar_changed)) + .WithDescription($"{before.Username}#{before.Discriminator} | {before.Id}") + .WithFooter(CurrentTime(g)) + .WithOkColor(); + + var bav = before.RealAvatarUrl(); + if (bav.IsAbsoluteUri) + embed.WithThumbnailUrl(bav.ToString()); + + var aav = after.RealAvatarUrl(); + if (aav.IsAbsoluteUri) + embed.WithImageUrl(aav.ToString()); + } + else + return; + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + public bool Log(ulong gid, ulong? cid, LogType type /*, string options*/) + { + ulong? channelId = null; + using (var uow = _db.GetDbContext()) + { + var logSetting = uow.LogSettingsFor(gid); + GuildLogSettings.AddOrUpdate(gid, _ => logSetting, (_, _) => logSetting); + switch (type) + { + case LogType.Other: + channelId = logSetting.LogOtherId = logSetting.LogOtherId is null ? cid : default; + break; + case LogType.MessageUpdated: + channelId = logSetting.MessageUpdatedId = logSetting.MessageUpdatedId is null ? cid : default; + break; + case LogType.MessageDeleted: + channelId = logSetting.MessageDeletedId = logSetting.MessageDeletedId is null ? cid : default; + //logSetting.DontLogBotMessageDeleted = (options == "nobot"); + break; + case LogType.UserJoined: + channelId = logSetting.UserJoinedId = logSetting.UserJoinedId is null ? cid : default; + break; + case LogType.UserLeft: + channelId = logSetting.UserLeftId = logSetting.UserLeftId is null ? cid : default; + break; + case LogType.UserBanned: + channelId = logSetting.UserBannedId = logSetting.UserBannedId is null ? cid : default; + break; + case LogType.UserUnbanned: + channelId = logSetting.UserUnbannedId = logSetting.UserUnbannedId is null ? cid : default; + break; + case LogType.UserUpdated: + channelId = logSetting.UserUpdatedId = logSetting.UserUpdatedId is null ? cid : default; + break; + case LogType.UserMuted: + channelId = logSetting.UserMutedId = logSetting.UserMutedId is null ? cid : default; + break; + case LogType.ChannelCreated: + channelId = logSetting.ChannelCreatedId = logSetting.ChannelCreatedId is null ? cid : default; + break; + case LogType.ChannelDestroyed: + channelId = logSetting.ChannelDestroyedId = logSetting.ChannelDestroyedId is null ? cid : default; + break; + case LogType.ChannelUpdated: + channelId = logSetting.ChannelUpdatedId = logSetting.ChannelUpdatedId is null ? cid : default; + break; + case LogType.UserPresence: + channelId = logSetting.LogUserPresenceId = logSetting.LogUserPresenceId is null ? cid : default; + break; + case LogType.VoicePresence: + channelId = logSetting.LogVoicePresenceId = logSetting.LogVoicePresenceId is null ? cid : default; + break; + case LogType.UserWarned: + channelId = logSetting.LogWarnsId = logSetting.LogWarnsId is null ? cid : default; + break; + case LogType.ThreadDeleted: + channelId = logSetting.ThreadDeletedId = logSetting.ThreadDeletedId is null ? cid : default; + break; + case LogType.ThreadCreated: + channelId = logSetting.ThreadCreatedId = logSetting.ThreadCreatedId is null ? cid : default; + break; + } + + uow.SaveChanges(); + } + + return channelId is not null; + } + + private void MuteCommands_UserMuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + var mutes = string.Empty; + var mutedLocalized = GetText(logChannel.Guild, strs.muted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_voice(mutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔇 " + GetText(logChannel.Guild, strs.xmuted_text(mutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔇 " + + GetText(logChannel.Guild, strs.xmuted_text_and_voice(mutedLocalized, mod.ToString())); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter(CurrentTime(usr.Guild)) + .WithOkColor(); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + + private void MuteCommands_UserUnmuted( + IGuildUser usr, + IUser mod, + MuteType muteType, + string reason) + => _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserMutedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserMuted)) is null) + return; + + var mutes = string.Empty; + var unmutedLocalized = GetText(logChannel.Guild, strs.unmuted_sn); + switch (muteType) + { + case MuteType.Voice: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_voice(unmutedLocalized, mod.ToString())); + break; + case MuteType.Chat: + mutes = "🔊 " + GetText(logChannel.Guild, strs.xmuted_text(unmutedLocalized, mod.ToString())); + break; + case MuteType.All: + mutes = "🔊 " + + GetText(logChannel.Guild, + strs.xmuted_text_and_voice(unmutedLocalized, mod.ToString())); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(mutes) + .WithTitle($"{usr.Username}#{usr.Discriminator} | {usr.Id}") + .WithFooter($"{CurrentTime(usr.Guild)}") + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(reason)) + embed.WithDescription(reason); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + + public Task TriggeredAntiProtection(PunishmentAction action, ProtectionType protection, params IGuildUser[] users) + { + _ = Task.Run(async () => + { + try + { + if (users.Length == 0) + return; + + if (!GuildLogSettings.TryGetValue(users.First().Guild.Id, out var logSetting) + || logSetting.LogOtherId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(users.First().Guild, logSetting, LogType.Other)) is null) + return; + + var punishment = string.Empty; + switch (action) + { + case PunishmentAction.Mute: + punishment = "🔇 " + GetText(logChannel.Guild, strs.muted_pl).ToUpperInvariant(); + break; + case PunishmentAction.Kick: + punishment = "👢 " + GetText(logChannel.Guild, strs.kicked_pl).ToUpperInvariant(); + break; + case PunishmentAction.Softban: + punishment = "☣ " + GetText(logChannel.Guild, strs.soft_banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.Ban: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.banned_pl).ToUpperInvariant(); + break; + case PunishmentAction.RemoveRoles: + punishment = "⛔️ " + GetText(logChannel.Guild, strs.remove_roles_pl).ToUpperInvariant(); + break; + } + + var embed = _sender.CreateEmbed() + .WithAuthor($"🛡 Anti-{protection}") + .WithTitle(GetText(logChannel.Guild, strs.users) + " " + punishment) + .WithDescription(string.Join("\n", users.Select(u => u.ToString()))) + .WithFooter(CurrentTime(logChannel.Guild)) + .WithOkColor(); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private string GetRoleDeletedKey(ulong roleId) + => $"role_deleted_{roleId}"; + + private Task _client_RoleDeleted(SocketRole socketRole) + { + Serilog.Log.Information("Role deleted {RoleId}", socketRole.Id); + _memoryCache.Set(GetRoleDeletedKey(socketRole.Id), true, TimeSpan.FromMinutes(5)); + return Task.CompletedTask; + } + + private bool IsRoleDeleted(ulong roleId) + { + var isDeleted = _memoryCache.TryGetValue(GetRoleDeletedKey(roleId), out _); + return isDeleted; + } + + private Task _client_GuildUserUpdated(Cacheable optBefore, SocketGuildUser after) + { + _ = Task.Run(async () => + { + try + { + var before = await optBefore.GetOrDownloadAsync(); + + if (before is null) + return; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if (logSetting.UserUpdatedId is not null + && (logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.UserUpdated)) is not null) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithFooter(CurrentTime(before.Guild)) + .WithTitle($"{before.Username}#{before.Discriminator} | {before.Id}"); + if (before.Nickname != after.Nickname) + { + embed.WithAuthor("👥 " + GetText(logChannel.Guild, strs.nick_change)) + .AddField(GetText(logChannel.Guild, strs.old_nick), + $"{before.Nickname}#{before.Discriminator}") + .AddField(GetText(logChannel.Guild, strs.new_nick), + $"{after.Nickname}#{after.Discriminator}"); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + else if (!before.Roles.SequenceEqual(after.Roles)) + { + if (before.Roles.Count < after.Roles.Count) + { + var diffRoles = after.Roles.Where(r => !before.Roles.Contains(r)).Select(r => r.Name); + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_add)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + else if (before.Roles.Count > after.Roles.Count) + { + await Task.Delay(1000); + var diffRoles = before.Roles.Where(r => !after.Roles.Contains(r) && !IsRoleDeleted(r.Id)) + .Select(r => r.Name) + .ToList(); + + if (diffRoles.Any()) + { + embed.WithAuthor("⚔ " + GetText(logChannel.Guild, strs.user_role_rem)) + .WithDescription(string.Join(", ", diffRoles).SanitizeMentions()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + } + } + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelUpdated(IChannel cbefore, IChannel cafter) + { + _ = Task.Run(async () => + { + try + { + if (cbefore is not IGuildChannel before) + return; + + var after = (IGuildChannel)cafter; + + if (!GuildLogSettings.TryGetValue(before.Guild.Id, out var logSetting) + || logSetting.ChannelUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == after.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(before.Guild, logSetting, LogType.ChannelUpdated)) is null) + return; + + var embed = _sender.CreateEmbed().WithOkColor().WithFooter(CurrentTime(before.Guild)); + + var beforeTextChannel = cbefore as ITextChannel; + var afterTextChannel = cafter as ITextChannel; + + if (before.Name != after.Name) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_name_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.ch_old_name), before.Name); + } + else if (beforeTextChannel?.Topic != afterTextChannel?.Topic) + { + embed.WithTitle("ℹ️ " + GetText(logChannel.Guild, strs.ch_topic_change)) + .WithDescription($"{after} | {after.Id}") + .AddField(GetText(logChannel.Guild, strs.old_topic), beforeTextChannel?.Topic ?? "-") + .AddField(GetText(logChannel.Guild, strs.new_topic), afterTextChannel?.Topic ?? "-"); + } + else + return; + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelDestroyed(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelDestroyedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == ch.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelDestroyed)) is null) + return; + + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_destroyed); + else + title = GetText(logChannel.Guild, strs.text_chan_destroyed); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_ChannelCreated(IChannel ich) + { + _ = Task.Run(async () => + { + try + { + if (ich is not IGuildChannel ch) + return; + + if (!GuildLogSettings.TryGetValue(ch.Guild.Id, out var logSetting) + || logSetting.ChannelCreatedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(ch.Guild, logSetting, LogType.ChannelCreated)) is null) + return; + string title; + if (ch is IVoiceChannel) + title = GetText(logChannel.Guild, strs.voice_chan_created); + else + title = GetText(logChannel.Guild, strs.text_chan_created); + + await _sender.Response(logChannel).Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("🆕 " + title) + .WithDescription($"{ch.Name} | {ch.Id}") + .WithFooter(CurrentTime(ch.Guild))).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserVoiceStateUpdated(SocketUser iusr, SocketVoiceState before, SocketVoiceState after) + { + _ = Task.Run(async () => + { + try + { + if (iusr is not IGuildUser usr || usr.IsBot) + return; + + var beforeVch = before.VoiceChannel; + var afterVch = after.VoiceChannel; + + if (beforeVch == afterVch) + return; + + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) + || logSetting.LogVoicePresenceId is null + || logSetting.LogIgnores.Any( + ilc => ilc.LogItemId == iusr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.VoicePresence)) is null) + return; + + var str = string.Empty; + if (beforeVch?.Guild == afterVch?.Guild) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vmoved("👤" + Format.Bold(usr.Username), + Format.Bold(beforeVch?.Name ?? ""), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (beforeVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vjoined("👤" + Format.Bold(usr.Username), + Format.Bold(afterVch?.Name ?? ""))); + } + else if (afterVch is null) + { + str = "🎙" + + Format.Code(PrettyCurrentTime(usr.Guild)) + + GetText(logChannel.Guild, + strs.user_vleft("👤" + Format.Bold(usr.Username), + Format.Bold(beforeVch.Name ?? ""))); + } + + if (!string.IsNullOrWhiteSpace(str)) + { + PresenceUpdates.AddOrUpdate(logChannel, + [str], + (_, list) => + { + list.Add(str); + return list; + }); + } + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserLeft(SocketGuild guild, SocketUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserLeftId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserLeft)) is null) + return; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("❌ " + GetText(logChannel.Guild, strs.user_left)) + .WithDescription(usr.ToString()) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserJoined(IGuildUser usr) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(usr.Guild.Id, out var logSetting) || logSetting.UserJoinedId is null) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(usr.Guild, logSetting, LogType.UserJoined)) is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("✅ " + GetText(logChannel.Guild, strs.user_joined)) + .WithDescription($"{usr.Mention} `{usr}`") + .AddField("Id", usr.Id.ToString()) + .AddField(GetText(logChannel.Guild, strs.joined_server), + $"{usr.JoinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", + true) + .AddField(GetText(logChannel.Guild, strs.joined_discord), + $"{usr.CreatedAt:dd.MM.yyyy HH:mm}", + true) + .WithFooter(CurrentTime(usr.Guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserUnbanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserUnbannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserUnbanned)) is null) + return; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("♻️ " + GetText(logChannel.Guild, strs.user_unbanned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .WithFooter(CurrentTime(guild)); + + if (Uri.IsWellFormedUriString(usr.GetAvatarUrl(), UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_UserBanned(IUser usr, IGuild guild) + { + _ = Task.Run(async () => + { + try + { + if (!GuildLogSettings.TryGetValue(guild.Id, out var logSetting) + || logSetting.UserBannedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == usr.Id && ilc.ItemType == IgnoredItemType.User)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null) + return; + + + string? reason = null; + try + { + var ban = await guild.GetBanAsync(usr); + reason = ban?.Reason; + } + catch + { + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned)) + .WithDescription(usr.ToString()!) + .AddField("Id", usr.Id.ToString()) + .AddField("Reason", string.IsNullOrWhiteSpace(reason) ? "-" : reason) + .WithFooter(CurrentTime(guild)); + + var avatarUrl = usr.GetAvatarUrl(); + + if (Uri.IsWellFormedUriString(avatarUrl, UriKind.Absolute)) + embed.WithThumbnailUrl(usr.GetAvatarUrl()); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageDeleted(Cacheable optMsg, Cacheable optCh) + { + _ = Task.Run(async () => + { + try + { + if (optMsg.Value is not IUserMessage msg || msg.IsAuthor(_client)) + return; + + if (_ignoreMessageIds.Contains(msg.Id)) + return; + + var ch = optCh.Value; + if (ch is not ITextChannel channel) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageDeletedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageDeleted)) is null + || logChannel.Id == msg.Id) + return; + + var resolvedMessage = msg.Resolve(TagHandling.FullName); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("🗑 " + + GetText(logChannel.Guild, strs.msg_del(((ITextChannel)msg.Channel).Name))) + .WithDescription(msg.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.content), + string.IsNullOrWhiteSpace(resolvedMessage) ? "-" : resolvedMessage) + .AddField("Id", msg.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + if (msg.Attachments.Any()) + { + embed.AddField(GetText(logChannel.Guild, strs.attachments), + string.Join(", ", msg.Attachments.Select(a => a.Url))); + } + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch (Exception) + { + // ignored + } + }); + return Task.CompletedTask; + } + + private Task _client_MessageUpdated( + Cacheable optmsg, + SocketMessage imsg2, + ISocketMessageChannel ch) + { + _ = Task.Run(async () => + { + try + { + if (imsg2 is not IUserMessage after || after.IsAuthor(_client)) + return; + + if ((optmsg.HasValue ? optmsg.Value : null) is not IUserMessage before) + return; + + if (ch is not ITextChannel channel) + return; + + if (before.Content == after.Content) + return; + + if (before.Author.IsBot) + return; + + if (!GuildLogSettings.TryGetValue(channel.Guild.Id, out var logSetting) + || logSetting.MessageUpdatedId is null + || logSetting.LogIgnores.Any(ilc + => ilc.LogItemId == channel.Id && ilc.ItemType == IgnoredItemType.Channel)) + return; + + ITextChannel? logChannel; + if ((logChannel = await TryGetLogChannel(channel.Guild, logSetting, LogType.MessageUpdated)) is null + || logChannel.Id == after.Channel.Id) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("📝 " + + GetText(logChannel.Guild, + strs.msg_update(((ITextChannel)after.Channel).Name))) + .WithDescription(after.Author.ToString()!) + .AddField(GetText(logChannel.Guild, strs.old_msg), + string.IsNullOrWhiteSpace(before.Content) + ? "-" + : before.Resolve(TagHandling.FullName)) + .AddField(GetText(logChannel.Guild, strs.new_msg), + string.IsNullOrWhiteSpace(after.Content) ? "-" : after.Resolve(TagHandling.FullName)) + .AddField("Id", after.Id.ToString()) + .WithFooter(CurrentTime(channel.Guild)); + + await _sender.Response(logChannel).Embed(embed).SendAsync(); + } + catch + { + // ignored + } + }); + return Task.CompletedTask; + } + + private async Task TryGetLogChannel(IGuild guild, LogSetting logSetting, LogType logChannelType) + { + ulong? id = null; + switch (logChannelType) + { + case LogType.Other: + id = logSetting.LogOtherId; + break; + case LogType.MessageUpdated: + id = logSetting.MessageUpdatedId; + break; + case LogType.MessageDeleted: + id = logSetting.MessageDeletedId; + break; + case LogType.UserJoined: + id = logSetting.UserJoinedId; + break; + case LogType.UserLeft: + id = logSetting.UserLeftId; + break; + case LogType.UserBanned: + id = logSetting.UserBannedId; + break; + case LogType.UserUnbanned: + id = logSetting.UserUnbannedId; + break; + case LogType.UserUpdated: + id = logSetting.UserUpdatedId; + break; + case LogType.ChannelCreated: + id = logSetting.ChannelCreatedId; + break; + case LogType.ChannelDestroyed: + id = logSetting.ChannelDestroyedId; + break; + case LogType.ChannelUpdated: + id = logSetting.ChannelUpdatedId; + break; + case LogType.UserPresence: + id = logSetting.LogUserPresenceId; + break; + case LogType.VoicePresence: + id = logSetting.LogVoicePresenceId; + break; + case LogType.UserMuted: + id = logSetting.UserMutedId; + break; + case LogType.UserWarned: + id = logSetting.LogWarnsId; + break; + case LogType.ThreadCreated: + id = logSetting.ThreadCreatedId; + break; + case LogType.ThreadDeleted: + id = logSetting.ThreadDeletedId; + break; + } + + if (id is null or 0) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + var channel = await guild.GetTextChannelAsync(id.Value); + + if (channel is null) + { + UnsetLogSetting(guild.Id, logChannelType); + return null; + } + + return channel; + } + + private void UnsetLogSetting(ulong guildId, LogType logChannelType) + { + using var uow = _db.GetDbContext(); + var newLogSetting = uow.LogSettingsFor(guildId); + switch (logChannelType) + { + case LogType.Other: + newLogSetting.LogOtherId = null; + break; + case LogType.MessageUpdated: + newLogSetting.MessageUpdatedId = null; + break; + case LogType.MessageDeleted: + newLogSetting.MessageDeletedId = null; + break; + case LogType.UserJoined: + newLogSetting.UserJoinedId = null; + break; + case LogType.UserLeft: + newLogSetting.UserLeftId = null; + break; + case LogType.UserBanned: + newLogSetting.UserBannedId = null; + break; + case LogType.UserUnbanned: + newLogSetting.UserUnbannedId = null; + break; + case LogType.UserUpdated: + newLogSetting.UserUpdatedId = null; + break; + case LogType.UserMuted: + newLogSetting.UserMutedId = null; + break; + case LogType.ChannelCreated: + newLogSetting.ChannelCreatedId = null; + break; + case LogType.ChannelDestroyed: + newLogSetting.ChannelDestroyedId = null; + break; + case LogType.ChannelUpdated: + newLogSetting.ChannelUpdatedId = null; + break; + case LogType.UserPresence: + newLogSetting.LogUserPresenceId = null; + break; + case LogType.VoicePresence: + newLogSetting.LogVoicePresenceId = null; + break; + case LogType.UserWarned: + newLogSetting.LogWarnsId = null; + break; + } + + GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting); + uow.SaveChanges(); + } +} diff --git a/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs new file mode 100644 index 0000000..1632da5 --- /dev/null +++ b/src/EllieBot/Modules/Administration/ServerLog/ServerLogCommands.cs @@ -0,0 +1,175 @@ +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + [NoPublicBot] + public partial class LogCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogServer(PermissionAction action) + { + await _service.LogServer(ctx.Guild.Id, ctx.Channel.Id, action.Value); + if (action.Value) + await Response().Confirm(strs.log_all).SendAsync(); + else + await Response().Confirm(strs.log_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore() + { + var settings = _service.GetGuildLogSettings(ctx.Guild.Id); + + var chs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.Channel).ToList() + ?? new List(); + var usrs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.User).ToList() + ?? new List(); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.log_ignored_channels), + chs.Count == 0 + ? "-" + : string.Join('\n', chs.Select(x => $"{x.LogItemId} | <#{x.LogItemId}>"))) + .AddField(GetText(strs.log_ignored_users), + usrs.Count == 0 + ? "-" + : string.Join('\n', usrs.Select(x => $"{x.LogItemId} | <@{x.LogItemId}>"))); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] ITextChannel target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.Channel); + + if (!removed) + { + await Response() + .Confirm( + strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm( + strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogIgnore([Leftover] IUser target) + { + var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.User); + + if (!removed) + { + await Response() + .Confirm(strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")"))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task LogEvents() + { + var logSetting = _service.GetGuildLogSettings(ctx.Guild.Id); + var str = string.Join("\n", + Enum.GetNames() + .Select(x => + { + var val = logSetting is null ? null : GetLogProperty(logSetting, Enum.Parse(x)); + if (val is not null) + return $"{Format.Bold(x)} <#{val}>"; + return Format.Bold(x); + })); + + await Response().Confirm(Format.Bold(GetText(strs.log_events)) + "\n" + str).SendAsync(); + } + + private static ulong? GetLogProperty(LogSetting l, LogType type) + { + switch (type) + { + case LogType.Other: + return l.LogOtherId; + case LogType.MessageUpdated: + return l.MessageUpdatedId; + case LogType.MessageDeleted: + return l.MessageDeletedId; + case LogType.UserJoined: + return l.UserJoinedId; + case LogType.UserLeft: + return l.UserLeftId; + case LogType.UserBanned: + return l.UserBannedId; + case LogType.UserUnbanned: + return l.UserUnbannedId; + case LogType.UserUpdated: + return l.UserUpdatedId; + case LogType.ChannelCreated: + return l.ChannelCreatedId; + case LogType.ChannelDestroyed: + return l.ChannelDestroyedId; + case LogType.ChannelUpdated: + return l.ChannelUpdatedId; + case LogType.UserPresence: + return l.LogUserPresenceId; + case LogType.VoicePresence: + return l.LogVoicePresenceId; + case LogType.UserMuted: + return l.UserMutedId; + case LogType.UserWarned: + return l.LogWarnsId; + case LogType.ThreadDeleted: + return l.ThreadDeletedId; + case LogType.ThreadCreated: + return l.ThreadCreatedId; + default: + return null; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [OwnerOnly] + public async Task Log(LogType type) + { + var val = _service.Log(ctx.Guild.Id, ctx.Channel.Id, type); + + if (val) + await Response().Confirm(strs.log(Format.Bold(type.ToString()))).SendAsync(); + else + await Response().Confirm(strs.log_stop(Format.Bold(type.ToString()))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs new file mode 100644 index 0000000..e1f2fea --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/GuildTimezoneService.cs @@ -0,0 +1,95 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Administration.Services; + +public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, IEService +{ + private readonly ConcurrentDictionary _timezones; + private readonly DbService _db; + private readonly IReplacementPatternStore _repStore; + + public GuildTimezoneService(IBot bot, DbService db, IReplacementPatternStore repStore) + { + _timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple) + .Where(x => x.Timezone is not null) + .ToDictionary(x => x.GuildId, x => x.Timezone) + .ToConcurrent(); + + _db = db; + _repStore = repStore; + + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + var (guildId, tz) = GetTimzezoneTuple(arg); + if (tz is not null) + _timezones.TryAdd(guildId, tz); + return Task.CompletedTask; + } + + private static (ulong GuildId, TimeZoneInfo Timezone) GetTimzezoneTuple(GuildConfig x) + { + TimeZoneInfo tz; + try + { + if (x.TimeZoneId is null) + tz = null; + else + tz = TimeZoneInfo.FindSystemTimeZoneById(x.TimeZoneId); + } + catch + { + tz = null; + } + + return (x.GuildId, Timezone: tz); + } + + public TimeZoneInfo GetTimeZoneOrDefault(ulong? guildId) + { + if (guildId is ulong gid && _timezones.TryGetValue(gid, out var tz)) + return tz; + + return null; + } + + public void SetTimeZone(ulong guildId, TimeZoneInfo tz) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + gc.TimeZoneId = tz?.Id; + uow.SaveChanges(); + + if (tz is null) + _timezones.TryRemove(guildId, out tz); + else + _timezones.AddOrUpdate(guildId, tz, (_, _) => tz); + } + + public TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId) + => GetTimeZoneOrDefault(guildId) ?? TimeZoneInfo.Utc; + + public Task OnReadyAsync() + { + _repStore.Register("%server.time%", + (IGuild g) => + { + var to = TimeZoneInfo.Local; + if (g is not null) + { + to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local; + } + + return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ") + + to.StandardName.GetInitials(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs new file mode 100644 index 0000000..a05789d --- /dev/null +++ b/src/EllieBot/Modules/Administration/Timezone/TimeZoneCommands.cs @@ -0,0 +1,78 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class TimeZoneCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezones(int page = 1) + { + page--; + + if (page is < 0 or > 20) + return; + + var timezones = TimeZoneInfo.GetSystemTimeZones().OrderBy(x => x.BaseUtcOffset).ToArray(); + var timezonesPerPage = 20; + + var curTime = DateTimeOffset.UtcNow; + + var i = 0; + var timezoneStrings = timezones.Select(x => (x, ++i % 2 == 0)) + .Select(data => + { + var (tzInfo, flip) = data; + var nameStr = $"{tzInfo.Id,-30}"; + var offset = curTime.ToOffset(tzInfo.GetUtcOffset(curTime)) + .ToString("zzz"); + if (flip) + return $"{offset} {Format.Code(nameStr)}"; + return $"{Format.Code(offset)} {nameStr}"; + }) + .ToList(); + + + await Response() + .Paginated() + .Items(timezoneStrings) + .PageSize(timezonesPerPage) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.timezones_available)) + .WithDescription(string.Join("\n", items))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Timezone() + => await Response().Confirm(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task Timezone([Leftover] string id) + { + TimeZoneInfo tz; + try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); } + catch { tz = null; } + + + if (tz is null) + { + await Response().Error(strs.timezone_not_found).SendAsync(); + return; + } + + _service.SetTimeZone(ctx.Guild.Id, tz); + + await Response().Confirm(tz.ToString()).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs new file mode 100644 index 0000000..27a3f12 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishCommands.cs @@ -0,0 +1,960 @@ +#nullable disable +using CommandLine; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Administration.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class UserPunishCommands : EllieModule + { + public enum AddRole + { + AddRole + } + + private readonly MuteService _mute; + + public UserPunishCommands(MuteService mute) + { + _mute = mute; + } + + private async Task CheckRoleHierarchy(IGuildUser target) + { + var curUser = ((SocketGuild)ctx.Guild).CurrentUser; + var ownerId = ctx.Guild.OwnerId; + var modMaxRole = ((IGuildUser)ctx.User).GetRoles().Max(r => r.Position); + var targetMaxRole = target.GetRoles().Max(r => r.Position); + var botMaxRole = curUser.GetRoles().Max(r => r.Position); + // bot can't punish a user who is higher in the hierarchy. Discord will return 403 + // moderator can be owner, in which case role hierarchy doesn't matter + // otherwise, moderator has to have a higher role + if (botMaxRole <= targetMaxRole + || (ctx.User.Id != ownerId && targetMaxRole >= modMaxRole) + || target.Id == ownerId) + { + await Response().Error(strs.hierarchy).SendAsync(); + return false; + } + + return true; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warn(IGuildUser user, [Leftover] string reason = null) + => Warn(1, user, reason); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warn(int weight, IGuildUser user, [Leftover] string reason = null) + { + if (weight <= 0) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + try + { + await _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.warned_on(ctx.Guild.ToString()))) + .AddField(GetText(strs.moderator), ctx.User.ToString()) + .AddField(GetText(strs.reason), reason ?? "-")) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + WarningPunishment punishment; + try + { + punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason); + } + catch (Exception ex) + { + Log.Warning(ex, "Exception occured while warning a user"); + var errorEmbed = _sender.CreateEmbed().WithErrorColor() + .WithDescription(GetText(strs.cant_apply_punishment)); + + if (dmFailed) + errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(errorEmbed).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor(); + if (punishment is null) + embed.WithDescription(GetText(strs.user_warned(Format.Bold(user.ToString())))); + else + { + embed.WithDescription(GetText(strs.user_warned_and_punished(Format.Bold(user.ToString()), + Format.Bold(punishment.Punishment.ToString())))); + } + + if (dmFailed) + embed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(1)] + public async Task WarnExpire() + { + var expireDays = await _service.GetWarnExpire(ctx.Guild.Id); + + if (expireDays == 0) + await Response().Confirm(strs.warns_dont_expire).SendAsync(); + else + await Response().Error(strs.warns_expire_in(expireDays)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [EllieOptions] + [Priority(2)] + public async Task WarnExpire(int days, params string[] args) + { + if (days is < 0 or > 366) + return; + + var opts = OptionsParser.ParseFrom(args); + + await ctx.Channel.TriggerTypingAsync(); + + await _service.WarnExpireAsync(ctx.Guild.Id, days, opts.Delete); + if (days == 0) + { + await Response().Confirm(strs.warn_expire_reset).SendAsync(); + return; + } + + if (opts.Delete) + await Response().Confirm(strs.warn_expire_set_delete(Format.Bold(days.ToString()))).SendAsync(); + else + await Response().Confirm(strs.warn_expire_set_clear(Format.Bold(days.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(2)] + public Task Warnlog(int page, [Leftover] IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return Warnlog(page, user.Id); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(3)] + public Task Warnlog(IGuildUser user = null) + { + user ??= (IGuildUser)ctx.User; + + return ctx.User.Id == user.Id || ((IGuildUser)ctx.User).GuildPermissions.BanMembers + ? Warnlog(user.Id) + : Task.CompletedTask; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task Warnlog(int page, ulong userId) + => InternalWarnlog(userId, page - 1); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Warnlog(ulong userId) + => InternalWarnlog(userId, 0); + + private async Task InternalWarnlog(ulong userId, int inputPage) + { + if (inputPage < 0) + return; + + var allWarnings = _service.UserWarnings(ctx.Guild.Id, userId); + + await Response() + .Paginated() + .Items(allWarnings) + .PageSize(9) + .CurrentPage(inputPage) + .Page((warnings, page) => + { + var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString(); + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.warnlog_for(user))); + + if (!warnings.Any()) + embed.WithDescription(GetText(strs.warnings_none)); + else + { + var descText = GetText(strs.warn_count( + Format.Bold(warnings.Where(x => !x.Forgiven).Sum(x => x.Weight).ToString()), + Format.Bold(warnings.Sum(x => x.Weight).ToString()))); + + embed.WithDescription(descText); + + var i = page * 9; + foreach (var w in warnings) + { + i++; + var name = GetText(strs.warned_on_by(w.DateAdded?.ToString("dd.MM.yyy"), + w.DateAdded?.ToString("HH:mm"), + w.Moderator)); + + if (w.Forgiven) + name = $"{Format.Strikethrough(name)} {GetText(strs.warn_cleared_by(w.ForgivenBy))}"; + + + embed.AddField($"#`{i}` " + name, + Format.Code(GetText(strs.warn_weight(w.Weight))) + '\n' + w.Reason.TrimTo(1000)); + } + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnlogAll(int page = 1) + { + if (--page < 0) + return; + var allWarnings = _service.WarnlogAll(ctx.Guild.Id); + + await Response() + .Paginated() + .Items(allWarnings) + .PageSize(15) + .CurrentPage(page) + .Page((warnings, _) => + { + var ws = warnings + .Select(x => + { + var all = x.Count(); + var forgiven = x.Count(y => y.Forgiven); + var total = all - forgiven; + var usr = ((SocketGuild)ctx.Guild).GetUser(x.Key); + return (usr?.ToString() ?? x.Key.ToString()) + + $" | {total} ({all} - {forgiven})"; + }); + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.warnings_list)) + .WithDescription(string.Join("\n", ws)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public Task Warnclear(IGuildUser user, int index = 0) + => Warnclear(user.Id, index); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task Warnclear(ulong userId, int index = 0) + { + if (index < 0) + return; + var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString()); + var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString()); + if (index == 0) + await Response().Error(strs.warnings_cleared(userStr)).SendAsync(); + else + { + if (success) + await Response().Confirm(strs.warning_cleared(Format.Bold(index.ToString()), userStr)).SendAsync(); + else + await Response().Error(strs.warning_clear_fail).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [Priority(1)] + public async Task WarnPunish( + int number, + AddRole _, + IRole role, + StoopidTime time = null) + { + var punish = PunishmentAction.AddRole; + + if (ctx.Guild.OwnerId != ctx.User.Id + && role.Position >= ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position)) + { + await Response().Error(strs.role_too_high).SendAsync(); + return; + } + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role); + + if (!success) + return; + + if (time is null) + { + await Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null) + { + // this should never happen. Addrole has its own method with higher priority + // also disallow warn punishment for getting warned + if (punish is PunishmentAction.AddRole or PunishmentAction.Warn) + return; + + // you must specify the time for timeout + if (punish is PunishmentAction.TimeOut && time is null) + return; + + var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time); + + if (!success) + return; + + if (time is null) + { + await Response() + .Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()), + Format.Bold(number.ToString()), + Format.Bold(time.Input))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + public async Task WarnPunish(int number) + { + if (!_service.WarnPunishRemove(ctx.Guild.Id, number)) + return; + + await Response().Confirm(strs.warn_punish_rem(Format.Bold(number.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WarnPunishList() + { + var ps = _service.WarnPunishList(ctx.Guild.Id); + + string list; + if (ps.Any()) + { + list = string.Join("\n", + ps.Select(x + => $"{x.Count} -> {x.Punishment} {(x.Punishment == PunishmentAction.AddRole ? $"<@&{x.RoleId}>" : "")} {(x.Time <= 0 ? "" : x.Time + "m")} ")); + } + else + list = GetText(strs.warnpl_none); + + await Response().Confirm(GetText(strs.warn_punish_list), list).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task Ban(StoopidTime time, IUser user, [Leftover] string msg = null) + => Ban(time, user.Id, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(StoopidTime time, ulong userId, [Leftover] string msg = null) + { + if (time.Time > TimeSpan.FromDays(49)) + return; + + var guildUser = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + + + if (guildUser is not null && !await CheckRoleHierarchy(guildUser)) + return; + + var dmFailed = false; + + if (guildUser is not null) + { + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var smartText = + await _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time); + if (smartText is not null) + await Response().User(guildUser).Text(smartText).SendAsync(); + } + catch + { + dmFailed = true; + } + } + + var user = await ctx.Client.GetUserAsync(userId); + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune); + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true) + .AddField("ID", userId.ToString(), true) + .AddField(GetText(strs.duration), + time.Time.ToPrettyStringHm(), + true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public async Task Ban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + { + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField("ID", userId.ToString(), true)) + .SendAsync(); + } + else + await Ban(user, msg); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(2)] + public async Task Ban(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg)); + var embed = await _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null); + if (embed is not null) + await Response().User(user).Text(embed).SendAsync(); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⛔️ " + GetText(strs.banned_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanPrune(int days) + { + if (days < 0 || days > 7) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + await _service.SetBanPruneAsync(ctx.Guild.Id, days); + + if (days == 0) + await Response().Confirm(strs.ban_prune_disabled).SendAsync(); + else + await Response().Confirm(strs.ban_prune(days)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMessage([Leftover] string message = null) + { + if (message is null) + { + var template = _service.GetBanTemplate(ctx.Guild.Id); + if (template is null) + { + await Response().Confirm(strs.banmsg_default).SendAsync(); + return; + } + + await Response().Confirm(template).SendAsync(); + return; + } + + _service.SetBanTemplate(ctx.Guild.Id, message); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task BanMsgReset() + { + _service.SetBanTemplate(ctx.Guild.Id, null); + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(0)] + public Task BanMessageTest([Leftover] string reason = null) + => InternalBanMessageTest(reason, null); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Priority(1)] + public Task BanMessageTest(StoopidTime duration, [Leftover] string reason = null) + => InternalBanMessageTest(reason, duration.Time); + + private async Task InternalBanMessageTest(string reason, TimeSpan? duration) + { + var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), reason)); + var smartText = await _service.GetBanUserDmEmbed(Context, + (IGuildUser)ctx.User, + defaultMessage, + reason, + duration); + + if (smartText is null) + await Response().Confirm(strs.banmsg_disabled).SendAsync(); + else + { + try + { + await Response().User(ctx.User).Text(smartText).SendAsync(); + } + catch (Exception) + { + await Response().Error(strs.unable_to_dm_user).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban([Leftover] string user) + { + var bans = await ctx.Guild.GetBansAsync().FlattenAsync(); + + var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant()); + + if (bun is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await UnbanInternal(bun.User); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Unban(ulong userId) + { + var bun = await ctx.Guild.GetBanAsync(userId); + + if (bun is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await UnbanInternal(bun.User); + } + + private async Task UnbanInternal(IUser user) + { + await ctx.Guild.RemoveBanAsync(user); + + await Response().Confirm(strs.unbanned_user(Format.Bold(user.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public Task Softban(IGuildUser user, [Leftover] string msg = null) + => SoftbanInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)] + [BotPerm(GuildPerm.BanMembers)] + public async Task Softban(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await SoftbanInternal(user, msg); + } + + private async Task SoftbanInternal(IGuildUser user, [Leftover] string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(strs.sbdm(Format.Bold(ctx.Guild.Name), msg)) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + await ctx.Guild.AddBanAsync(user, banPrune, ("Softban | " + ctx.User + " | " + msg).TrimTo(512)); + try { await ctx.Guild.RemoveBanAsync(user); } + catch { await ctx.Guild.RemoveBanAsync(user); } + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("☣ " + GetText(strs.sb_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(1)] + public Task Kick(IGuildUser user, [Leftover] string msg = null) + => KickInternal(user, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.KickMembers)] + [BotPerm(GuildPerm.KickMembers)] + [Priority(0)] + public async Task Kick(ulong userId, [Leftover] string msg = null) + { + var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId); + if (user is null) + return; + + await KickInternal(user, msg); + } + + private async Task KickInternal(IGuildUser user, string msg = null) + { + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + await Response() + .Channel(await user.CreateDMChannelAsync()) + .Error(GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg))) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.KickAsync((ctx.User + " | " + msg).TrimTo(512)); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.kicked_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ModerateMembers)] + [BotPerm(GuildPerm.ModerateMembers)] + [Priority(2)] + public async Task Timeout(IUser globalUser, StoopidTime time, [Leftover] string msg = null) + { + var user = await ctx.Guild.GetUserAsync(globalUser.Id); + + if (user is null) + return; + + if (!await CheckRoleHierarchy(user)) + return; + + var dmFailed = false; + + try + { + var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg)); + await _sender.Response(user) + .Embed(_sender.CreateEmbed() + .WithPendingColor() + .WithDescription(dmMessage)) + .SendAsync(); + } + catch + { + dmFailed = true; + } + + await user.SetTimeOutAsync(time.Time); + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("⏳ " + GetText(strs.timedout_user)) + .AddField(GetText(strs.username), user.ToString(), true) + .AddField("ID", user.Id.ToString(), true); + + if (dmFailed) + toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user)); + + await Response().Embed(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [Ratelimit(30)] + public async Task MassBan(params string[] userStrings) + { + if (userStrings.Length == 0) + return; + + var missing = new List(); + var banning = new HashSet(); + + await ctx.Channel.TriggerTypingAsync(); + foreach (var userStr in userStrings) + { + if (ulong.TryParse(userStr, out var userId)) + { + IUser user = await ctx.Guild.GetUserAsync(userId) + ?? await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, + userId); + + if (user is null) + { + // if IGuildUser is null, try to get IUser + user = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(userId); + + // only add to missing if *still* null + if (user is null) + { + missing.Add(userStr); + continue; + } + } + + //Hierachy checks only if the user is in the guild + if (user is IGuildUser gu && !await CheckRoleHierarchy(gu)) + return; + + banning.Add(user); + } + else + missing.Add(userStr); + } + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + var toSend = _sender.CreateEmbed() + .WithDescription(GetText(strs.mass_ban_in_progress(banning.Count))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithPendingColor(); + + var banningMessage = await Response().Embed(toSend).SendAsync(); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + foreach (var toBan in banning) + { + try + { + await ctx.Guild.AddBanAsync(toBan.Id, banPrune, $"{ctx.User} | Massban"); + } + catch (Exception ex) + { + Log.Warning(ex, "Error banning {User} user in {GuildId} server", toBan.Id, ctx.Guild.Id); + } + } + + await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_ban_completed(banning.Count()))) + .AddField(GetText(strs.invalid(missing.Count)), missStr) + .WithOkColor() + .Build()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.BanMembers)] + [BotPerm(GuildPerm.BanMembers)] + [OwnerOnly] + public async Task MassKill([Leftover] string people) + { + if (string.IsNullOrWhiteSpace(people)) + return; + + var (bans, missing) = _service.MassKill((SocketGuild)ctx.Guild, people); + + var missStr = string.Join("\n", missing); + if (string.IsNullOrWhiteSpace(missStr)) + missStr = "-"; + + //send a message but don't wait for it + var banningMessageTask = Response() + .Embed(_sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_kill_in_progress(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithPendingColor()) + .SendAsync(); + + var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7; + //do the banning + await Task.WhenAll(bans.Where(x => x.Id.HasValue) + .Select(x => ctx.Guild.AddBanAsync(x.Id.Value, + banPrune, + x.Reason, + new() + { + RetryMode = RetryMode.AlwaysRetry + }))); + + //wait for the message and edit it + var banningMessage = await banningMessageTask; + + await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithDescription( + GetText(strs.mass_kill_completed(bans.Count()))) + .AddField(GetText(strs.invalid(missing)), missStr) + .WithOkColor() + .Build()); + } + + public class WarnExpireOptions : IEllieCommandOptions + { + [Option('d', "delete", Default = false, HelpText = "Delete warnings instead of clearing them.")] + public bool Delete { get; set; } = false; + + public void NormalizeOptions() + { + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs new file mode 100644 index 0000000..cdc9900 --- /dev/null +++ b/src/EllieBot/Modules/Administration/UserPunish/UserPunishService.cs @@ -0,0 +1,597 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Administration.Services; + +public class UserPunishService : IEService, IReadyExecutor +{ + private readonly MuteService _mute; + private readonly DbService _db; + private readonly BlacklistService _blacklistService; + private readonly BotConfigService _bcs; + private readonly DiscordSocketClient _client; + private readonly IReplacementService _repSvc; + + public event Func OnUserWarned = static delegate { return Task.CompletedTask; }; + + public UserPunishService( + MuteService mute, + DbService db, + BlacklistService blacklistService, + BotConfigService bcs, + DiscordSocketClient client, + IReplacementService repSvc) + { + _mute = mute; + _db = db; + _blacklistService = blacklistService; + _bcs = bcs; + _client = client; + _repSvc = repSvc; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12)); + do + { + try + { + await CheckAllWarnExpiresAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message); + } + } while (await expiryTimer.WaitForNextTickAsync()); + } + + public async Task Warn( + IGuild guild, + ulong userId, + IUser mod, + long weight, + string reason) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weight); + + var modName = mod.ToString(); + + if (string.IsNullOrWhiteSpace(reason)) + reason = "-"; + + var guildId = guild.Id; + + var warn = new Warning + { + UserId = userId, + GuildId = guildId, + Forgiven = false, + Reason = reason, + Moderator = modName, + Weight = weight + }; + + long previousCount; + List ps; + await using (var uow = _db.GetDbContext()) + { + ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + + previousCount = uow.Set().ForId(guildId, userId) + .Where(w => !w.Forgiven && w.UserId == userId) + .Sum(x => x.Weight); + + uow.Set().Add(warn); + + await uow.SaveChangesAsync(); + } + + _ = OnUserWarned(warn); + + var totalCount = previousCount + weight; + + var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount) + .MaxBy(x => x.Count); + + if (p is not null) + { + var user = await guild.GetUserAsync(userId); + if (user is null) + return null; + + await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times."); + return p; + } + + return null; + } + + public async Task ApplyPunishment( + IGuild guild, + IGuildUser user, + IUser mod, + PunishmentAction p, + int minutes, + ulong? roleId, + string reason) + { + if (!await CheckPermission(guild, p)) + return; + + int banPrune; + switch (p) + { + case PunishmentAction.Mute: + if (minutes == 0) + await _mute.MuteUser(user, mod, reason: reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason); + break; + case PunishmentAction.VoiceMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Voice, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason); + break; + case PunishmentAction.ChatMute: + if (minutes == 0) + await _mute.MuteUser(user, mod, MuteType.Chat, reason); + else + await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason); + break; + case PunishmentAction.Kick: + await user.KickAsync(reason); + break; + case PunishmentAction.Ban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + if (minutes == 0) + await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune); + else + await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune); + break; + case PunishmentAction.Softban: + banPrune = await GetBanPruneAsync(user.GuildId) ?? 7; + await guild.AddBanAsync(user, banPrune, $"Softban | {reason}"); + try + { + await guild.RemoveBanAsync(user); + } + catch + { + await guild.RemoveBanAsync(user); + } + + break; + case PunishmentAction.RemoveRoles: + await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole)); + break; + case PunishmentAction.AddRole: + if (roleId is null) + return; + var role = guild.GetRole(roleId.Value); + if (role is not null) + { + if (minutes == 0) + await user.AddRoleAsync(role); + else + await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role); + } + else + { + Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment", + roleId.Value, + guild.Id); + } + + break; + case PunishmentAction.Warn: + await Warn(guild, user.Id, mod, 1, reason); + break; + case PunishmentAction.TimeOut: + await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes)); + break; + } + } + + /// + /// Used to prevent the bot from hitting 403's when it needs to + /// apply punishments with insufficient permissions + /// + /// Guild the punishment is applied in + /// Punishment to apply + /// Whether the bot has sufficient permissions + private async Task CheckPermission(IGuild guild, PunishmentAction punish) + { + var botUser = await guild.GetCurrentUserAsync(); + switch (punish) + { + case PunishmentAction.Mute: + return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles; + case PunishmentAction.Kick: + return botUser.GuildPermissions.KickMembers; + case PunishmentAction.Ban: + return botUser.GuildPermissions.BanMembers; + case PunishmentAction.Softban: + return botUser.GuildPermissions.BanMembers; // ban + unban + case PunishmentAction.RemoveRoles: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.ChatMute: + return botUser.GuildPermissions.ManageRoles; // adds ellie-mute role + case PunishmentAction.VoiceMute: + return botUser.GuildPermissions.MuteMembers; + case PunishmentAction.AddRole: + return botUser.GuildPermissions.ManageRoles; + case PunishmentAction.TimeOut: + return botUser.GuildPermissions.ModerateMembers; + default: + return true; + } + } + + public async Task CheckAllWarnExpiresAsync() + { + await using var uow = _db.GetDbContext(); + var cleared = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Clear) + && x.Forgiven == false + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + + var deleted = await uow.Set() + .Where(x => uow.Set() + .Any(y => y.GuildId == x.GuildId + && y.WarnExpireHours > 0 + && y.WarnExpireAction == WarnExpireAction.Delete) + && x.DateAdded + < DateTime.UtcNow.AddHours(-uow.Set() + .Where(y => x.GuildId == y.GuildId) + .Select(y => y.WarnExpireHours) + .First())) + .DeleteAsync(); + + if (cleared > 0 || deleted > 0) + { + Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry", + cleared, + deleted); + } + + await uow.SaveChangesAsync(); + } + + public async Task CheckWarnExpiresAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, inc => inc); + + if (config.WarnExpireHours == 0) + return; + + if (config.WarnExpireAction == WarnExpireAction.Clear) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.Forgiven == false + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .UpdateAsync(_ => new() + { + Forgiven = true, + ForgivenBy = "expiry" + }); + } + else if (config.WarnExpireAction == WarnExpireAction.Delete) + { + await uow.Set() + .Where(x => x.GuildId == guildId + && x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours)) + .DeleteAsync(); + } + + await uow.SaveChangesAsync(); + } + + public Task GetWarnExpire(ulong guildId) + { + using var uow = _db.GetDbContext(); + var config = uow.GuildConfigsForId(guildId, set => set); + return Task.FromResult(config.WarnExpireHours / 24); + } + + public async Task WarnExpireAsync(ulong guildId, int days, bool delete) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, inc => inc); + + config.WarnExpireHours = days * 24; + config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear; + await uow.SaveChangesAsync(); + + // no need to check for warn expires + if (config.WarnExpireHours == 0) + return; + } + + await CheckWarnExpiresAsync(guildId); + } + + public IGrouping[] WarnlogAll(ulong gid) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetForGuild(gid).GroupBy(x => x.UserId).ToArray(); + } + + public Warning[] UserWarnings(ulong gid, ulong userId) + { + using var uow = _db.GetDbContext(); + return uow.Set().ForId(gid, userId); + } + + public async Task WarnClearAsync( + ulong guildId, + ulong userId, + int index, + string moderator) + { + var toReturn = true; + await using var uow = _db.GetDbContext(); + if (index == 0) + await uow.Set().ForgiveAll(guildId, userId, moderator); + else + toReturn = uow.Set().Forgive(guildId, userId, moderator, index - 1); + await uow.SaveChangesAsync(); + return toReturn; + } + + public bool WarnPunish( + ulong guildId, + int number, + PunishmentAction punish, + StoopidTime time, + IRole role = null) + { + // these 3 don't make sense with time + if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles + && time is not null) + return false; + if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49))) + return false; + + if (punish is PunishmentAction.AddRole && role is null) + return false; + + if (punish is PunishmentAction.TimeOut && time is null) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var toDelete = ps.Where(x => x.Count == number); + + uow.RemoveRange(toDelete); + + ps.Add(new() + { + Count = number, + Punishment = punish, + Time = (int?)time?.Time.TotalMinutes ?? 0, + RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?) + }); + uow.SaveChanges(); + return true; + } + + public bool WarnPunishRemove(ulong guildId, int number) + { + if (number <= 0) + return false; + + using var uow = _db.GetDbContext(); + var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments; + var p = ps.FirstOrDefault(x => x.Count == number); + + if (p is not null) + { + uow.Remove(p); + uow.SaveChanges(); + } + + return true; + } + + public WarningPunishment[] WarnPunishList(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments)) + .WarnPunishments.OrderBy(x => x.Count) + .ToArray(); + } + + public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill( + SocketGuild guild, + string people) + { + var gusers = guild.Users; + //get user objects and reasons + var bans = people.Split("\n") + .Select(x => + { + var split = x.Trim().Split(" "); + + var reason = string.Join(" ", split.Skip(1)); + + if (ulong.TryParse(split[0], out var id)) + return (Original: split[0], Id: id, Reason: reason); + + return (Original: split[0], + gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id, + Reason: reason); + }) + .ToArray(); + + //if user is null, means that person couldn't be found + var missing = bans.Count(x => !x.Id.HasValue); + + //get only data for found users + var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList(); + + _ = _blacklistService.BlacklistUsers(found); + + return (bans, missing); + } + + public string GetBanTemplate(ulong guildId) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + return template?.Text; + } + + public void SetBanTemplate(ulong guildId, string text) + { + using var uow = _db.GetDbContext(); + var template = uow.Set().AsQueryable().FirstOrDefault(x => x.GuildId == guildId); + + if (text is null) + { + if (template is null) + return; + + uow.Remove(template); + } + else if (template is null) + { + uow.Set().Add(new() + { + GuildId = guildId, + Text = text + }); + } + else + template.Text = text; + + uow.SaveChanges(); + } + + public async Task SetBanPruneAsync(ulong guildId, int? pruneDays) + { + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + GuildId = guildId, + Text = null, + DateAdded = DateTime.UtcNow, + PruneDays = pruneDays + }, + old => new() + { + PruneDays = pruneDays + }, + () => new() + { + GuildId = guildId + }); + } + + public async Task GetBanPruneAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .Where(x => x.GuildId == guildId) + .Select(x => x.PruneDays) + .FirstOrDefaultAsyncLinqToDB(); + } + + public Task GetBanUserDmEmbed( + ICommandContext context, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + => GetBanUserDmEmbed((DiscordSocketClient)context.Client, + (SocketGuild)context.Guild, + (IGuildUser)context.User, + target, + defaultMessage, + banReason, + duration); + + public async Task GetBanUserDmEmbed( + DiscordSocketClient client, + SocketGuild guild, + IGuildUser moderator, + IGuildUser target, + string defaultMessage, + string banReason, + TimeSpan? duration) + { + var template = GetBanTemplate(guild.Id); + + banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason; + + var repCtx = new ReplacementContext(client, guild) + .WithOverride("%ban.mod%", () => moderator.ToString()) + .WithOverride("%ban.mod.fullname%", () => moderator.ToString()) + .WithOverride("%ban.mod.name%", () => moderator.Username) + .WithOverride("%ban.mod.discrim%", () => moderator.Discriminator) + .WithOverride("%ban.user%", () => target.ToString()) + .WithOverride("%ban.user.fullname%", () => target.ToString()) + .WithOverride("%ban.user.name%", () => target.Username) + .WithOverride("%ban.user.discrim%", () => target.Discriminator) + .WithOverride("%reason%", () => banReason) + .WithOverride("%ban.reason%", () => banReason) + .WithOverride("%ban.duration%", + () => duration?.ToString(@"d\.hh\:mm") ?? "perma"); + + + // if template isn't set, use the old message style + if (string.IsNullOrWhiteSpace(template)) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = defaultMessage + }); + } + // if template is set to "-" do not dm the user + else if (template == "-") + return default; + // if template is an embed, send that embed with replacements + // otherwise, treat template as a regular string with replacements + else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true }) + { + template = JsonConvert.SerializeObject(new + { + color = _bcs.Data.Color.Error.PackedValue >> 8, + description = template + }); + } + + var output = SmartText.CreateFrom(template); + return await _repSvc.ReplaceAsync(output, repCtx); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs new file mode 100644 index 0000000..a5f547a --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Modules.Administration.Services; + +namespace EllieBot.Modules.Administration; + +public partial class Administration +{ + [Group] + public partial class VcRoleCommands : EllieModule + { + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRoleRm(ulong vcId) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vcId)) + await Response().Confirm(strs.vcrole_removed(Format.Bold(vcId.ToString()))).SendAsync(); + else + await Response().Error(strs.vcrole_not_found).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.ManageRoles)] + [BotPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task VcRole([Leftover] IRole role = null) + { + var user = (IGuildUser)ctx.User; + + var vc = user.VoiceChannel; + + if (vc is null || vc.GuildId != user.GuildId) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + if (role is null) + { + if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id)) + await Response().Confirm(strs.vcrole_removed(Format.Bold(vc.Name))).SendAsync(); + } + else + { + _service.AddVcRole(ctx.Guild.Id, role, vc.Id); + await Response().Confirm(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name))).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task VcRoleList() + { + var guild = (SocketGuild)ctx.Guild; + string text; + if (_service.VcRoles.TryGetValue(ctx.Guild.Id, out var roles)) + { + if (!roles.Any()) + text = GetText(strs.no_vcroles); + else + { + text = string.Join("\n", + roles.Select(x + => $"{Format.Bold(guild.GetVoiceChannel(x.Key)?.Name ?? x.Key.ToString())} => {x.Value}")); + } + } + else + text = GetText(strs.no_vcroles); + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.vc_role_list)) + .WithDescription(text)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs new file mode 100644 index 0000000..c2dc60b --- /dev/null +++ b/src/EllieBot/Modules/Administration/VcRole/VcRoleService.cs @@ -0,0 +1,208 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Administration.Services; + +public class VcRoleService : IEService +{ + public ConcurrentDictionary> VcRoles { get; } + public ConcurrentDictionary> ToAssign { get; } + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public VcRoleService(DiscordSocketClient client, IBot bot, DbService db) + { + _db = db; + _client = client; + + _client.UserVoiceStateUpdated += ClientOnUserVoiceStateUpdated; + VcRoles = new(); + ToAssign = new(); + + using (var uow = db.GetDbContext()) + { + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + uow.Set() + .AsQueryable() + .Include(x => x.VcRoleInfos) + .Where(x => guildIds.Contains(x.GuildId)) + .AsEnumerable() + .Select(InitializeVcRole) + .WhenAll(); + } + + Task.Run(async () => + { + while (true) + { + Task Selector(System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)> queue) + { + return Task.Run(async () => + { + while (queue.TryDequeue(out var item)) + { + var (add, user, role) = item; + + try + { + if (add) + { + if (!user.RoleIds.Contains(role.Id)) + await user.AddRoleAsync(role); + } + else + { + if (user.RoleIds.Contains(role.Id)) + await user.RemoveRoleAsync(role); + } + } + catch + { + } + + await Task.Delay(250); + } + }); + } + + await ToAssign.Values.Select(Selector).Append(Task.Delay(1000)).WhenAll(); + } + }); + + _client.LeftGuild += _client_LeftGuild; + bot.JoinedGuild += Bot_JoinedGuild; + } + + private Task Bot_JoinedGuild(GuildConfig arg) + { + // includeall no longer loads vcrole + // need to load new guildconfig with vc role included + using (var uow = _db.GetDbContext()) + { + var configWithVcRole = uow.GuildConfigsForId(arg.GuildId, set => set.Include(x => x.VcRoleInfos)); + _ = InitializeVcRole(configWithVcRole); + } + + return Task.CompletedTask; + } + + private Task _client_LeftGuild(SocketGuild arg) + { + VcRoles.TryRemove(arg.Id, out _); + ToAssign.TryRemove(arg.Id, out _); + return Task.CompletedTask; + } + + private async Task InitializeVcRole(GuildConfig gconf) + { + var g = _client.GetGuild(gconf.GuildId); + if (g is null) + return; + + var infos = new ConcurrentDictionary(); + var missingRoles = new List(); + VcRoles.AddOrUpdate(gconf.GuildId, infos, delegate { return infos; }); + foreach (var ri in gconf.VcRoleInfos) + { + var role = g.GetRole(ri.RoleId); + if (role is null) + { + missingRoles.Add(ri); + continue; + } + + infos.TryAdd(ri.VoiceChannelId, role); + } + + if (missingRoles.Any()) + { + await using var uow = _db.GetDbContext(); + uow.RemoveRange(missingRoles); + await uow.SaveChangesAsync(); + + Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}", + missingRoles.Count, + nameof(VcRoleService)); + } + } + + public void AddVcRole(ulong guildId, IRole role, ulong vcId) + { + ArgumentNullException.ThrowIfNull(role); + + var guildVcRoles = VcRoles.GetOrAdd(guildId, new ConcurrentDictionary()); + + guildVcRoles.AddOrUpdate(vcId, role, (_, _) => role); + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toDelete = conf.VcRoleInfos.FirstOrDefault(x => x.VoiceChannelId == vcId); // remove old one + if (toDelete is not null) + uow.Remove(toDelete); + conf.VcRoleInfos.Add(new() + { + VoiceChannelId = vcId, + RoleId = role.Id + }); // add new one + uow.SaveChanges(); + } + + public bool RemoveVcRole(ulong guildId, ulong vcId) + { + if (!VcRoles.TryGetValue(guildId, out var guildVcRoles)) + return false; + + if (!guildVcRoles.TryRemove(vcId, out _)) + return false; + + using var uow = _db.GetDbContext(); + var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos)); + var toRemove = conf.VcRoleInfos.Where(x => x.VoiceChannelId == vcId).ToList(); + uow.RemoveRange(toRemove); + uow.SaveChanges(); + + return true; + } + + private Task ClientOnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState) + { + if (usr is not SocketGuildUser gusr) + return Task.CompletedTask; + + var oldVc = oldState.VoiceChannel; + var newVc = newState.VoiceChannel; + _ = Task.Run(() => + { + try + { + if (oldVc != newVc) + { + ulong guildId; + guildId = newVc?.Guild.Id ?? oldVc.Guild.Id; + + if (VcRoles.TryGetValue(guildId, out var guildVcRoles)) + { + //remove old + if (oldVc is not null && guildVcRoles.TryGetValue(oldVc.Id, out var role)) + Assign(false, gusr, role); + //add new + if (newVc is not null && guildVcRoles.TryGetValue(newVc.Id, out role)) + Assign(true, gusr, role); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error in VcRoleService VoiceStateUpdate"); + } + }); + return Task.CompletedTask; + } + + private void Assign(bool v, SocketGuildUser gusr, IRole role) + { + var queue = ToAssign.GetOrAdd(gusr.Guild.Id, new System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>()); + queue.Enqueue((v, gusr, role)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs new file mode 100644 index 0000000..bd3eaab --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerBannerResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerBannerResult +{ + Success, + InvalidFileType, + Toolarge, + InvalidURL +} diff --git a/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs new file mode 100644 index 0000000..f0d0b1a --- /dev/null +++ b/src/EllieBot/Modules/Administration/_common/SetServerIconResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Administration._common.results; + +public enum SetServerIconResult +{ + Success, + InvalidFileType, + InvalidURL +} \ No newline at end of file -- 2.43.0 From d9096a07a8cde572c615495bedbcb4181c13b0a7 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:50:22 +1200 Subject: [PATCH 021/340] Added Expressions module --- .../Expressions/EllieExpressionExtensions.cs | 91 ++ .../Modules/Expressions/EllieExpressions.cs | 447 ++++++++++ .../Expressions/EllieExpressionsService.cs | 801 ++++++++++++++++++ .../Modules/Expressions/ExportedExpr.cs | 27 + src/EllieBot/Modules/Expressions/ExprField.cs | 10 + .../TypeReaders/CommandOrExprTypeReader.cs | 33 + 6 files changed, 1409 insertions(+) create mode 100644 src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs create mode 100644 src/EllieBot/Modules/Expressions/EllieExpressions.cs create mode 100644 src/EllieBot/Modules/Expressions/EllieExpressionsService.cs create mode 100644 src/EllieBot/Modules/Expressions/ExportedExpr.cs create mode 100644 src/EllieBot/Modules/Expressions/ExprField.cs create mode 100644 src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs new file mode 100644 index 0000000..72606a7 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionExtensions.cs @@ -0,0 +1,91 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; + +namespace EllieBot.Modules.EllieExpressions; + +public static class EllieExpressionExtensions +{ + private static string ResolveTriggerString(this string str, DiscordSocketClient client) + => str.Replace("%bot.mention%", client.CurrentUser.Mention, StringComparison.Ordinal); + + public static async Task Send( + this EllieExpression cr, + IUserMessage ctx, + IReplacementService repSvc, + DiscordSocketClient client, + IMessageSenderService sender) + { + var channel = cr.DmResponse ? await ctx.Author.CreateDMChannelAsync() : ctx.Channel; + + var trigger = cr.Trigger.ResolveTriggerString(client); + var substringIndex = trigger.Length; + if (cr.ContainsAnywhere) + { + var pos = ctx.Content.AsSpan().GetWordPosition(trigger); + if (pos == WordPosition.Start) + substringIndex += 1; + else if (pos == WordPosition.End) + substringIndex = ctx.Content.Length; + else if (pos == WordPosition.Middle) + substringIndex += ctx.Content.IndexOf(trigger, StringComparison.InvariantCulture); + } + + var canMentionEveryone = (ctx.Author as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + + var repCtx = new ReplacementContext(client: client, + guild: (ctx.Channel as ITextChannel)?.Guild as SocketGuild, + channel: ctx.Channel, + users: ctx.Author + ) + .WithOverride("%target%", + () => canMentionEveryone + ? ctx.Content[substringIndex..].Trim() + : ctx.Content[substringIndex..].Trim().SanitizeMentions(true)); + + var text = SmartText.CreateFrom(cr.Response); + text = await repSvc.ReplaceAsync(text, repCtx); + + return await sender.Response(channel).Text(text).Sanitize(false).SendAsync(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static WordPosition GetWordPosition(this ReadOnlySpan str, in ReadOnlySpan word) + { + var wordIndex = str.IndexOf(word, StringComparison.OrdinalIgnoreCase); + if (wordIndex == -1) + return WordPosition.None; + + if (wordIndex == 0) + { + if (word.Length < str.Length && str.IsValidWordDivider(word.Length)) + return WordPosition.Start; + } + else if (wordIndex + word.Length == str.Length) + { + if (str.IsValidWordDivider(wordIndex - 1)) + return WordPosition.End; + } + else if (str.IsValidWordDivider(wordIndex - 1) && str.IsValidWordDivider(wordIndex + word.Length)) + return WordPosition.Middle; + + return WordPosition.None; + } + + private static bool IsValidWordDivider(this in ReadOnlySpan str, int index) + { + var ch = str[index]; + if (ch is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '1' and <= '9') + return false; + + return true; + } +} + +public enum WordPosition +{ + None, + Start, + Middle, + End +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressions.cs b/src/EllieBot/Modules/Expressions/EllieExpressions.cs new file mode 100644 index 0000000..5def4f3 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressions.cs @@ -0,0 +1,447 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +[Name("Expressions")] +public partial class EllieExpressions : EllieModule +{ + public enum All + { + All + } + + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _clientFactory; + + public EllieExpressions(IBotCredentials creds, IHttpClientFactory clientFactory) + { + _creds = creds; + _clientFactory = clientFactory; + } + + private bool AdminInGuildOrOwnerInDm() + => (ctx.Guild is null && _creds.IsOwner(ctx.User)) + || (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator); + + private async Task ExprAddInternalAsync(string key, string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + var ex = await _service.AddAsync(ctx.Guild?.Id, key, message); + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_new)) + .WithDescription($"#{new kwum(ex.Id)}") + .AddField(GetText(strs.trigger), key) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprToggleGlobal() + { + var result = await _service.ToggleGlobalExpressionsAsync(ctx.Guild.Id); + if (result) + await Response().Confirm(strs.expr_global_disabled).SendAsync(); + else + await Response().Confirm(strs.expr_global_enabled).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprAddServer(string key, [Leftover] string message) + { + if (string.IsNullOrWhiteSpace(message) || string.IsNullOrWhiteSpace(key)) + { + return; + } + + await ExprAddInternalAsync(key, message); + } + + + [Cmd] + public async Task ExprAdd(string trigger, [Leftover] string response) + { + if (string.IsNullOrWhiteSpace(response) || string.IsNullOrWhiteSpace(trigger)) + { + return; + } + + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprAddInternalAsync(trigger, response); + } + + [Cmd] + public async Task ExprEdit(kwum id, [Leftover] string message) + { + var channel = ctx.Channel as ITextChannel; + if (string.IsNullOrWhiteSpace(message) || id < 0) + { + return; + } + + if (!IsValidExprEditor()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = await _service.EditAsync(ctx.Guild?.Id, id, message); + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_edited)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger) + .AddField(GetText(strs.response), + message.Length > 1024 ? GetText(strs.redacted_too_long) : message)) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + private bool IsValidExprEditor() + => (ctx.Guild is not null && ((IGuildUser)ctx.User).GuildPermissions.Administrator) + || (ctx.Guild is null && _creds.IsOwner(ctx.User)); + + [Cmd] + [Priority(1)] + public async Task ExprList(int page = 1) + { + if (--page < 0 || page > 999) + { + return; + } + + var allExpressions = _service.GetExpressionsFor(ctx.Guild?.Id) + .OrderBy(x => x.Trigger) + .ToArray(); + + if (!allExpressions.Any()) + { + await Response().Error(strs.expr_no_found).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(allExpressions) + .PageSize(20) + .CurrentPage(page) + .Page((exprs, _) => + { + var desc = exprs + .Select(ex => $"{(ex.ContainsAnywhere ? "🗯" : "◾")}" + + $"{(ex.DmResponse ? "✉" : "◾")}" + + $"{(ex.AutoDeleteTrigger ? "❌" : "◾")}" + + $"`{(kwum)ex.Id}` {ex.Trigger}" + + (string.IsNullOrWhiteSpace(ex.Reactions) + ? string.Empty + : " // " + string.Join(" ", ex.GetReactions()))) + .Join('\n'); + + return _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.expressions)).WithDescription(desc); + }) + .SendAsync(); + } + + [Cmd] + public async Task ExprShow(kwum id) + { + var found = _service.GetExpression(ctx.Guild?.Id, id); + + if (found is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + var inter = CreateEditInteraction(id, found); + + await Response() + .Interaction(IsValidExprEditor() ? inter : null) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), + found.Response.TrimTo(1000).Replace("](", "]\\("))) + .SendAsync(); + } + + private EllieInteractionBase CreateEditInteraction(kwum id, EllieExpression found) + { + var modal = new ModalBuilder() + .WithCustomId("expr:edit_modal") + .WithTitle($"Edit expression {id}") + .AddTextInput(new TextInputBuilder() + .WithLabel(GetText(strs.response)) + .WithValue(found.Response) + .WithMinLength(1) + .WithCustomId("expr:edit_modal:response") + .WithStyle(TextInputStyle.Paragraph)); + + var inter = _inter.Create(ctx.User.Id, + new ButtonBuilder() + .WithEmote(Emoji.Parse("📝")) + .WithLabel("Edit") + .WithStyle(ButtonStyle.Primary) + .WithCustomId("test"), + modal, + async (sm) => + { + var msg = sm.Data.Components.FirstOrDefault()?.Value; + + await ExprEdit(id, msg); + } + ); + return inter; + } + + public async Task ExprDeleteInternalAsync(kwum id) + { + var ex = await _service.DeleteAsync(ctx.Guild?.Id, id); + + if (ex is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.expr_deleted)) + .WithDescription($"#{id}") + .AddField(GetText(strs.trigger), ex.Trigger.TrimTo(1024)) + .AddField(GetText(strs.response), ex.Response.TrimTo(1024))) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [RequireContext(ContextType.Guild)] + public async Task ExprDeleteServer(kwum id) + => await ExprDeleteInternalAsync(id); + + [Cmd] + public async Task ExprDelete(kwum id) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + await ExprDeleteInternalAsync(id); + } + + [Cmd] + public async Task ExprReact(kwum id, params string[] emojiStrs) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var ex = _service.GetExpression(ctx.Guild?.Id, id); + if (ex is null) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (emojiStrs.Length == 0) + { + await _service.ResetExprReactions(ctx.Guild?.Id, id); + await Response().Confirm(strs.expr_reset(Format.Bold(id.ToString()))).SendAsync(); + return; + } + + var succ = new List(); + foreach (var emojiStr in emojiStrs) + { + var emote = emojiStr.ToIEmote(); + + // i should try adding these emojis right away to the message, to make sure the bot can react with these emojis. If it fails, skip that emoji + try + { + await ctx.Message.AddReactionAsync(emote); + await Task.Delay(100); + succ.Add(emojiStr); + + if (succ.Count >= 3) + { + break; + } + } + catch { } + } + + if (succ.Count == 0) + { + await Response().Error(strs.invalid_emojis).SendAsync(); + return; + } + + await _service.SetExprReactions(ctx.Guild?.Id, id, succ); + + + await Response() + .Confirm(strs.expr_set(Format.Bold(id.ToString()), + succ.Select(static x => x.ToString()).Join(", "))) + .SendAsync(); + } + + [Cmd] + public Task ExprCa(kwum id) + => InternalExprEdit(id, ExprField.ContainsAnywhere); + + [Cmd] + public Task ExprDm(kwum id) + => InternalExprEdit(id, ExprField.DmResponse); + + [Cmd] + public Task ExprAd(kwum id) + => InternalExprEdit(id, ExprField.AutoDelete); + + [Cmd] + public Task ExprAt(kwum id) + => InternalExprEdit(id, ExprField.AllowTarget); + + [Cmd] + [OwnerOnly] + public async Task ExprsReload() + { + await _service.TriggerReloadExpressions(); + + await ctx.OkAsync(); + } + + private async Task InternalExprEdit(kwum id, ExprField option) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + var (success, newVal) = await _service.ToggleExprOptionAsync(ctx.Guild?.Id, id, option); + if (!success) + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + return; + } + + if (newVal) + { + await Response() + .Confirm(strs.option_enabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.option_disabled(Format.Code(option.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ExprClear() + { + if (await PromptUserConfirmAsync(_sender.CreateEmbed() + .WithTitle("Expression clear") + .WithDescription("This will delete all expressions on this server."))) + { + var count = _service.DeleteAllExpressions(ctx.Guild.Id); + await Response().Confirm(strs.exprs_cleared(count)).SendAsync(); + } + } + + [Cmd] + public async Task ExprsExport() + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var serialized = _service.ExportExpressions(ctx.Guild?.Id); + await using var stream = await serialized.ToStream(); + await ctx.Channel.SendFileAsync(stream, "exprs-export.yml"); + } + + [Cmd] +#if GLOBAL_ELLIE + [OwnerOnly] +#endif + public async Task ExprsImport([Leftover] string input = null) + { + if (!AdminInGuildOrOwnerInDm()) + { + await Response().Error(strs.expr_insuff_perms).SendAsync(); + return; + } + + input = input?.Trim(); + + _ = ctx.Channel.TriggerTypingAsync(); + + if (input is null) + { + var attachment = ctx.Message.Attachments.FirstOrDefault(); + if (attachment is null) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + + using var client = _clientFactory.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + } + + var succ = await _service.ImportExpressionsAsync(ctx.Guild?.Id, input); + if (!succ) + { + await Response().Error(strs.expr_import_invalid_data).SendAsync(); + return; + } + + await ctx.OkAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs new file mode 100644 index 0000000..2f0b740 --- /dev/null +++ b/src/EllieBot/Modules/Expressions/EllieExpressionsService.cs @@ -0,0 +1,801 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Common.Yml; +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Runtime.CompilerServices; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Permissions.Services; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Modules.EllieExpressions; + +public sealed class EllieExpressionsService : IExecOnMessage, IReadyExecutor +{ + private const string MENTION_PH = "%bot.mention%"; + + private const string PREPEND_EXPORT = + """ + # Keys are triggers, Each key has a LIST of expressions in the following format: + # - res: Response string + # id: Alphanumeric id used for commands related to the expression. (Note, when using .exprsimport, a new id will be generated.) + # react: + # - + # at: Whether expression allows targets (see .h .exprat) + # ca: Whether expression expects trigger anywhere (see .h .exprca) + # dm: Whether expression DMs the response (see .h .exprdm) + # ad: Whether expression automatically deletes triggering message (see .h .exprad) + + + """; + + private static readonly ISerializer _exportSerializer = new SerializerBuilder() + .WithEventEmitter(args + => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .ConfigureDefaultValuesHandling(DefaultValuesHandling + .OmitDefaults) + .DisableAliases() + .Build(); + + public int Priority + => 0; + + private readonly object _gexprWriteLock = new(); + + private readonly TypedKey _gexprAddedKey = new("gexpr.added"); + private readonly TypedKey _gexprDeletedkey = new("gexpr.deleted"); + private readonly TypedKey _gexprEditedKey = new("gexpr.edited"); + private readonly TypedKey _exprsReloadedKey = new("exprs.reloaded"); + + // it is perfectly fine to have global expressions as an array + // 1. expressions are almost never added (compared to how many times they are being looped through) + // 2. only need write locks for this as we'll rebuild+replace the array on every edit + // 3. there's never many of them (at most a thousand, usually < 100) + private EllieExpression[] globalExpressions = Array.Empty(); + private ConcurrentDictionary newguildExpressions = new(); + + private readonly DbService _db; + + private readonly DiscordSocketClient _client; + + // private readonly PermissionService _perms; + // private readonly GlobalPermissionService _gperm; + // private readonly CmdCdService _cmdCds; + private readonly IPermissionChecker _permChecker; + private readonly ICommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IBot _bot; + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly IReplacementService _repSvc; + private readonly Random _rng; + + private bool ready; + private ConcurrentHashSet _disabledGlobalExpressionGuilds; + private readonly PermissionService _pc; + + public EllieExpressionsService( + DbService db, + IBotStrings strings, + IBot bot, + DiscordSocketClient client, + ICommandHandler cmd, + IPubSub pubSub, + IMessageSenderService sender, + IReplacementService repSvc, + IPermissionChecker permChecker, + PermissionService pc) + { + _db = db; + _client = client; + _cmd = cmd; + _strings = strings; + _bot = bot; + _pubSub = pubSub; + _sender = sender; + _repSvc = repSvc; + _permChecker = permChecker; + _pc = pc; + _rng = new EllieRandom(); + + _pubSub.Sub(_exprsReloadedKey, OnExprsShouldReload); + pubSub.Sub(_gexprAddedKey, OnGexprAdded); + pubSub.Sub(_gexprDeletedkey, OnGexprDeleted); + pubSub.Sub(_gexprEditedKey, OnGexprEdited); + + bot.JoinedGuild += OnJoinedGuild; + _client.LeftGuild += OnLeftGuild; + } + + private async Task ReloadInternal(IReadOnlyList allGuildIds) + { + await using var uow = _db.GetDbContext(); + var guildItems = await uow.Set() + .AsNoTracking() + .Where(x => allGuildIds.Contains(x.GuildId.Value)) + .ToListAsync(); + + newguildExpressions = guildItems.GroupBy(k => k.GuildId!.Value) + .ToDictionary(g => g.Key, + g => g.Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, + _client.CurrentUser.Mention); + return x; + }) + .ToArray()) + .ToConcurrent(); + + _disabledGlobalExpressionGuilds = new(await uow.Set() + .Where(x => x.DisableGlobalExpressions) + .Select(x => x.GuildId) + .ToListAsyncLinqToDB()); + + lock (_gexprWriteLock) + { + var globalItems = uow.Set() + .AsNoTracking() + .Where(x => x.GuildId == null || x.GuildId == 0) + .Where(x => x.Trigger != null) + .AsEnumerable() + .Select(x => + { + x.Trigger = x.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + return x; + }) + .ToArray(); + + globalExpressions = globalItems; + } + + ready = true; + } + + private EllieExpression TryGetExpression(IUserMessage umsg) + { + if (!ready) + return null; + + if (umsg.Channel is not SocketTextChannel channel) + return null; + + var content = umsg.Content.Trim().ToLowerInvariant(); + + if (newguildExpressions.TryGetValue(channel.Guild.Id, out var expressions) && expressions.Length > 0) + { + var expr = MatchExpressions(content, expressions); + if (expr is not null) + return expr; + } + + if (_disabledGlobalExpressionGuilds.Contains(channel.Guild.Id)) + return null; + + var localGrs = globalExpressions; + + return MatchExpressions(content, localGrs); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private EllieExpression MatchExpressions(in ReadOnlySpan content, EllieExpression[] exprs) + { + var result = new List(1); + for (var i = 0; i < exprs.Length; i++) + { + var expr = exprs[i]; + var trigger = expr.Trigger; + if (content.Length > trigger.Length) + { + // if input is greater than the trigger, it can only work if: + // it has CA enabled + if (expr.ContainsAnywhere) + { + // if ca is enabled, we have to check if it is a word within the content + var wp = content.GetWordPosition(trigger); + + // if it is, then that's valid + if (wp != WordPosition.None) + result.Add(expr); + + // if it's not, then it cant' work under any circumstance, + // because content is greater than the trigger length + // so it can't be equal, and it's not contained as a word + continue; + } + + // if CA is disabled, and expr has AllowTarget, then the + // content has to start with the trigger followed by a space + if (expr.AllowTarget + && content.StartsWith(trigger, StringComparison.OrdinalIgnoreCase) + && content[trigger.Length] == ' ') + result.Add(expr); + } + else if (content.Length < expr.Trigger.Length) + { + // if input length is less than trigger length, it means + // that the reaction can never be triggered + } + else + { + // if input length is the same as trigger length + // reaction can only trigger if the strings are equal + if (content.SequenceEqual(expr.Trigger)) + result.Add(expr); + } + } + + if (result.Count == 0) + return null; + + var cancelled = result.FirstOrDefault(x => x.Response == "-"); + if (cancelled is not null) + return cancelled; + + return result[_rng.Next(0, result.Count)]; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + // maybe this message is an expression + var expr = TryGetExpression(msg); + + if (expr is null || expr.Response == "-") + return false; + + try + { + if (guild is SocketGuild sg) + { + var result = await _permChecker.CheckPermsAsync( + guild, + msg.Channel, + msg.Author, + "ACTUALEXPRESSIONS", + expr.Trigger + ); + + if (!result.IsAllowed) + { + var cache = _pc.GetCacheFor(guild.Id); + if (cache.Verbose) + { + if (result.TryPickT3(out var disallowed, out _)) + { + var permissionMessage = _strings.GetText(strs.perm_prevent(disallowed.PermIndex + 1, + Format.Bold(disallowed.PermText)), + sg.Id); + + try + { + await _sender.Response(msg.Channel) + .Error(permissionMessage) + .SendAsync(); + } + catch + { + } + + Log.Information("{PermissionMessage}", permissionMessage); + } + } + + return true; + } + } + + var sentMsg = await expr.Send(msg, _repSvc, _client, _sender); + + var reactions = expr.GetReactions(); + foreach (var reaction in reactions) + { + try + { + await sentMsg.AddReactionAsync(reaction.ToIEmote()); + } + catch + { + Log.Warning("Unable to add reactions to message {Message} in server {GuildId}", + sentMsg.Id, + expr.GuildId); + break; + } + + await Task.Delay(1000); + } + + if (expr.AutoDeleteTrigger) + { + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + + Log.Information("s: {GuildId} c: {ChannelId} u: {UserId} | {UserName} executed expression {Expr}", + guild.Id, + msg.Channel.Id, + msg.Author.Id, + msg.Author.ToString(), + expr.Trigger); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in Expression RunBehavior: {ErrorMessage}", ex.Message); + } + + return false; + } + + public async Task ResetExprReactions(ulong? maybeGuildId, int id) + { + EllieExpression expr; + await using var uow = _db.GetDbContext(); + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Empty; + + await uow.SaveChangesAsync(); + } + + private Task UpdateInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + UpdateInternal(guildId, expr); + else + return _pubSub.Pub(_gexprEditedKey, expr); + + return Task.CompletedTask; + } + + private void UpdateInternal(ulong? maybeGuildId, EllieExpression expr) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + [expr], + (_, old) => + { + var newArray = old.ToArray(); + for (var i = 0; i < newArray.Length; i++) + { + if (newArray[i].Id == expr.Id) + newArray[i] = expr; + } + + return newArray; + }); + } + else + { + lock (_gexprWriteLock) + { + var exprs = globalExpressions; + for (var i = 0; i < exprs.Length; i++) + { + if (exprs[i].Id == expr.Id) + exprs[i] = expr; + } + } + } + } + + private Task AddInternalAsync(ulong? maybeGuildId, EllieExpression expr) + { + // only do this for perf purposes + expr.Trigger = expr.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention); + + if (maybeGuildId is { } guildId) + newguildExpressions.AddOrUpdate(guildId, [expr], (_, old) => old.With(expr)); + else + return _pubSub.Pub(_gexprAddedKey, expr); + + return Task.CompletedTask; + } + + private Task DeleteInternalAsync(ulong? maybeGuildId, int id) + { + if (maybeGuildId is { } guildId) + { + newguildExpressions.AddOrUpdate(guildId, + Array.Empty(), + (key, old) => DeleteInternal(old, id, out _)); + + return Task.CompletedTask; + } + + lock (_gexprWriteLock) + { + var expr = Array.Find(globalExpressions, item => item.Id == id); + if (expr is not null) + return _pubSub.Pub(_gexprDeletedkey, expr.Id); + } + + return Task.CompletedTask; + } + + private EllieExpression[] DeleteInternal( + IReadOnlyList exprs, + int id, + out EllieExpression deleted) + { + deleted = null; + if (exprs is null || exprs.Count == 0) + return exprs as EllieExpression[] ?? exprs?.ToArray(); + + var newExprs = new EllieExpression[exprs.Count - 1]; + for (int i = 0, k = 0; i < exprs.Count; i++, k++) + { + if (exprs[i].Id == id) + { + deleted = exprs[i]; + k--; + continue; + } + + newExprs[k] = exprs[i]; + } + + return newExprs; + } + + public async Task SetExprReactions(ulong? guildId, int id, IEnumerable emojis) + { + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + if (expr is null) + return; + + expr.Reactions = string.Join("@@@", emojis); + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + } + + public async Task<(bool Sucess, bool NewValue)> ToggleExprOptionAsync(ulong? guildId, int id, ExprField field) + { + var newVal = false; + EllieExpression expr; + await using (var uow = _db.GetDbContext()) + { + expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return (false, false); + if (field == ExprField.AutoDelete) + newVal = expr.AutoDeleteTrigger = !expr.AutoDeleteTrigger; + else if (field == ExprField.ContainsAnywhere) + newVal = expr.ContainsAnywhere = !expr.ContainsAnywhere; + else if (field == ExprField.DmResponse) + newVal = expr.DmResponse = !expr.DmResponse; + else if (field == ExprField.AllowTarget) + newVal = expr.AllowTarget = !expr.AllowTarget; + + await uow.SaveChangesAsync(); + } + + await UpdateInternalAsync(guildId, expr); + + return (true, newVal); + } + + public EllieExpression GetExpression(ulong? guildId, int id) + { + using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + if (expr is null || expr.GuildId != guildId) + return null; + + return expr; + } + + public int DeleteAllExpressions(ulong guildId) + { + using var uow = _db.GetDbContext(); + var count = uow.Set().ClearFromGuild(guildId); + uow.SaveChanges(); + + newguildExpressions.TryRemove(guildId, out _); + + return count; + } + + public bool ExpressionExists(ulong? guildId, string input) + { + input = input.ToLowerInvariant(); + + var gexprs = globalExpressions; + foreach (var t in gexprs) + { + if (t.Trigger == input) + return true; + } + + if (guildId is ulong gid && newguildExpressions.TryGetValue(gid, out var guildExprs)) + { + foreach (var t in guildExprs) + { + if (t.Trigger == input) + return true; + } + } + + return false; + } + + public string ExportExpressions(ulong? guildId) + { + var exprs = GetExpressionsFor(guildId); + + var exprsDict = exprs.GroupBy(x => x.Trigger).ToDictionary(x => x.Key, x => x.Select(ExportedExpr.FromModel)); + + return PREPEND_EXPORT + _exportSerializer.Serialize(exprsDict).UnescapeUnicodeCodePoints(); + } + + public async Task ImportExpressionsAsync(ulong? guildId, string input) + { + Dictionary> data; + try + { + data = Yaml.Deserializer.Deserialize>>(input); + if (data.Sum(x => x.Value.Count) == 0) + return false; + } + catch + { + return false; + } + + await using var uow = _db.GetDbContext(); + foreach (var entry in data) + { + var trigger = entry.Key; + await uow.Set() + .AddRangeAsync(entry.Value + .Where(expr => !string.IsNullOrWhiteSpace(expr.Res)) + .Select(expr => new EllieExpression + { + GuildId = guildId, + Response = expr.Res, + Reactions = expr.React?.Join("@@@"), + Trigger = trigger, + AllowTarget = expr.At, + ContainsAnywhere = expr.Ca, + DmResponse = expr.Dm, + AutoDeleteTrigger = expr.Ad + })); + } + + await uow.SaveChangesAsync(); + await TriggerReloadExpressions(); + return true; + } + + #region Event Handlers + + public async Task OnReadyAsync() + => await OnExprsShouldReload(true); + + private ValueTask OnExprsShouldReload(bool _) + => new(ReloadInternal(_bot.GetCurrentGuildIds())); + + private ValueTask OnGexprAdded(EllieExpression c) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = new EllieExpression[globalExpressions.Length + 1]; + Array.Copy(globalExpressions, newGlobalReactions, globalExpressions.Length); + newGlobalReactions[globalExpressions.Length] = c; + globalExpressions = newGlobalReactions; + } + + return default; + } + + private ValueTask OnGexprEdited(EllieExpression c) + { + lock (_gexprWriteLock) + { + for (var i = 0; i < globalExpressions.Length; i++) + { + if (globalExpressions[i].Id == c.Id) + { + globalExpressions[i] = c; + return default; + } + } + + // if edited expr is not found?! + // add it + OnGexprAdded(c); + } + + return default; + } + + private ValueTask OnGexprDeleted(int id) + { + lock (_gexprWriteLock) + { + var newGlobalReactions = DeleteInternal(globalExpressions, id, out _); + globalExpressions = newGlobalReactions; + } + + return default; + } + + public Task TriggerReloadExpressions() + => _pubSub.Pub(_exprsReloadedKey, true); + + #endregion + + #region Client Event Handlers + + private Task OnLeftGuild(SocketGuild arg) + { + newguildExpressions.TryRemove(arg.Id, out _); + + return Task.CompletedTask; + } + + private async Task OnJoinedGuild(GuildConfig gc) + { + await using var uow = _db.GetDbContext(); + var exprs = await uow.Set().AsNoTracking().Where(x => x.GuildId == gc.GuildId).ToArrayAsync(); + + newguildExpressions[gc.GuildId] = exprs; + } + + #endregion + + #region Basic Operations + + public async Task AddAsync( + ulong? guildId, + string key, + string message, + bool ca = false, + bool ad = false, + bool dm = false) + { + key = key.ToLowerInvariant(); + var expr = new EllieExpression + { + GuildId = guildId, + Trigger = key, + Response = message, + ContainsAnywhere = ca, + AutoDeleteTrigger = ad, + DmResponse = dm + }; + + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(expr); + await uow.SaveChangesAsync(); + } + + await AddInternalAsync(guildId, expr); + + return expr; + } + + public async Task EditAsync( + ulong? guildId, + int id, + string message, + bool? ca = null, + bool? ad = null, + bool? dm = null) + { + await using var uow = _db.GetDbContext(); + var expr = uow.Set().GetById(id); + + if (expr is null || expr.GuildId != guildId) + return null; + + // disable allowtarget if message had target, but it was removed from it + if (!message.Contains("%target%", StringComparison.OrdinalIgnoreCase) + && expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = false; + + expr.Response = message; + + // enable allow target if message is edited to contain target + if (expr.Response.Contains("%target%", StringComparison.OrdinalIgnoreCase)) + expr.AllowTarget = true; + + expr.ContainsAnywhere = ca ?? expr.ContainsAnywhere; + expr.AutoDeleteTrigger = ad ?? expr.AutoDeleteTrigger; + expr.DmResponse = dm ?? expr.DmResponse; + + await uow.SaveChangesAsync(); + await UpdateInternalAsync(guildId, expr); + + return expr; + } + + + public async Task DeleteAsync(ulong? guildId, int id) + { + await using var uow = _db.GetDbContext(); + var toDelete = uow.Set().GetById(id); + + if (toDelete is null) + return null; + + if ((toDelete.IsGlobal() && guildId is null) || guildId == toDelete.GuildId) + { + uow.Set().Remove(toDelete); + await uow.SaveChangesAsync(); + await DeleteInternalAsync(guildId, id); + return toDelete; + } + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public EllieExpression[] GetExpressionsFor(ulong? maybeGuildId) + { + if (maybeGuildId is { } guildId) + return newguildExpressions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty(); + + return globalExpressions; + } + + #endregion + + public async Task ToggleGlobalExpressionsAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, set => set); + var toReturn = gc.DisableGlobalExpressions = !gc.DisableGlobalExpressions; + await ctx.SaveChangesAsync(); + + if (toReturn) + _disabledGlobalExpressionGuilds.Add(guildId); + else + _disabledGlobalExpressionGuilds.TryRemove(guildId); + + return toReturn; + } + + + public async Task<(IReadOnlyCollection Exprs, int TotalCount)> FindExpressionsAsync( + ulong guildId, + string query, + int page) + { + await using var ctx = _db.GetDbContext(); + + if (newguildExpressions.TryGetValue(guildId, out var exprs)) + { + return (exprs.Where(x => x.Trigger.Contains(query)) + .Skip(page * 9) + .Take(9) + .ToArray(), exprs.Length); + } + + return ([], 0); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExportedExpr.cs b/src/EllieBot/Modules/Expressions/ExportedExpr.cs new file mode 100644 index 0000000..c45fbdc --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExportedExpr.cs @@ -0,0 +1,27 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.EllieExpressions; + +public class ExportedExpr +{ + public string Res { get; set; } + public string Id { get; set; } + public bool Ad { get; set; } + public bool Dm { get; set; } + public bool At { get; set; } + public bool Ca { get; set; } + public string[] React; + + public static ExportedExpr FromModel(EllieExpression cr) + => new() + { + Res = cr.Response, + Id = ((kwum)cr.Id).ToString(), + Ad = cr.AutoDeleteTrigger, + At = cr.AllowTarget, + Ca = cr.ContainsAnywhere, + Dm = cr.DmResponse, + React = string.IsNullOrWhiteSpace(cr.Reactions) ? null : cr.GetReactions() + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/ExprField.cs b/src/EllieBot/Modules/Expressions/ExprField.cs new file mode 100644 index 0000000..9b9fa2f --- /dev/null +++ b/src/EllieBot/Modules/Expressions/ExprField.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.EllieExpressions; + +public enum ExprField +{ + AutoDelete, + DmResponse, + AllowTarget, + ContainsAnywhere, + Message +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs new file mode 100644 index 0000000..716735e --- /dev/null +++ b/src/EllieBot/Modules/Expressions/TypeReaders/CommandOrExprTypeReader.cs @@ -0,0 +1,33 @@ +#nullable disable +using EllieBot.Modules.EllieExpressions; + +namespace EllieBot.Common.TypeReaders; + +public sealed class CommandOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _commandHandler; + private readonly EllieExpressionsService _exprs; + + public CommandOrExprTypeReader(CommandService cmds, EllieExpressionsService exprs, ICommandHandler commandHandler) + { + _cmds = cmds; + _exprs = exprs; + _commandHandler = commandHandler; + } + + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (_exprs.ExpressionExists(ctx.Guild?.Id, input)) + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(input, CommandOrExprInfo.Type.Custom)); + + var cmd = await new CommandTypeReader(_commandHandler, _cmds).ReadAsync(ctx, input); + if (cmd.IsSuccess) + { + return TypeReaderResult.FromSuccess(new CommandOrExprInfo(((CommandInfo)cmd.Values.First().Value).Name, + CommandOrExprInfo.Type.Normal)); + } + + return TypeReaderResult.FromError(CommandError.ParseFailed, "No such command or expression found."); + } +} \ No newline at end of file -- 2.43.0 From 922f726a6e029efd1c3df66d8f21721f356b9a62 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:50:45 +1200 Subject: [PATCH 022/340] Added Gambling module --- .../Gambling/AnimalRacing/AnimalRace.cs | 153 +++ .../AnimalRacing/AnimalRaceService.cs | 9 + .../AnimalRacing/AnimalRacingCommands.cs | 197 ++++ .../Gambling/AnimalRacing/AnimalRacingUser.cs | 26 + .../Exceptions/AlreadyJoinedException.cs | 19 + .../Exceptions/AlreadyStartedException.cs | 19 + .../Exceptions/AnimalRaceFullException.cs | 19 + .../Exceptions/NotEnoughFundsException.cs | 19 + .../Gambling/AnimalRacing/RaceOptions.cs | 16 + .../Modules/Gambling/Bank/BankCommands.cs | 118 ++ .../Modules/Gambling/Bank/BankService.cs | 115 ++ .../Gambling/BlackJack/BlackJackCommands.cs | 183 +++ .../Gambling/BlackJack/BlackJackService.cs | 9 + .../Modules/Gambling/BlackJack/Blackjack.cs | 329 ++++++ .../Modules/Gambling/BlackJack/Player.cs | 57 + .../Modules/Gambling/Connect4/Connect4.cs | 409 +++++++ .../Gambling/Connect4/Connect4Commands.cs | 205 ++++ .../Modules/Gambling/CurrencyProvider.cs | 16 + .../Gambling/DiceRoll/DiceRollCommands.cs | 224 ++++ .../Modules/Gambling/Draw/DrawCommands.cs | 234 ++++ .../Modules/Gambling/EconomyResult.cs | 12 + .../Gambling/Events/CurrencyEventsCommands.cs | 60 + .../Gambling/Events/CurrencyEventsService.cs | 70 ++ .../Modules/Gambling/Events/EventOptions.cs | 39 + .../Gambling/Events/GameStatusEvent.cs | 198 ++++ .../Modules/Gambling/Events/ICurrencyEvent.cs | 9 + .../Modules/Gambling/Events/ReactionEvent.cs | 197 ++++ .../Gambling/FlipCoin/FlipCoinCommands.cs | 140 +++ .../Modules/Gambling/FlipCoin/FlipResult.cs | 7 + src/EllieBot/Modules/Gambling/Gambling.cs | 1032 +++++++++++++++++ .../Modules/Gambling/GamblingConfig.cs | 404 +++++++ .../Modules/Gambling/GamblingConfigService.cs | 194 ++++ .../Modules/Gambling/GamblingService.cs | 220 ++++ .../Gambling/GamblingTopLevelModule.cs | 68 ++ src/EllieBot/Modules/Gambling/InputRpsPick.cs | 3 + .../PlantPick/PlantAndPickCommands.cs | 114 ++ .../Gambling/PlantPick/PlantPickService.cs | 385 ++++++ .../Gambling/Raffle/CurrencyRaffleCommands.cs | 61 + .../Gambling/Raffle/CurrencyRaffleGame.cs | 69 ++ .../Gambling/Raffle/CurrencyRaffleService.cs | 81 ++ .../Modules/Gambling/Shop/IShopService.cs | 46 + .../Modules/Gambling/Shop/ShopCommands.cs | 590 ++++++++++ .../Modules/Gambling/Shop/ShopService.cs | 127 ++ .../Modules/Gambling/Slot/SlotCommands.cs | 230 ++++ .../Modules/Gambling/VoteRewardService.cs | 106 ++ .../Gambling/Waifus/WaifuClaimCommands.cs | 393 +++++++ .../Modules/Gambling/Waifus/WaifuService.cs | 582 ++++++++++ .../Gambling/Waifus/_common/AffinityTitle.cs | 16 + .../Gambling/Waifus/_common/ClaimTitle.cs | 18 + .../Gambling/Waifus/_common/DivorceResult.cs | 10 + .../Gambling/Waifus/_common/Extensions.cs | 6 + .../Waifus/_common/WaifuClaimResult.cs | 9 + .../Modules/Gambling/Waifus/db/Waifu.cs | 17 + .../Gambling/Waifus/db/WaifuExtensions.cs | 131 +++ .../Gambling/Waifus/db/WaifuInfoStats.cs | 14 + .../Modules/Gambling/Waifus/db/WaifuItem.cs | 10 + .../Gambling/Waifus/db/WaifuLbResult.cs | 16 + .../Modules/Gambling/Waifus/db/WaifuUpdate.cs | 15 + .../Gambling/Waifus/db/WaifuUpdateType.cs | 8 + .../Gambling/_common/Decks/QuadDeck.cs | 19 + .../_common/GamblingCleanupService.cs | 56 + .../_common/IGamblingCleanupService.cs | 8 + .../Gambling/_common/IGamblingService.cs | 18 + .../Gambling/_common/NewGamblingService.cs | 269 +++++ .../Modules/Gambling/_common/RollDuelGame.cs | 139 +++ .../BaseShmartInputAmountReader.cs | 95 ++ .../ShmartBankInputAmountReader.cs | 21 + .../TypeReaders/ShmartNumberTypeReader.cs | 57 + 68 files changed, 8765 insertions(+) create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs create mode 100644 src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs create mode 100644 src/EllieBot/Modules/Gambling/Bank/BankCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Bank/BankService.cs create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs create mode 100644 src/EllieBot/Modules/Gambling/BlackJack/Player.cs create mode 100644 src/EllieBot/Modules/Gambling/Connect4/Connect4.cs create mode 100644 src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs create mode 100644 src/EllieBot/Modules/Gambling/CurrencyProvider.cs create mode 100644 src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/EconomyResult.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/EventOptions.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs create mode 100644 src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs create mode 100644 src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs create mode 100644 src/EllieBot/Modules/Gambling/Gambling.cs create mode 100644 src/EllieBot/Modules/Gambling/GamblingConfig.cs create mode 100644 src/EllieBot/Modules/Gambling/GamblingConfigService.cs create mode 100644 src/EllieBot/Modules/Gambling/GamblingService.cs create mode 100644 src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs create mode 100644 src/EllieBot/Modules/Gambling/InputRpsPick.cs create mode 100644 src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs create mode 100644 src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs create mode 100644 src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs create mode 100644 src/EllieBot/Modules/Gambling/Shop/IShopService.cs create mode 100644 src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Shop/ShopService.cs create mode 100644 src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/VoteRewardService.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs create mode 100644 src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/IGamblingService.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs create mode 100644 src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs new file mode 100644 index 0000000..84f10d4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRace.cs @@ -0,0 +1,153 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public sealed class AnimalRace : IDisposable +{ + public enum Phase + { + WaitingForPlayers, + Running, + Ended + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + public event Func OnStartingFailed = delegate { return Task.CompletedTask; }; + public event Func OnStateUpdate = delegate { return Task.CompletedTask; }; + public event Func OnEnded = delegate { return Task.CompletedTask; }; + + public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers; + + public IReadOnlyCollection Users + => _users.ToList(); + + public List FinishedUsers { get; } = new(); + public int MaxUsers { get; } + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly HashSet _users = new(); + private readonly ICurrencyService _currency; + private readonly RaceOptions _options; + private readonly Queue _animalsQueue; + + public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable availableAnimals) + { + _currency = currency; + _options = options; + _animalsQueue = new(availableAnimals); + MaxUsers = _animalsQueue.Count; + + if (_animalsQueue.Count == 0) + CurrentPhase = Phase.Ended; + } + + public void Initialize() //lame name + => _ = Task.Run(async () => + { + await Task.Delay(_options.StartTime * 1000); + + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.WaitingForPlayers) + return; + + await Start(); + } + finally { _locker.Release(); } + }); + + public async Task JoinRace(ulong userId, string userName, long bet = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(bet); + + var user = new AnimalRacingUser(userName, userId, bet); + + await _locker.WaitAsync(); + try + { + if (_users.Count == MaxUsers) + throw new AnimalRaceFullException(); + + if (CurrentPhase != Phase.WaitingForPlayers) + throw new AlreadyStartedException(); + + if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet"))) + throw new NotEnoughFundsException(); + + if (_users.Contains(user)) + throw new AlreadyJoinedException(); + + var animal = _animalsQueue.Dequeue(); + user.Animal = animal; + _users.Add(user); + + if (_animalsQueue.Count == 0) //start if no more spots left + await Start(); + + return user; + } + finally { _locker.Release(); } + } + + private async Task Start() + { + CurrentPhase = Phase.Running; + if (_users.Count <= 1) + { + foreach (var user in _users) + { + if (user.Bet > 0) + await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund")); + } + + _ = OnStartingFailed?.Invoke(this); + CurrentPhase = Phase.Ended; + return; + } + + _ = OnStarted?.Invoke(this); + _ = Task.Run(async () => + { + var rng = new EllieRandom(); + while (!_users.All(x => x.Progress >= 60)) + { + foreach (var user in _users) + { + user.Progress += rng.Next(1, 11); + if (user.Progress >= 60) + user.Progress = 60; + } + + var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle(); + + FinishedUsers.AddRange(finished); + + _ = OnStateUpdate?.Invoke(this); + await Task.Delay(2500); + } + + if (FinishedUsers[0].Bet > 0) + { + await _currency.AddAsync(FinishedUsers[0].UserId, + FinishedUsers[0].Bet * (_users.Count - 1), + new("animalrace", "win")); + } + + _ = OnEnded?.Invoke(this); + }); + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnStartingFailed = null; + OnStateUpdate = null; + _locker.Dispose(); + _users.Clear(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs new file mode 100644 index 0000000..f4c99a8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRaceService.cs @@ -0,0 +1,9 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.AnimalRacing; + +namespace EllieBot.Modules.Gambling.Services; + +public class AnimalRaceService : IEService +{ + public ConcurrentDictionary AnimalRaces { get; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs new file mode 100644 index 0000000..1f23cf6 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingCommands.cs @@ -0,0 +1,197 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.AnimalRacing; +using EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Gambling; + +// wth is this, needs full rewrite +public partial class Gambling +{ + [Group] + public partial class AnimalRacingCommands : GamblingSubmodule + { + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly GamesConfigService _gamesConf; + + private IUserMessage raceMessage; + + public AnimalRacingCommands( + ICurrencyService cs, + DiscordSocketClient client, + GamblingConfigService gamblingConf, + GamesConfigService gamesConf) + : base(gamblingConf) + { + _cs = cs; + _client = client; + _gamesConf = gamesConf; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public Task Race(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args); + + var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle()); + if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar)) + return Response() + .Error(GetText(strs.animal_race), GetText(strs.animal_race_already_started)) + .SendAsync(); + + ar.Initialize(); + + var count = 0; + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(() => + { + try + { + if (arg.Channel.Id == ctx.Channel.Id) + { + if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0) + raceMessage = null; + } + } + catch { } + }); + return Task.CompletedTask; + } + + Task ArOnEnded(AnimalRace race) + { + _client.MessageReceived -= ClientMessageReceived; + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + var winner = race.FinishedUsers[0]; + if (race.FinishedUsers[0].Bet > 0) + { + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_won_money(Format.Bold(winner.Username), + winner.Animal.Icon, + (race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign))) + .SendAsync(); + } + + ar.Dispose(); + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon))) + .SendAsync(); + } + + ar.OnStartingFailed += Ar_OnStartingFailed; + ar.OnStateUpdate += Ar_OnStateUpdate; + ar.OnEnded += ArOnEnded; + ar.OnStarted += Ar_OnStarted; + _client.MessageReceived += ClientMessageReceived; + + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_starting(options.StartTime)), + footer: GetText(strs.animal_race_join_instr(prefix))) + .SendAsync(); + } + + private Task Ar_OnStarted(AnimalRace race) + { + if (race.Users.Count == race.MaxUsers) + return Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync(); + return Response() + .Confirm(GetText(strs.animal_race), + GetText(strs.animal_race_starting_with_x(race.Users.Count))) + .SendAsync(); + } + + private async Task Ar_OnStateUpdate(AnimalRace race) + { + var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚| +{string.Join("\n", race.Users.Select(p => +{ + var index = race.FinishedUsers.IndexOf(p); + var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}"; + return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}"; +}))} +|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|"; + + var msg = raceMessage; + + if (msg is null) + raceMessage = await Response().Confirm(text).SendAsync(); + else + { + await msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.animal_race)) + .WithDescription(text) + .WithOkColor() + .Build()); + } + } + + private Task Ar_OnStartingFailed(AnimalRace race) + { + _service.AnimalRaces.TryRemove(ctx.Guild.Id, out _); + race.Dispose(); + return Response().Error(strs.animal_race_failed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default) + { + if (!await CheckBetOptional(amount)) + return; + + if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar)) + { + await Response().Error(strs.race_not_exist).SendAsync(); + return; + } + + try + { + var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount); + if (amount > 0) + { + await Response() + .Confirm(GetText(strs.animal_race_join_bet(ctx.User.Mention, + user.Animal.Icon, + amount + CurrencySign))) + .SendAsync(); + } + else + await Response() + .Confirm(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)) + .SendAsync(); + } + catch (ArgumentOutOfRangeException) + { + //ignore if user inputed an invalid amount + } + catch (AlreadyJoinedException) + { + // just ignore this + } + catch (AlreadyStartedException) + { + //ignore + } + catch (AnimalRaceFullException) + { + await Response().Confirm(GetText(strs.animal_race), GetText(strs.animal_race_full)).SendAsync(); + } + catch (NotEnoughFundsException) + { + await Response().Error(GetText(strs.not_enough(CurrencySign))).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs new file mode 100644 index 0000000..814b475 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/AnimalRacingUser.cs @@ -0,0 +1,26 @@ +#nullable disable +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public class AnimalRacingUser +{ + public long Bet { get; } + public string Username { get; } + public ulong UserId { get; } + public RaceAnimal Animal { get; set; } + public int Progress { get; set; } + + public AnimalRacingUser(string username, ulong userId, long bet) + { + Bet = bet; + Username = username; + UserId = userId; + } + + public override bool Equals(object obj) + => obj is AnimalRacingUser x ? x.UserId == UserId : false; + + public override int GetHashCode() + => UserId.GetHashCode(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs new file mode 100644 index 0000000..914b6a4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyJoinedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyJoinedException : Exception +{ + public AlreadyJoinedException() + { + } + + public AlreadyJoinedException(string message) + : base(message) + { + } + + public AlreadyJoinedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs new file mode 100644 index 0000000..e662785 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AlreadyStartedException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AlreadyStartedException : Exception +{ + public AlreadyStartedException() + { + } + + public AlreadyStartedException(string message) + : base(message) + { + } + + public AlreadyStartedException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs new file mode 100644 index 0000000..9a76b5b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/AnimalRaceFullException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class AnimalRaceFullException : Exception +{ + public AnimalRaceFullException() + { + } + + public AnimalRaceFullException(string message) + : base(message) + { + } + + public AnimalRaceFullException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs new file mode 100644 index 0000000..b827761 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/Exceptions/NotEnoughFundsException.cs @@ -0,0 +1,19 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.AnimalRacing.Exceptions; + +public class NotEnoughFundsException : Exception +{ + public NotEnoughFundsException() + { + } + + public NotEnoughFundsException(string message) + : base(message) + { + } + + public NotEnoughFundsException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs new file mode 100644 index 0000000..fb0f8c9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/AnimalRacing/RaceOptions.cs @@ -0,0 +1,16 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Gambling.Common.AnimalRacing; + +public class RaceOptions : IEllieCommandOptions +{ + [Option('s', "start-time", Default = 20, Required = false)] + public int StartTime { get; set; } = 20; + + public void NormalizeOptions() + { + if (StartTime is < 10 or > 120) + StartTime = 20; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs new file mode 100644 index 0000000..91388ef --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Bank/BankCommands.cs @@ -0,0 +1,118 @@ +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Name("Bank")] + [Group("bank")] + public partial class BankCommands : GamblingModule + { + private readonly IBankService _bank; + private readonly DiscordSocketClient _client; + + public BankCommands(GamblingConfigService gcs, + IBankService bank, + DiscordSocketClient client) : base(gcs) + { + _bank = bank; + _client = client; + } + + [Cmd] + public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.DepositAsync(ctx.User.Id, amount)) + { + await Response().Confirm(strs.bank_deposited(N(amount))).SendAsync(); + } + else + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } + + [Cmd] + public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount) + { + if (amount <= 0) + return; + + if (await _bank.WithdrawAsync(ctx.User.Id, amount)) + { + await Response().Confirm(strs.bank_withdrew(N(amount))).SendAsync(); + } + else + { + await Response().Error(strs.bank_withdraw_insuff(CurrencySign)).SendAsync(); + } + } + + [Cmd] + public async Task BankBalance() + { + var bal = await _bank.GetBalanceAsync(ctx.User.Id); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText(strs.bank_balance(N(bal)))); + + try + { + await Response().User(ctx.User).Embed(eb).SendAsync(); + await ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } + + private async Task BankTakeInternalAsync(long amount, ulong userId) + { + if (await _bank.TakeAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + await Response().Error(strs.take_fail(N(amount), + _client.GetUser(userId)?.ToString() + ?? userId.ToString(), + CurrencySign)).SendAsync(); + } + + private async Task BankAwardInternalAsync(long amount, ulong userId) + { + if (await _bank.AwardAsync(userId, amount)) + { + await ctx.OkAsync(); + return; + } + + } + + [Cmd] + [OwnerOnly] + [Priority(1)] + public async Task BankTake(long amount, [Leftover] IUser user) + => await BankTakeInternalAsync(amount, user.Id); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public async Task BankTake(long amount, ulong userId) + => await BankTakeInternalAsync(amount, userId); + + [Cmd] + [OwnerOnly] + public async Task BankAward(long amount, [Leftover] IUser user) + => await BankAwardInternalAsync(amount, user.Id); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Bank/BankService.cs b/src/EllieBot/Modules/Gambling/Bank/BankService.cs new file mode 100644 index 0000000..0d75607 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Bank/BankService.cs @@ -0,0 +1,115 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Bank; + +public sealed class BankService : IBankService, IEService +{ + private readonly ICurrencyService _cur; + private readonly DbService _db; + + public BankService(ICurrencyService cur, DbService db) + { + _cur = cur; + _db = db; + } + + public async Task AwardAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task TakeAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + return rows > 0; + } + + public async Task DepositAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit"))) + return false; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + Balance = amount + }, + (old) => new() + { + Balance = old.Balance + amount + }, + () => new() + { + UserId = userId + }); + + return true; + } + + public async Task WithdrawAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set() + .ToLinqToDBTable() + .Where(x => x.UserId == userId && x.Balance >= amount) + .UpdateAsync((old) => new() + { + Balance = old.Balance - amount + }); + + if (rows > 0) + { + await _cur.AddAsync(userId, amount, new("bank", "withdraw")); + return true; + } + + return false; + } + + public async Task GetBalanceAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + return (await ctx.Set() + .ToLinqToDBTable() + .FirstOrDefaultAsync(x => x.UserId == userId)) + ?.Balance + ?? 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs new file mode 100644 index 0000000..772cb4f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackCommands.cs @@ -0,0 +1,183 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Blackjack; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + public partial class BlackJackCommands : GamblingSubmodule + { + public enum BjAction + { + Hit = int.MinValue, + Stand, + Double + } + + private readonly ICurrencyService _cs; + private readonly DbService _db; + private IUserMessage msg; + + public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf) + : base(gamblingConf) + { + _cs = cs; + _db = db; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + var newBj = new Blackjack(_cs); + Blackjack bj; + if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj))) + { + if (!await bj.Join(ctx.User, amount)) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + bj.StateUpdated += Bj_StateUpdated; + bj.GameEnded += Bj_GameEnded; + bj.Start(); + + await Response().NoReply().Confirm(strs.bj_created(ctx.User.ToString())).SendAsync(); + } + else + { + if (await bj.Join(ctx.User, amount)) + await Response().NoReply().Confirm(strs.bj_joined(ctx.User.ToString())).SendAsync(); + else + { + Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already", + ctx.User, + bj.State); + } + } + + await ctx.Message.DeleteAsync(); + } + + private Task Bj_GameEnded(Blackjack arg) + { + _service.Games.TryRemove(ctx.Channel.Id, out _); + return Task.CompletedTask; + } + + private async Task Bj_StateUpdated(Blackjack bj) + { + try + { + if (msg is not null) + _ = msg.DeleteAsync(); + + var c = bj.Dealer.Cards.Select(x => x.GetEmojiString()) + .ToList(); + var dealerIcon = "❔ "; + if (bj.State == Blackjack.GameState.Ended) + { + if (bj.Dealer.GetHandValue() == 21) + dealerIcon = "💰 "; + else if (bj.Dealer.GetHandValue() > 21) + dealerIcon = "💥 "; + else + dealerIcon = "🏁 "; + } + + var cStr = string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n" + string.Concat(c.Select(x => x.Last() + " ")); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("BlackJack") + .AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr); + + if (bj.CurrentUser is not null) + embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}"); + + foreach (var p in bj.Players) + { + c = p.Cards.Select(x => x.GetEmojiString()).ToList(); + cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " ")); + cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " ")); + var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}"; + if (bj.State == Blackjack.GameState.Ended) + { + if (p.State == User.UserState.Lost) + full = "❌ " + full; + else + full = "✅ " + full; + } + else if (p == bj.CurrentUser) + full = "▶ " + full; + else if (p.State == User.UserState.Stand) + full = "⏹ " + full; + else if (p.State == User.UserState.Bust) + full = "💥 " + full; + else if (p.State == User.UserState.Blackjack) + full = "💰 " + full; + + embed.AddField(full, cStr); + } + + msg = await Response().Embed(embed).SendAsync(); + } + catch + { + } + } + + private string UserToString(User x) + { + var playerName = x.State == User.UserState.Bust + ? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30)) + : x.DiscordUser.ToString(); + + // var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}"; + + + return $"{playerName} | Bet: {x.Bet}\n"; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Hit() + => InternalBlackJack(BjAction.Hit); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Stand() + => InternalBlackJack(BjAction.Stand); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Double() + => InternalBlackJack(BjAction.Double); + + private async Task InternalBlackJack(BjAction a) + { + if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj)) + return; + + if (a == BjAction.Hit) + await bj.Hit(ctx.User); + else if (a == BjAction.Stand) + await bj.Stand(ctx.User); + else if (a == BjAction.Double) + { + if (!await bj.Double(ctx.User)) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + + await ctx.Message.DeleteAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs new file mode 100644 index 0000000..3bfb87c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/BlackJackService.cs @@ -0,0 +1,9 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common.Blackjack; + +namespace EllieBot.Modules.Gambling.Services; + +public class BlackJackService : IEService +{ + public ConcurrentDictionary Games { get; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs new file mode 100644 index 0000000..e21d2cd --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/Blackjack.cs @@ -0,0 +1,329 @@ +#nullable disable +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common.Blackjack; + +public class Blackjack +{ + public enum GameState + { + Starting, + Playing, + Ended + } + + public event Func StateUpdated; + public event Func GameEnded; + + private Deck Deck { get; } = new QuadDeck(); + public Dealer Dealer { get; set; } + + + public List Players { get; set; } = new(); + public GameState State { get; set; } = GameState.Starting; + public User CurrentUser { get; private set; } + + private TaskCompletionSource currentUserMove; + private readonly ICurrencyService _cs; + + private readonly SemaphoreSlim _locker = new(1, 1); + + public Blackjack(ICurrencyService cs) + { + _cs = cs; + Dealer = new(); + } + + public void Start() + => _ = GameLoop(); + + public async Task GameLoop() + { + try + { + //wait for players to join + await Task.Delay(20000); + await _locker.WaitAsync(); + try + { + State = GameState.Playing; + } + finally + { + _locker.Release(); + } + + await PrintState(); + //if no users joined the game, end it + if (!Players.Any()) + { + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + return; + } + + //give 1 card to the dealer and 2 to each player + Dealer.Cards.Add(Deck.Draw()); + foreach (var usr in Players) + { + usr.Cards.Add(Deck.Draw()); + usr.Cards.Add(Deck.Draw()); + + if (usr.GetHandValue() == 21) + usr.State = User.UserState.Blackjack; + } + + //go through all users and ask them what they want to do + foreach (var usr in Players.Where(x => !x.Done)) + { + while (!usr.Done) + { + Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser); + await PromptUserMove(usr); + } + } + + await PrintState(); + State = GameState.Ended; + await Task.Delay(2500); + Log.Information("Dealer moves"); + await DealerMoves(); + await PrintState(); + _ = GameEnded?.Invoke(this); + } + catch (Exception ex) + { + Log.Error(ex, "REPORT THE MESSAGE BELOW IN Ellie's Home SERVER PLEASE"); + State = GameState.Ended; + _ = GameEnded?.Invoke(this); + } + } + + private async Task PromptUserMove(User usr) + { + using var cts = new CancellationTokenSource(); + var pause = Task.Delay(20000, cts.Token); //10 seconds to decide + CurrentUser = usr; + currentUserMove = new(); + await PrintState(); + // either wait for the user to make an action and + // if he doesn't - stand + var finished = await Task.WhenAny(pause, currentUserMove.Task); + if (finished == pause) + await Stand(usr); + else + cts.Cancel(); + + CurrentUser = null; + currentUserMove = null; + } + + public async Task Join(IUser user, long bet) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Starting) + return false; + + if (Players.Count >= 5) + return false; + + if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble"))) + return false; + + Players.Add(new(user, bet)); + _ = PrintState(); + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Stand(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Stand(cu); + + return false; + } + + public async Task Stand(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + return true; + } + finally + { + _locker.Release(); + } + } + + private async Task DealerMoves() + { + var hw = Dealer.GetHandValue(); + while (hw < 17 + || (hw == 17 + && Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17 + { + /* Dealer has + A 6 + That's 17, soft + hw == 17 => true + number of aces = 1 + 1 > 17-17 /10 => true + + AA 5 + That's 17, again soft, since one ace is worth 11, even though another one is 1 + hw == 17 => true + number of aces = 2 + 2 > 27 - 17 / 10 => true + + AA Q 5 + That's 17, but not soft, since both aces are worth 1 + hw == 17 => true + number of aces = 2 + 2 > 37 - 17 / 10 => false + * */ + Dealer.Cards.Add(Deck.Draw()); + hw = Dealer.GetHandValue(); + } + + if (hw > 21) + { + foreach (var usr in Players) + { + if (usr.State is User.UserState.Stand or User.UserState.Blackjack) + usr.State = User.UserState.Won; + else + usr.State = User.UserState.Lost; + } + } + else + { + foreach (var usr in Players) + { + if (usr.State == User.UserState.Blackjack) + usr.State = User.UserState.Won; + else if (usr.State == User.UserState.Stand) + usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost; + else + usr.State = User.UserState.Lost; + } + } + + foreach (var usr in Players) + { + if (usr.State is User.UserState.Won or User.UserState.Blackjack) + await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win")); + } + } + + public async Task Double(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Double(cu); + + return false; + } + + public async Task Double(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double"))) + return false; + + u.Bet *= 2; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + else + //with double you just get one card, and then you're done + u.State = User.UserState.Stand; + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public async Task Hit(IUser u) + { + var cu = CurrentUser; + + if (cu is not null && cu.DiscordUser == u) + return await Hit(cu); + + return false; + } + + public async Task Hit(User u) + { + await _locker.WaitAsync(); + try + { + if (State != GameState.Playing) + return false; + + if (CurrentUser != u) + return false; + + u.Cards.Add(Deck.Draw()); + + if (u.GetHandValue() == 21) + //blackjack + u.State = User.UserState.Blackjack; + else if (u.GetHandValue() > 21) + // user busted + u.State = User.UserState.Bust; + + currentUserMove.TrySetResult(true); + + return true; + } + finally + { + _locker.Release(); + } + } + + public Task PrintState() + { + if (StateUpdated is null) + return Task.CompletedTask; + return StateUpdated.Invoke(this); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/BlackJack/Player.cs b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs new file mode 100644 index 0000000..fb238c1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/BlackJack/Player.cs @@ -0,0 +1,57 @@ +#nullable disable +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common.Blackjack; + +public abstract class Player +{ + public List Cards { get; } = new(); + + public int GetHandValue() + { + var val = GetRawHandValue(); + + // while the hand value is greater than 21, for each ace you have in the deck + // reduce the value by 10 until it drops below 22 + // (emulating the fact that ace is either a 1 or a 11) + var i = Cards.Count(x => x.Number == 1); + while (val > 21 && i-- > 0) + val -= 10; + return val; + } + + public int GetRawHandValue() + => Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number); +} + +public class Dealer : Player +{ +} + +public class User : Player +{ + public enum UserState + { + Waiting, + Stand, + Bust, + Blackjack, + Won, + Lost + } + + public UserState State { get; set; } = UserState.Waiting; + public long Bet { get; set; } + public IUser DiscordUser { get; } + + public bool Done + => State != UserState.Waiting; + + public User(IUser user, long bet) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bet); + + Bet = bet; + DiscordUser = user; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs new file mode 100644 index 0000000..45d2e89 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4.cs @@ -0,0 +1,409 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Gambling.Common.Connect4; + +public sealed class Connect4Game : IDisposable +{ + public enum Field //temporary most likely + { + Empty, + P1, + P2 + } + + public enum Phase + { + Joining, // waiting for second player to join + P1Move, + P2Move, + Ended + } + + public enum Result + { + Draw, + CurrentPlayerWon, + OtherPlayerWon + } + + public const int NUMBER_OF_COLUMNS = 7; + public const int NUMBER_OF_ROWS = 6; + + //public event Func OnGameStarted; + public event Func OnGameStateUpdated; + public event Func OnGameFailedToStart; + public event Func OnGameEnded; + + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray GameState + => _gameState.ToImmutableArray(); + + public ImmutableArray<(ulong UserId, string Username)?> Players + => _players.ToImmutableArray(); + + public (ulong UserId, string Username) CurrentPlayer + => CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value; + + public (ulong UserId, string Username) OtherPlayer + => CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value; + + //state is bottom to top, left to right + private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS]; + private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2]; + + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly Options _options; + private readonly ICurrencyService _cs; + private readonly EllieRandom _rng; + + private Timer playerTimeoutTimer; + + /* [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + * [ ][ ][ ][ ][ ][ ] + */ + + public Connect4Game( + ulong userId, + string userName, + Options options, + ICurrencyService cs) + { + _players[0] = (userId, userName); + _options = options; + _cs = cs; + + _rng = new(); + for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++) + _gameState[i] = Field.Empty; + } + + public void Initialize() + { + if (CurrentPhase != Phase.Joining) + return; + _ = Task.Run(async () => + { + await Task.Delay(15000); + await _locker.WaitAsync(); + try + { + if (_players[1] is null) + { + _ = OnGameFailedToStart?.Invoke(this); + CurrentPhase = Phase.Ended; + await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund")); + } + } + finally { _locker.Release(); } + }); + } + + public async Task Join(ulong userId, string userName, int bet) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase + return false; + + if (_players[0].Value.UserId == userId) // same user can't join own game + return false; + + if (bet != _options.Bet) // can't join if bet amount is not the same + return false; + + if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble + return false; + + if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player + { + _players[1] = _players[0]; + _players[0] = (userId, userName); + } + else //else join as a second player + _players[1] = (userId, userName); + + CurrentPhase = Phase.P1Move; //start the game + playerTimeoutTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + EndGame(Result.OtherPlayerWon, OtherPlayer.UserId); + } + finally { _locker.Release(); } + }, + null, + TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + _ = OnGameStateUpdated?.Invoke(this); + + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, int inputCol) + { + await _locker.WaitAsync(); + try + { + inputCol -= 1; + if (CurrentPhase is Phase.Ended or Phase.Joining) + return false; + + if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move) + || (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move))) + return false; + + if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input + return false; + + if (IsColumnFull(inputCol)) //can't play there event? + return false; + + var start = NUMBER_OF_ROWS * inputCol; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + { + _gameState[i] = GetPlayerPiece(userId); + break; + } + } + + //check winnning condition + // ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected + + for (var i = 0; i < NUMBER_OF_ROWS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_COLUMNS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[i + (j * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[i + k + (j * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + // i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected + for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var j = 0; j < NUMBER_OF_ROWS; j++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[j + (i * NUMBER_OF_ROWS)]; + if (first != Field.Empty) + { + for (var k = 1; k < 4; k++) + { + var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)]; + if (next == first) + { + if (k == 3) + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + else + continue; + } + else + break; + } + } + } + } + + //need to check diagonal now + for (var col = 0; col < NUMBER_OF_COLUMNS; col++) + { + if (CurrentPhase == Phase.Ended) + break; + + for (var row = 0; row < NUMBER_OF_ROWS; row++) + { + if (CurrentPhase == Phase.Ended) + break; + + var first = _gameState[row + (col * NUMBER_OF_ROWS)]; + + if (first != Field.Empty) + { + var same = 1; + + //top left + for (var i = 1; i < 4; i++) + { + //while going top left, rows are increasing, columns are decreasing + var curRow = row + i; + var curCol = col - i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + + same = 1; + + //top right + for (var i = 1; i < 4; i++) + { + //while going top right, rows are increasing, columns are increasing + var curRow = row + i; + var curCol = col + i; + + //check if current values are in range + if (curRow is >= NUMBER_OF_ROWS or < 0) + break; + if (curCol is < 0 or >= NUMBER_OF_COLUMNS) + break; + + var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)]; + if (cur == first) + same++; + else + break; + } + + if (same == 4) + { + EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId); + break; + } + } + } + } + + //check draw? if it's even possible + if (_gameState.All(x => x != Field.Empty)) + EndGame(Result.Draw, null); + + if (CurrentPhase != Phase.Ended) + { + if (CurrentPhase == Phase.P1Move) + CurrentPhase = Phase.P2Move; + else + CurrentPhase = Phase.P1Move; + + ResetTimer(); + } + + _ = OnGameStateUpdated?.Invoke(this); + return true; + } + finally { _locker.Release(); } + } + + private void ResetTimer() + => playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer), + TimeSpan.FromSeconds(_options.TurnTimer)); + + private void EndGame(Result result, ulong? winId) + { + if (CurrentPhase == Phase.Ended) + return; + _ = OnGameEnded?.Invoke(this, result); + CurrentPhase = Phase.Ended; + + if (result == Result.Draw) + { + _cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw")); + _cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw")); + return; + } + + if (winId is not null) + _cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win")); + } + + private Field GetPlayerPiece(ulong userId) + => _players[0].Value.UserId == userId ? Field.P1 : Field.P2; + + //column is full if there are no empty fields + private bool IsColumnFull(int column) + { + var start = NUMBER_OF_ROWS * column; + for (var i = start; i < start + NUMBER_OF_ROWS; i++) + { + if (_gameState[i] == Field.Empty) + return false; + } + + return true; + } + + public void Dispose() + { + OnGameFailedToStart = null; + OnGameStateUpdated = null; + OnGameEnded = null; + playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + + public class Options : IEllieCommandOptions + { + [Option('t', + "turn-timer", + Required = false, + Default = 15, + HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")] + public int TurnTimer { get; set; } = 15; + + [Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")] + public int Bet { get; set; } + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + + if (Bet < 0) + Bet = 0; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs new file mode 100644 index 0000000..8210ad4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Connect4/Connect4Commands.cs @@ -0,0 +1,205 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Connect4; +using EllieBot.Modules.Gambling.Services; +using System.Text; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class Connect4Commands : GamblingSubmodule + { + private static readonly string[] _numbers = + [ + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:" + ]; + + private int RepostCounter + { + get => repostCounter; + set + { + if (value is < 0 or > 7) + repostCounter = 0; + else + repostCounter = value; + } + } + + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + + private IUserMessage msg; + + private int repostCounter; + + public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb) + : base(gamb) + { + _client = client; + _cs = cs; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Connect4(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args); + if (!await CheckBetOptional(options.Bet)) + return; + + var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs); + Connect4Game game; + if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame) + { + if (game.CurrentPhase != Connect4Game.Phase.Joining) + return; + + newGame.Dispose(); + //means game already exists, try to join + await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet); + return; + } + + if (options.Bet > 0) + { + if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet"))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + _service.Connect4Games.TryRemove(ctx.Channel.Id, out _); + game.Dispose(); + return; + } + } + + game.OnGameStateUpdated += Game_OnGameStateUpdated; + game.OnGameFailedToStart += GameOnGameFailedToStart; + game.OnGameEnded += GameOnGameEnded; + _client.MessageReceived += ClientMessageReceived; + + game.Initialize(); + if (options.Bet == 0) + await Response().Confirm(strs.connect4_created).SendAsync(); + else + await Response().Error(strs.connect4_created_bet(N(options.Bet))).SendAsync(); + + Task ClientMessageReceived(SocketMessage arg) + { + if (ctx.Channel.Id != arg.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var success = false; + if (int.TryParse(arg.Content, out var col)) + success = await game.Input(arg.Author.Id, col); + + if (success) + { + try { await arg.DeleteAsync(); } + catch { } + } + else + { + if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended) + return; + RepostCounter++; + if (RepostCounter == 0) + { + try { msg = await Response().Embed(msg.Embeds.First().ToEmbedBuilder()).SendAsync(); } + catch { } + } + } + }); + return Task.CompletedTask; + } + + Task GameOnGameFailedToStart(Connect4Game arg) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + return Response().Error(strs.connect4_failed_to_start).SendAsync(); + } + + Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result) + { + if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose)) + { + _client.MessageReceived -= ClientMessageReceived; + toDispose.Dispose(); + } + + string title; + if (result == Connect4Game.Result.CurrentPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username), + Format.Bold(arg.OtherPlayer.Username))); + } + else if (result == Connect4Game.Result.OtherPlayerWon) + { + title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username), + Format.Bold(arg.CurrentPlayer.Username))); + } + else + title = GetText(strs.connect4_draw); + + return msg.ModifyAsync(x => x.Embed = _sender.CreateEmbed() + .WithTitle(title) + .WithDescription(GetGameStateText(game)) + .WithOkColor() + .Build()); + } + } + + private async Task Game_OnGameStateUpdated(Connect4Game game) + { + var embed = _sender.CreateEmbed() + .WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}") + .WithDescription(GetGameStateText(game)) + .WithOkColor(); + + + if (msg is null) + msg = await Response().Embed(embed).SendAsync(); + else + await msg.ModifyAsync(x => x.Embed = embed.Build()); + } + + private string GetGameStateText(Connect4Game game) + { + var sb = new StringBuilder(); + + if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move) + sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username)))); + + for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--) + { + for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++) + { + var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1]; + + if (cur == Connect4Game.Field.Empty) + sb.Append("⚫"); //black circle + else if (cur == Connect4Game.Field.P1) + sb.Append("🔴"); //red circle + else + sb.Append("🔵"); //blue circle + } + + sb.AppendLine(); + } + + for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++) + sb.Append(_numbers[i]); + + return sb.ToString(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/CurrencyProvider.cs b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs new file mode 100644 index 0000000..e4f4bc1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/CurrencyProvider.cs @@ -0,0 +1,16 @@ +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public sealed class CurrencyProvider : ICurrencyProvider, IEService +{ + private readonly GamblingConfigService _cs; + + public CurrencyProvider(GamblingConfigService cs) + { + _cs = cs; + } + + public string GetCurrencySign() + => _cs.Data.Currency.Sign; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs b/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs new file mode 100644 index 0000000..15bf7ff --- /dev/null +++ b/src/EllieBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs @@ -0,0 +1,224 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using System.Text.RegularExpressions; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DiceRollCommands : EllieModule + { + private static readonly Regex _dndRegex = new(@"^(?\d+)d(?\d+)(?:\+(?\d+))?(?:\-(?\d+))?$", + RegexOptions.Compiled); + + private static readonly Regex _fudgeRegex = new(@"^(?\d+)d(?:F|f)$", RegexOptions.Compiled); + + private static readonly char[] _fateRolls = ['-', ' ', '+']; + private readonly IImageCache _images; + + public DiceRollCommands(IImageCache images) + => _images = images; + + [Cmd] + public async Task Roll() + { + var rng = new EllieRandom(); + var gen = rng.Next(1, 101); + + var num1 = gen / 10; + var num2 = gen % 10; + + using var img1 = await GetDiceAsync(num1); + using var img2 = await GetDiceAsync(num2); + using var img = new[] { img1, img2 }.Merge(out var format); + await using var ms = await img.ToStreamAsync(format); + + var fileName = $"dice.{format.FileExtensions.First()}"; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.roll2), gen) + .WithImageUrl($"attachment://{fileName}"); + + await ctx.Channel.SendFileAsync(ms, + fileName, + embed: eb.Build()); + } + + [Cmd] + [Priority(1)] + public async Task Roll(int num) + => await InternalRoll(num, true); + + + [Cmd] + [Priority(1)] + public async Task Rolluo(int num = 1) + => await InternalRoll(num, false); + + [Cmd] + [Priority(0)] + public async Task Roll(string arg) + => await InternallDndRoll(arg, true); + + [Cmd] + [Priority(0)] + public async Task Rolluo(string arg) + => await InternallDndRoll(arg, false); + + private async Task InternalRoll(int num, bool ordered) + { + if (num is < 1 or > 30) + { + await Response().Error(strs.dice_invalid_number(1, 30)).SendAsync(); + return; + } + + var rng = new EllieRandom(); + + var dice = new List>(num); + var values = new List(num); + for (var i = 0; i < num; i++) + { + var randomNumber = rng.Next(1, 7); + var toInsert = dice.Count; + if (ordered) + { + if (randomNumber == 6 || dice.Count == 0) + toInsert = 0; + else if (randomNumber != 1) + { + for (var j = 0; j < dice.Count; j++) + { + if (values[j] < randomNumber) + { + toInsert = j; + break; + } + } + } + } + else + toInsert = dice.Count; + + dice.Insert(toInsert, await GetDiceAsync(randomNumber)); + values.Insert(toInsert, randomNumber); + } + + using var bitmap = dice.Merge(out var format); + await using var ms = bitmap.ToStream(format); + foreach (var d in dice) + d.Dispose(); + + var imageName = $"dice.{format.FileExtensions.First()}"; + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true) + .AddField(GetText(strs.total), values.Sum(), true) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString())))) + .WithImageUrl($"attachment://{imageName}"); + + await ctx.Channel.SendFileAsync(ms, + imageName, + embed: eb.Build()); + } + + private async Task InternallDndRoll(string arg, bool ordered) + { + Match match; + if ((match = _fudgeRegex.Match(arg)).Length != 0 + && int.TryParse(match.Groups["n1"].ToString(), out var n1) + && n1 is > 0 and < 500) + { + var rng = new EllieRandom(); + + var rolls = new List(); + + for (var i = 0; i < n1; i++) + rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString())))) + .AddField(Format.Bold("Result"), + string.Join(" ", rolls.Select(c => Format.Code($"[{c}]")))); + + await Response().Embed(embed).SendAsync(); + } + else if ((match = _dndRegex.Match(arg)).Length != 0) + { + var rng = new EllieRandom(); + if (int.TryParse(match.Groups["n1"].ToString(), out n1) + && int.TryParse(match.Groups["n2"].ToString(), out var n2) + && n1 <= 50 + && n2 <= 100000 + && n1 > 0 + && n2 > 0) + { + if (!int.TryParse(match.Groups["add"].Value, out var add)) + add = 0; + if (!int.TryParse(match.Groups["sub"].Value, out var sub)) + sub = 0; + + var arr = new int[n1]; + for (var i = 0; i < n1; i++) + arr[i] = rng.Next(1, n2 + 1); + + var sum = arr.Sum(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`"))) + .AddField(Format.Bold(GetText(strs.rolls)), + string.Join(" ", + (ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x + => Format.Code(x.ToString())))) + .AddField(Format.Bold("Sum"), + sum + " + " + add + " - " + sub + " = " + (sum + add - sub)); + await Response().Embed(embed).SendAsync(); + } + } + } + + [Cmd] + public async Task NRoll([Leftover] string range) + { + int rolled; + if (range.Contains("-")) + { + var arr = range.Split('-').Take(2).Select(int.Parse).ToArray(); + if (arr[0] > arr[1]) + { + await Response().Error(strs.second_larger_than_first).SendAsync(); + return; + } + + rolled = new EllieRandom().Next(arr[0], arr[1] + 1); + } + else + rolled = new EllieRandom().Next(0, int.Parse(range) + 1); + + await Response().Confirm(strs.dice_rolled(Format.Bold(rolled.ToString()))).SendAsync(); + } + + private async Task> GetDiceAsync(int num) + { + if (num is < 0 or > 10) + throw new ArgumentOutOfRangeException(nameof(num)); + + if (num == 10) + { + using var imgOne = Image.Load(await _images.GetDiceAsync(1)); + using var imgZero = Image.Load(await _images.GetDiceAsync(0)); + return new[] { imgOne, imgZero }.Merge(); + } + + return Image.Load(await _images.GetDiceAsync(num)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs new file mode 100644 index 0000000..dd10cf1 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Draw/DrawCommands.cs @@ -0,0 +1,234 @@ +#nullable disable +using Ellie.Econ; +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class DrawCommands : GamblingSubmodule + { + private static readonly ConcurrentDictionary _allDecks = new(); + private readonly IImageCache _images; + + public DrawCommands(IImageCache images, GamblingConfigService gcs) : base(gcs) + => _images = images; + + private async Task InternalDraw(int count, ulong? guildId = null) + { + if (count is < 1 or > 10) + throw new ArgumentOutOfRangeException(nameof(count)); + + var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new()); + var images = new List>(); + var cardObjects = new List(); + for (var i = 0; i < count; i++) + { + if (cards.CardPool.Count == 0 && i != 0) + { + try + { + await Response().Error(strs.no_more_cards).SendAsync(); + } + catch + { + // ignored + } + + break; + } + + var currentCard = cards.Draw(); + cardObjects.Add(currentCard); + var image = await GetCardImageAsync(currentCard); + images.Add(image); + } + + var imgName = "cards.jpg"; + using var img = images.Merge(); + foreach (var i in images) + i.Dispose(); + + var eb = _sender.CreateEmbed() + .WithOkColor(); + + var toSend = string.Empty; + if (cardObjects.Count == 5) + eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true); + + if (guildId is not null) + toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString()))); + + eb.WithDescription(toSend) + .WithAuthor(ctx.User) + .WithImageUrl($"attachment://{imgName}"); + + if (count > 1) + eb.AddField(GetText(strs.cards), count.ToString(), true); + + await using var imageStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imageStream, + imgName, + embed: eb.Build()); + } + + private async Task> GetCardImageAsync(RegularCard currentCard) + { + var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + private async Task> GetCardImageAsync(Deck.Card currentCard) + { + var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_'); + var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg"); + return Image.Load(cardBytes); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Draw(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num, ctx.Guild.Id); + } + + [Cmd] + public async Task DrawNew(int num = 1) + { + if (num < 1) + return; + + if (num > 10) + num = 10; + + await InternalDraw(num); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeckShuffle() + { + //var channel = (ITextChannel)ctx.Channel; + + _allDecks.AddOrUpdate(ctx.Guild, + _ => new(), + (_, c) => + { + c.Restart(); + return c; + }); + + await Response().Confirm(strs.deck_reshuffled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null) + => BetDrawInternal(amount, val, col); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null) + => BetDrawInternal(amount, val, col); + + public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col) + { + if (amount <= 0) + return; + + var res = await _service.BetDrawAsync(ctx.User.Id, + amount, + (byte?)val, + (byte?)col); + + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(result.Card.GetEmoji()) + .AddField(GetText(strs.guess), GetGuessInfo(val, col), true) + .AddField(GetText(strs.card), GetCardInfo(result.Card), true) + .AddField(GetText(strs.won), N((long)result.Won), false) + .WithImageUrl("attachment://card.png"); + + using var img = await GetCardImageAsync(result.Card); + await using var imgStream = await img.ToStreamAsync(); + await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build()); + } + + private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG) + { + var val = valG switch + { + InputValueGuess.H => "Hi ⬆️", + InputValueGuess.L => "Lo ⬇️", + _ => "❓" + }; + + var col = colG switch + { + InputColorGuess.Red => "R 🔴", + InputColorGuess.Black => "B ⚫", + _ => "❓" + }; + + return $"{val} / {col}"; + } + private string GetCardInfo(RegularCard card) + { + var val = (int)card.Value switch + { + < 7 => "Lo ⬇️", + > 7 => "Hi ⬆️", + _ => "7 💀" + }; + + var col = card.Value == RegularValue.Seven + ? "7 💀" + : card.Suit switch + { + RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴", + _ => "B ⚫" + }; + + return $"{val} / {col}"; + } + + public enum InputValueGuess + { + High = 0, + H = 0, + Hi = 0, + Low = 1, + L = 1, + Lo = 1, + } + + public enum InputColorGuess + { + R = 0, + Red = 0, + B = 1, + Bl = 1, + Black = 1, + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/EconomyResult.cs b/src/EllieBot/Modules/Gambling/EconomyResult.cs new file mode 100644 index 0000000..12a00f8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/EconomyResult.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Services; + +public sealed class EconomyResult +{ + public decimal Cash { get; init; } + public decimal Planted { get; init; } + public decimal Waifus { get; init; } + public decimal OnePercent { get; init; } + public decimal Bank { get; init; } + public long Bot { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs new file mode 100644 index 0000000..c5e836c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsCommands.cs @@ -0,0 +1,60 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Events; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class CurrencyEventsCommands : GamblingSubmodule + { + public CurrencyEventsCommands(GamblingConfigService gamblingConf) + : base(gamblingConf) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + [OwnerOnly] + public async Task EventStart(CurrencyEvent.Type ev, params string[] options) + { + var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options); + if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed)) + await Response().Error(strs.start_event_fail).SendAsync(); + } + + private EmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot) + => type switch + { + CurrencyEvent.Type.Reaction => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetReactionDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + CurrencyEvent.Type.GameStatus => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.event_title(type.ToString()))) + .WithDescription(GetGameStatusDescription(opts.Amount, currentPot)) + .WithFooter(GetText(strs.event_duration_footer(opts.Hours))), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + + private string GetReactionDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize)); + + return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + + private string GetGameStatusDescription(long amount, long potSize) + { + var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign); + + return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs new file mode 100644 index 0000000..39160ff --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/CurrencyEventsService.cs @@ -0,0 +1,70 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Events; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public class CurrencyEventsService : IEService +{ + private readonly DiscordSocketClient _client; + private readonly ICurrencyService _cs; + private readonly GamblingConfigService _configService; + + private readonly ConcurrentDictionary _events = new(); + private readonly IMessageSenderService _sender; + + public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService, + IMessageSenderService sender) + { + _client = client; + _cs = cs; + _configService = configService; + _sender = sender; + } + + public async Task TryCreateEventAsync( + ulong guildId, + ulong channelId, + CurrencyEvent.Type type, + EventOptions opts, + Func embed) + { + var g = _client.GetGuild(guildId); + if (g?.GetChannel(channelId) is not ITextChannel ch) + return false; + + ICurrencyEvent ce; + + if (type == CurrencyEvent.Type.Reaction) + ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, _sender, embed); + else if (type == CurrencyEvent.Type.GameStatus) + ce = new GameStatusEvent(_client, _cs, g, ch, opts, _sender, embed); + else + return false; + + var added = _events.TryAdd(guildId, ce); + if (added) + { + try + { + ce.OnEnded += OnEventEnded; + await ce.StartEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error starting event"); + _events.TryRemove(guildId, out ce); + return false; + } + } + + return added; + } + + private Task OnEventEnded(ulong gid) + { + _events.TryRemove(gid, out _); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/EventOptions.cs b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs new file mode 100644 index 0000000..3d0eb3f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/EventOptions.cs @@ -0,0 +1,39 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class EventOptions : IEllieCommandOptions +{ + [Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")] + public long Amount { get; set; } = 100; + + [Option('p', + "pot-size", + Required = false, + Default = 0, + HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")] + public long PotSize { get; set; } + + //[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")] + //public string TypeString { get; set; } = "reaction"; + [Option('d', + "duration", + Required = false, + Default = 24, + HelpText = "Number of hours the event should run for. Default 24.")] + public int Hours { get; set; } = 24; + + + public void NormalizeOptions() + { + if (Amount < 0) + Amount = 100; + if (PotSize < 0) + PotSize = 0; + if (Hours <= 0) + Hours = 24; + if (PotSize != 0 && PotSize < Amount) + PotSize = 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs new file mode 100644 index 0000000..aeb23c0 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/GameStatusEvent.cs @@ -0,0 +1,198 @@ +#nullable disable +using EllieBot.Db.Models; +using System.Collections.Concurrent; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class GameStatusEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly EventOptions _opts; + + private readonly string _code; + + private readonly char[] _sneakyGameStatusChars = Enumerable.Range(48, 10) + .Concat(Enumerable.Range(65, 26)) + .Concat(Enumerable.Range(97, 26)) + .Select(x => (char)x) + .ToArray(); + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + private readonly IMessageSenderService _sender; + + public GameStatusEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + IMessageSenderService sender, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _opts = opt; + _sender = sender; + // generate code + _code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray()); + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, + _amount, + new("event", "gamestatus") + ); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error in OnTimerTick in gamestatusevent"); + } + } + + public async Task StartEvent() + { + msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync(); + await _client.SetGameAsync(_code); + _client.MessageDeleted += OnMessageDeleted; + _client.MessageReceived += HandleMessage; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private EmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.MessageReceived -= HandleMessage; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + _ = _client.SetGameAsync(null); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleMessage(SocketMessage message) + { + _ = Task.Run(async () => + { + if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts + || gu.IsBot // no bots + || message.Content != _code // code has to be the same + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot()) + { + _toAward.Enqueue(message.Author.Id); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + + try + { + await message.DeleteAsync(new() + { + RetryMode = RetryMode.AlwaysFail + }); + } + catch { } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs new file mode 100644 index 0000000..57b96d9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/ICurrencyEvent.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public interface ICurrencyEvent +{ + event Func OnEnded; + Task StopEvent(); + Task StartEvent(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs new file mode 100644 index 0000000..6f02747 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Events/ReactionEvent.cs @@ -0,0 +1,197 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Common.Events; + +public class ReactionEvent : ICurrencyEvent +{ + public event Func OnEnded; + private long PotSize { get; set; } + public bool Stopped { get; private set; } + public bool PotEmptied { get; private set; } + private readonly DiscordSocketClient _client; + private readonly IGuild _guild; + private IUserMessage msg; + private IEmote emote; + private readonly ICurrencyService _cs; + private readonly long _amount; + + private readonly Func _embedFunc; + private readonly bool _isPotLimited; + private readonly ITextChannel _channel; + private readonly ConcurrentHashSet _awardedUsers = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue _toAward = new(); + private readonly Timer _t; + private readonly Timer _timeout; + private readonly bool _noRecentlyJoinedServer; + private readonly EventOptions _opts; + private readonly GamblingConfig _config; + + private readonly object _stopLock = new(); + + private readonly object _potLock = new(); + private readonly IMessageSenderService _sender; + + public ReactionEvent( + DiscordSocketClient client, + ICurrencyService cs, + SocketGuild g, + ITextChannel ch, + EventOptions opt, + GamblingConfig config, + IMessageSenderService sender, + Func embedFunc) + { + _client = client; + _guild = g; + _cs = cs; + _amount = opt.Amount; + PotSize = opt.PotSize; + _embedFunc = embedFunc; + _isPotLimited = PotSize > 0; + _channel = ch; + _noRecentlyJoinedServer = false; + _opts = opt; + _config = config; + _sender = sender; + + _t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2)); + if (_opts.Hours > 0) + _timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan); + } + + private void EventTimeout(object state) + => _ = StopEvent(); + + private async void OnTimerTick(object state) + { + var potEmpty = PotEmptied; + var toAward = new List(); + while (_toAward.TryDequeue(out var x)) + toAward.Add(x); + + if (!toAward.Any()) + return; + + try + { + await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction")); + + if (_isPotLimited) + { + await msg.ModifyAsync(m => + { + m.Embed = GetEmbed(PotSize).Build(); + }); + } + + Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}", + toAward.Count, + _amount, + _isPotLimited ? $" {PotSize} left." : ""); + + if (potEmpty) + _ = StopEvent(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding bulk currency to users"); + } + } + + public async Task StartEvent() + { + if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote)) + emote = parsedEmote; + else + emote = new Emoji(_config.Currency.Sign); + msg = await _sender.Response(_channel).Embed(GetEmbed(_opts.PotSize)).SendAsync(); + await msg.AddReactionAsync(emote); + _client.MessageDeleted += OnMessageDeleted; + _client.ReactionAdded += HandleReaction; + _t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2)); + } + + private EmbedBuilder GetEmbed(long pot) + => _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot); + + private async Task OnMessageDeleted(Cacheable message, Cacheable cacheable) + { + if (message.Id == msg.Id) + await StopEvent(); + } + + public Task StopEvent() + { + lock (_stopLock) + { + if (Stopped) + return Task.CompletedTask; + + Stopped = true; + _client.MessageDeleted -= OnMessageDeleted; + _client.ReactionAdded -= HandleReaction; + _t.Change(Timeout.Infinite, Timeout.Infinite); + _timeout?.Change(Timeout.Infinite, Timeout.Infinite); + try + { + _ = msg.DeleteAsync(); + } + catch { } + + _ = OnEnded?.Invoke(_guild.Id); + } + + return Task.CompletedTask; + } + + private Task HandleReaction( + Cacheable message, + Cacheable cacheable, + SocketReaction r) + { + _ = Task.Run(() => + { + if (emote.Name != r.Emote.Name) + return; + if ((r.User.IsSpecified + ? r.User.Value + : null) is not IGuildUser gu // no unknown users, as they could be bots, or alts + || message.Id != msg.Id // same message + || gu.IsBot // no bots + || (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts + || (_noRecentlyJoinedServer + && // if specified, no users who joined the server in the last 24h + (gu.JoinedAt is null + || (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays + < 1))) // and no users for who we don't know when they joined + return; + // there has to be money left in the pot + // and the user wasn't rewarded + if (_awardedUsers.Add(r.UserId) && TryTakeFromPot()) + { + _toAward.Enqueue(r.UserId); + if (_isPotLimited && PotSize < _amount) + PotEmptied = true; + } + }); + return Task.CompletedTask; + } + + private bool TryTakeFromPot() + { + if (_isPotLimited) + { + lock (_potLock) + { + if (PotSize < _amount) + return false; + + PotSize -= _amount; + return true; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs new file mode 100644 index 0000000..2704495 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class FlipCoinCommands : GamblingSubmodule + { + public enum BetFlipGuess : byte + { + H = 0, + Head = 0, + Heads = 0, + T = 1, + Tail = 1, + Tails = 1 + } + + private static readonly EllieRandom _rng = new(); + private readonly IImageCache _images; + private readonly ICurrencyService _cs; + private readonly ImagesConfig _ic; + + public FlipCoinCommands( + IImageCache images, + ImagesConfig ic, + ICurrencyService cs, + GamblingConfigService gss) + : base(gss) + { + _ic = ic; + _images = images; + _cs = cs; + } + + [Cmd] + public async Task Flip(int count = 1) + { + if (count is > 10 or < 1) + { + await Response().Error(strs.flip_invalid(10)).SendAsync(); + return; + } + + var headCount = 0; + var tailCount = 0; + var imgs = new Image[count]; + var headsArr = await _images.GetHeadsImageAsync(); + var tailsArr = await _images.GetTailsImageAsync(); + + var result = await _service.FlipAsync(count); + + for (var i = 0; i < result.Length; i++) + { + if (result[i].Side == 0) + { + imgs[i] = Image.Load(headsArr); + headCount++; + } + else + { + imgs[i] = Image.Load(tailsArr); + tailCount++; + } + } + + using var img = imgs.Merge(out var format); + await using var stream = await img.ToStreamAsync(format); + foreach (var i in imgs) + i.Dispose(); + + var imgName = $"coins.{format.FileExtensions.First()}"; + + var msg = count != 1 + ? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount))) + : GetText(strs.flipped(headCount > 0 + ? Format.Bold(GetText(strs.heads)) + : Format.Bold(GetText(strs.tails)))); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithDescription(msg) + .WithImageUrl($"attachment://{imgName}"); + + await ctx.Channel.SendFileAsync(stream, + imgName, + embed: eb.Build()); + } + + [Cmd] + public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess) + { + if (!await CheckBetMandatory(amount) || amount == 1) + return; + + var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess); + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + Uri imageToSend; + var coins = _ic.Data.Coins; + if (result.Side == 0) + { + imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)]; + } + else + { + imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)]; + } + + string str; + var won = (long)result.Won; + if (won > 0) + { + str = Format.Bold(GetText(strs.flip_guess(N(won)))); + } + else + { + str = Format.Bold(GetText(strs.better_luck)); + } + + await Response().Embed(_sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(str) + .WithOkColor() + .WithImageUrl(imageToSend.ToString())).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs new file mode 100644 index 0000000..6c16b9f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/FlipCoin/FlipResult.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct FlipResult +{ + public long Won { get; init; } + public int Side { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Gambling.cs b/src/EllieBot/Modules/Gambling/Gambling.cs new file mode 100644 index 0000000..428f756 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Gambling.cs @@ -0,0 +1,1032 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Utility.Services; +using EllieBot.Services.Currency; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using EllieBot.Modules.Gambling.Rps; +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling : GamblingModule +{ + private readonly IGamblingService _gs; + private readonly DbService _db; + private readonly ICurrencyService _cs; + private readonly DiscordSocketClient _client; + private readonly NumberFormatInfo _enUsCulture; + private readonly DownloadTracker _tracker; + private readonly GamblingConfigService _configService; + private readonly IBankService _bank; + private readonly IPatronageService _ps; + private readonly IRemindService _remind; + private readonly GamblingTxTracker _gamblingTxTracker; + + private IUserMessage rdMsg; + + public Gambling( + IGamblingService gs, + DbService db, + ICurrencyService currency, + DiscordSocketClient client, + DownloadTracker tracker, + GamblingConfigService configService, + IBankService bank, + IPatronageService ps, + IRemindService remind, + GamblingTxTracker gamblingTxTracker) + : base(configService) + { + _gs = gs; + _db = db; + _cs = currency; + _client = client; + _bank = bank; + _ps = ps; + _remind = remind; + _gamblingTxTracker = gamblingTxTracker; + + _enUsCulture = new CultureInfo("en-US", false).NumberFormat; + _enUsCulture.NumberDecimalDigits = 0; + _enUsCulture.NumberGroupSeparator = " "; + _tracker = tracker; + _configService = configService; + } + + public async Task GetBalanceStringAsync(ulong userId) + { + var bal = await _cs.GetBalanceAsync(userId); + return N(bal); + } + + [Cmd] + public async Task BetStats() + { + var stats = await _gamblingTxTracker.GetAllAsync(); + + var eb = _sender.CreateEmbed() + .WithOkColor(); + + var str = "` Feature `|`   Bet  `|`Paid Out`|`  RoI  `\n"; + str += "――――――――――――――――――――\n"; + foreach (var stat in stats) + { + var perc = (stat.PaidOut / stat.Bet).ToString("P2", Culture); + str += $"`{stat.Feature.PadBoth(9)}`" + + $"|`{stat.Bet.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{stat.PaidOut.ToString("N0").PadLeft(8, ' ')}`" + + $"|`{perc.PadLeft(6, ' ')}`\n"; + } + + var bet = stats.Sum(x => x.Bet); + var paidOut = stats.Sum(x => x.PaidOut); + + if (bet == 0) + bet = 1; + + var tPerc = (paidOut / bet).ToString("P2", Culture); + str += "――――――――――――――――――――\n"; + str += $"` {("TOTAL").PadBoth(7)}` " + + $"|**{N(bet).PadLeft(8, ' ')}**" + + $"|**{N(paidOut).PadLeft(8, ' ')}**" + + $"|`{tPerc.PadLeft(6, ' ')}`"; + + eb.WithDescription(str); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + public async Task Economy() + { + var ec = await _service.GetEconomyAsync(); + decimal onePercent = 0; + + // This stops the top 1% from owning more than 100% of the money + if (ec.Cash > 0) + { + onePercent = ec.OnePercent / (ec.Cash - ec.Bot); + } + + // [21:03] Bob Page: Kinda remids me of US economy + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.economy_state)) + .AddField(GetText(strs.currency_owned), N(ec.Cash - ec.Bot)) + .AddField(GetText(strs.currency_one_percent), (onePercent * 100).ToString("F2") + "%") + .AddField(GetText(strs.currency_planted), N(ec.Planted)) + .AddField(GetText(strs.owned_waifus_total), N(ec.Waifus)) + .AddField(GetText(strs.bot_currency), N(ec.Bot)) + .AddField(GetText(strs.bank_accounts), N(ec.Bank)) + .AddField(GetText(strs.total), N(ec.Cash + ec.Planted + ec.Waifus + ec.Bank)) + .WithOkColor(); + + // ec.Cash already contains ec.Bot as it's the total of all values in the CurrencyAmount column of the DiscordUser table + await Response().Embed(embed).SendAsync(); + } + + private static readonly FeatureLimitKey _timelyKey = new FeatureLimitKey() + { + Key = "timely:extra_percent", + PrettyName = "Timely" + }; + + private async Task RemindTimelyAction(SocketMessageComponent smc, DateTime when) + { + var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative); + + await _remind.AddReminderAsync(ctx.User.Id, + ctx.User.Id, + ctx.Guild?.Id, + true, + when, + GetText(strs.timely_time), + ReminderType.Timely); + + await smc.RespondConfirmAsync(_sender, GetText(strs.remind_timely(tt)), ephemeral: true); + } + + private EllieInteractionBase CreateRemindMeInteraction(int period) + => _inter + .Create(ctx.User.Id, + new ButtonBuilder( + label: "Remind me", + emote: Emoji.Parse("⏰"), + customId: "timely:remind_me"), + (smc) => RemindTimelyAction(smc, DateTime.UtcNow.Add(TimeSpan.FromHours(period))) + ); + + [Cmd] + public async Task Timely() + { + var val = Config.Timely.Amount; + var period = Config.Timely.Cooldown; + if (val <= 0 || period <= 0) + { + await Response().Error(strs.timely_none).SendAsync(); + return; + } + + var inter = CreateRemindMeInteraction(period); + + if (await _service.ClaimTimelyAsync(ctx.User.Id, period) is { } rem) + { + // Removes timely button if there is a timely reminder in DB + if (_service.UserHasTimelyReminder(ctx.User.Id)) + { + inter = null; + } + + var now = DateTime.UtcNow; + var relativeTag = TimestampTag.FromDateTime(now.Add(rem), TimestampTagStyles.Relative); + await Response().Pending(strs.timely_already_claimed(relativeTag)).Interaction(inter).SendAsync(); + return; + } + + var result = await _ps.TryGetFeatureLimitAsync(_timelyKey, ctx.User.Id, 0); + + val = (int)(val * (1 + (result.Quota! * 0.01f))); + + await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim")); + + await Response().Confirm(strs.timely(N(val), period)).Interaction(inter).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task TimelyReset() + { + await _service.RemoveAllTimelyClaimsAsync(); + await Response().Confirm(strs.timely_reset).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task TimelySet(int amount, int period = 24) + { + if (amount < 0 || period < 0) + { + return; + } + + _configService.ModifyConfig(gs => + { + gs.Timely.Amount = amount; + gs.Timely.Cooldown = period; + }); + + if (amount == 0) + { + await Response().Confirm(strs.timely_set_none).SendAsync(); + } + else + { + await Response() + .Confirm(strs.timely_set(Format.Bold(N(amount)), Format.Bold(period.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Raffle([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = (await role.GetMembersAsync()).Where(u => u.Status != UserStatus.Offline); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await Response() + .Confirm("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}**", + footer: $"ID: {usr.Id}") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RaffleAny([Leftover] IRole role = null) + { + role ??= ctx.Guild.EveryoneRole; + + var members = await role.GetMembersAsync(); + var membersArray = members as IUser[] ?? members.ToArray(); + if (membersArray.Length == 0) + { + return; + } + + var usr = membersArray[new EllieRandom().Next(0, membersArray.Length)]; + await Response() + .Confirm("🎟 " + GetText(strs.raffled_user), + $"**{usr.Username}**", + footer: $"ID: {usr.Id}") + .SendAsync(); + } + + [Cmd] + [Priority(2)] + public Task CurrencyTransactions(int page = 1) + => InternalCurrencyTransactions(ctx.User.Id, page); + + [Cmd] + [OwnerOnly] + [Priority(0)] + public Task CurrencyTransactions([Leftover] IUser usr) + => InternalCurrencyTransactions(usr.Id, 1); + + [Cmd] + [OwnerOnly] + [Priority(1)] + public Task CurrencyTransactions(IUser usr, int page) + => InternalCurrencyTransactions(usr.Id, page); + + private async Task InternalCurrencyTransactions(ulong userId, int page) + { + if (--page < 0) + { + return; + } + + List trs; + await using (var uow = _db.GetDbContext()) + { + trs = await uow.Set().GetPageFor(userId, page); + } + + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.transactions(((SocketGuild)ctx.Guild)?.GetUser(userId)?.ToString() + ?? $"{userId}"))) + .WithOkColor(); + + var sb = new StringBuilder(); + foreach (var tr in trs) + { + var change = tr.Amount >= 0 ? "🔵" : "🔴"; + var kwumId = new kwum(tr.Id).ToString(); + var date = $"#{Format.Code(kwumId)} `〖{GetFormattedCurtrDate(tr)}〗`"; + + sb.AppendLine($"\\{change} {date} {Format.Bold(N(tr.Amount))}"); + var transactionString = GetHumanReadableTransaction(tr.Type, tr.Extra, tr.OtherId); + if (transactionString is not null) + { + sb.AppendLine(transactionString); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + sb.AppendLine($"\t`Note:` {tr.Note.TrimTo(50)}"); + } + } + + embed.WithDescription(sb.ToString()); + embed.WithFooter(GetText(strs.page(page + 1))); + await Response().Embed(embed).SendAsync(); + } + + private static string GetFormattedCurtrDate(CurrencyTransaction ct) + => $"{ct.DateAdded:HH:mm yyyy-MM-dd}"; + + [Cmd] + public async Task CurrencyTransaction(kwum id) + { + int intId = id; + await using var uow = _db.GetDbContext(); + + var tr = await uow.Set() + .ToLinqToDBTable() + .Where(x => x.Id == intId && x.UserId == ctx.User.Id) + .FirstOrDefaultAsync(); + + if (tr is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed().WithOkColor(); + + eb.WithAuthor(ctx.User); + eb.WithTitle(GetText(strs.transaction)); + eb.WithDescription(new kwum(tr.Id).ToString()); + eb.AddField("Amount", N(tr.Amount)); + eb.AddField("Type", tr.Type, true); + eb.AddField("Extra", tr.Extra, true); + + if (tr.OtherId is ulong other) + { + eb.AddField("From Id", other); + } + + if (!string.IsNullOrWhiteSpace(tr.Note)) + { + eb.AddField("Note", tr.Note); + } + + eb.WithFooter(GetFormattedCurtrDate(tr)); + + await Response().Embed(eb).SendAsync(); + } + + private string GetHumanReadableTransaction(string type, string subType, ulong? maybeUserId) + => (type, subType, maybeUserId) switch + { + ("gift", var name, ulong userId) => GetText(strs.curtr_gift(name, userId)), + ("award", var name, ulong userId) => GetText(strs.curtr_award(name, userId)), + ("take", var name, ulong userId) => GetText(strs.curtr_take(name, userId)), + ("blackjack", _, _) => $"Blackjack - {subType}", + ("wheel", _, _) => $"Lucky Ladder - {subType}", + ("lula", _, _) => $"Lucky Ladder - {subType}", + ("rps", _, _) => $"Rock Paper Scissors - {subType}", + (null, _, _) => null, + (_, null, _) => null, + (_, _, ulong userId) => $"{type} - {subType} | [{userId}]", + _ => $"{type} - {subType}" + }; + + [Cmd] + [Priority(0)] + public async Task Cash(ulong userId) + { + var cur = await GetBalanceStringAsync(userId); + await Response().Confirm(strs.has(Format.Code(userId.ToString()), cur)).SendAsync(); + } + + private async Task BankAction(SocketMessageComponent smc) + { + var balance = await _bank.GetBalanceAsync(ctx.User.Id); + + await N(balance) + .Pipe(strs.bank_balance) + .Pipe(GetText) + .Pipe(text => smc.RespondConfirmAsync(_sender, text, ephemeral: true)); + } + + private EllieInteractionBase CreateCashInteraction() + => _inter.Create(ctx.User.Id, + new ButtonBuilder( + customId: "cash:bank_show_balance", + emote: new Emoji("🏦")), + BankAction); + + [Cmd] + [Priority(1)] + public async Task Cash([Leftover] IUser user = null) + { + user ??= ctx.User; + var cur = await GetBalanceStringAsync(user.Id); + + var inter = user == ctx.User + ? CreateCashInteraction() + : null; + + await Response() + .Confirm( + user.ToString() + .Pipe(Format.Bold) + .With(cur) + .Pipe(strs.has)) + .Interaction(inter) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task Give( + [OverrideTypeReader(typeof(BalanceTypeReader))] + long amount, + IGuildUser receiver, + [Leftover] string msg) + { + if (amount <= 0 || ctx.User.Id == receiver.Id || receiver.IsBot) + { + return; + } + + if (!await _cs.TransferAsync(_sender, ctx.User, receiver, amount, msg, N(amount))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + await Response().Confirm(strs.gifted(N(amount), Format.Bold(receiver.ToString()), ctx.User)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Give([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, [Leftover] IGuildUser receiver) + => Give(amount, receiver, null); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(0)] + public Task Award(long amount, IGuildUser usr, [Leftover] string msg) + => Award(amount, usr.Id, msg); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(1)] + public Task Award(long amount, [Leftover] IGuildUser usr) + => Award(amount, usr.Id); + + [Cmd] + [OwnerOnly] + [Priority(2)] + public async Task Award(long amount, ulong usrId, [Leftover] string msg = null) + { + if (amount <= 0) + { + return; + } + + var usr = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(usrId); + + if (usr is null) + { + await Response().Error(strs.user_not_found).SendAsync(); + return; + } + + await _cs.AddAsync(usr.Id, amount, new("award", ctx.User.ToString()!, msg, ctx.User.Id)); + await Response().Confirm(strs.awarded(N(amount), $"<@{usrId}>", ctx.User)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(3)] + public async Task Award(long amount, [Leftover] IRole role) + { + var users = (await ctx.Guild.GetUsersAsync()).Where(u => u.GetRoles().Contains(role)).ToList(); + + await _cs.AddBulkAsync(users.Select(x => x.Id).ToList(), + amount, + new("award", ctx.User.ToString()!, role.Name, ctx.User.Id)); + + await Response() + .Confirm(strs.mass_award(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(0)] + public async Task Take(long amount, [Leftover] IRole role) + { + var users = (await role.GetMembersAsync()).ToList(); + + await _cs.RemoveBulkAsync(users.Select(x => x.Id).ToList(), + amount, + new("take", ctx.User.ToString()!, null, ctx.User.Id)); + + await Response() + .Confirm(strs.mass_take(N(amount), + Format.Bold(users.Count.ToString()), + Format.Bold(role.Name))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + [Priority(1)] + public async Task Take(long amount, [Leftover] IGuildUser user) + { + if (amount <= 0) + { + return; + } + + var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id); + + if (await _cs.RemoveAsync(user.Id, amount, extra)) + { + await Response().Confirm(strs.take(N(amount), Format.Bold(user.ToString()))).SendAsync(); + } + else + { + await Response().Error(strs.take_fail(N(amount), Format.Bold(user.ToString()), CurrencySign)).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task Take(long amount, [Leftover] ulong usrId) + { + if (amount <= 0) + { + return; + } + + var extra = new TxData("take", ctx.User.ToString()!, null, ctx.User.Id); + + if (await _cs.RemoveAsync(usrId, amount, extra)) + { + await Response().Confirm(strs.take(N(amount), $"<@{usrId}>")).SendAsync(); + } + else + { + await Response().Error(strs.take_fail(N(amount), Format.Code(usrId.ToString()), CurrencySign)).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel(IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + //since the challenge is created by another user, we need to reverse the ids + //if it gets removed, means challenge is accepted + if (_service.Duels.TryRemove((ctx.User.Id, u.Id), out var game)) + { + await game.StartGame(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RollDuel([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, IUser u) + { + if (ctx.User.Id == u.Id) + { + return; + } + + if (amount <= 0) + { + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.roll_duel)); + + var description = string.Empty; + + var game = new RollDuelGame(_cs, _client.CurrentUser.Id, ctx.User.Id, u.Id, amount); + //means challenge is just created + if (_service.Duels.TryGetValue((ctx.User.Id, u.Id), out var other)) + { + if (other.Amount != amount) + { + await Response().Error(strs.roll_duel_already_challenged).SendAsync(); + } + else + { + await RollDuel(u); + } + + return; + } + + if (_service.Duels.TryAdd((u.Id, ctx.User.Id), game)) + { + game.OnGameTick += GameOnGameTick; + game.OnEnded += GameOnEnded; + + await Response() + .Confirm(strs.roll_duel_challenge(Format.Bold(ctx.User.ToString()), + Format.Bold(u.ToString()), + Format.Bold(N(amount)))) + .SendAsync(); + } + + async Task GameOnGameTick(RollDuelGame arg) + { + var rolls = arg.Rolls.Last(); + description += $@"{Format.Bold(ctx.User.ToString())} rolled **{rolls.Item1}** +{Format.Bold(u.ToString())} rolled **{rolls.Item2}** +-- +"; + embed = embed.WithDescription(description); + + if (rdMsg is null) + { + rdMsg = await Response().Embed(embed).SendAsync(); + } + else + { + await rdMsg.ModifyAsync(x => { x.Embed = embed.Build(); }); + } + } + + async Task GameOnEnded(RollDuelGame rdGame, RollDuelGame.Reason reason) + { + try + { + if (reason == RollDuelGame.Reason.Normal) + { + var winner = rdGame.Winner == rdGame.P1 ? ctx.User : u; + description += $"\n**{winner}** Won {N((long)(rdGame.Amount * 2 * 0.98))}"; + + embed = embed.WithDescription(description); + + await rdMsg.ModifyAsync(x => x.Embed = embed.Build()); + } + else if (reason == RollDuelGame.Reason.Timeout) + { + await Response().Error(strs.roll_duel_timeout).SendAsync(); + } + else if (reason == RollDuelGame.Reason.NoFunds) + { + await Response().Error(strs.roll_duel_no_funds).SendAsync(); + } + } + finally + { + _service.Duels.TryRemove((u.Id, ctx.User.Id), out _); + } + } + } + + [Cmd] + public async Task BetRoll([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + { + return; + } + + var maybeResult = await _gs.BetRollAsync(ctx.User.Id, amount); + if (!maybeResult.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + + var win = (long)result.Won; + string str; + if (win > 0) + { + str = GetText(strs.br_win(N(win), result.Threshold + (result.Roll == 100 ? " 👑" : ""))); + } + else + { + str = GetText(strs.better_luck); + } + + var eb = _sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(Format.Bold(str)) + .AddField(GetText(strs.roll2), result.Roll.ToString(CultureInfo.InvariantCulture)) + .WithOkColor(); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [EllieOptions] + [Priority(0)] + public Task Leaderboard(params string[] args) + => Leaderboard(1, args); + + [Cmd] + [EllieOptions] + [Priority(1)] + public async Task Leaderboard(int page = 1, params string[] args) + { + if (--page < 0) + { + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args); + + // List cleanRichest; + // it's pointless to have clean on dm context + if (ctx.Guild is null) + { + opts.Clean = false; + } + + + async Task> GetTopRichest(int curPage) + { + if (opts.Clean) + { + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + await using var uow = _db.GetDbContext(); + + var cleanRichest = await uow.Set() + .GetTopRichest(_client.CurrentUser.Id, 0, 1000); + + var sg = (SocketGuild)ctx.Guild!; + return cleanRichest.Where(x => sg.GetUser(x.UserId) is not null).ToList(); + } + else + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetTopRichest(_client.CurrentUser.Id, curPage); + } + } + + var res = Response() + .Paginated(); + + await Response() + .Paginated() + .PageItems(GetTopRichest) + .TotalElements(900) + .PageSize(9) + .CurrentPage(page) + .Page((toSend, curPage) => + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(CurrencySign + " " + GetText(strs.leaderboard)); + + if (!toSend.Any()) + { + embed.WithDescription(GetText(strs.no_user_on_this_page)); + return Task.FromResult(embed); + } + + for (var i = 0; i < toSend.Count; i++) + { + var x = toSend[i]; + var usrStr = x.ToString().TrimTo(20, true); + + var j = i; + embed.AddField("#" + ((9 * curPage) + j + 1) + " " + usrStr, N(x.CurrencyAmount), true); + } + + return Task.FromResult(embed); + }) + .SendAsync(); + } + + public enum InputRpsPick : byte + { + R = 0, + Rock = 0, + Rocket = 0, + P = 1, + Paper = 1, + Paperclip = 1, + S = 2, + Scissors = 2 + } + + [Cmd] + public async Task Rps(InputRpsPick pick, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default) + { + static string GetRpsPick(InputRpsPick p) + { + switch (p) + { + case InputRpsPick.R: + return "🚀"; + case InputRpsPick.P: + return "📎"; + default: + return "✂️"; + } + } + + if (!await CheckBetOptional(amount) || amount == 1) + return; + + var res = await _gs.RpsAsync(ctx.User.Id, amount, (byte)pick); + + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed(); + + string msg; + if (result.Result == RpsResultType.Draw) + { + msg = GetText(strs.rps_draw(GetRpsPick(pick))); + } + else if (result.Result == RpsResultType.Win) + { + if ((long)result.Won > 0) + embed.AddField(GetText(strs.won), N((long)result.Won)); + + msg = GetText(strs.rps_win(ctx.User.Mention, + GetRpsPick(pick), + GetRpsPick((InputRpsPick)result.ComputerPick))); + } + else + { + msg = GetText(strs.rps_win(ctx.Client.CurrentUser.Mention, + GetRpsPick((InputRpsPick)result.ComputerPick), + GetRpsPick(pick))); + } + + embed + .WithOkColor() + .WithDescription(msg); + + await Response().Embed(embed).SendAsync(); + } + + private static readonly ImmutableArray _emojis = + new[] { "⬆", "↖", "⬅", "↙", "⬇", "↘", "➡", "↗" }.ToImmutableArray(); + + [Cmd] + public async Task LuckyLadder([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + var res = await _gs.LulaAsync(ctx.User.Id, amount); + if (!res.TryPickT0(out var result, out _)) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var multis = result.Multipliers; + + var sb = new StringBuilder(); + foreach (var multi in multis) + { + sb.Append($"╠══╣"); + + if (multi == result.Multiplier) + sb.Append($"{Format.Bold($"x{multi:0.##}")} ⬅️"); + else + sb.Append($"||x{multi:0.##}||"); + + sb.AppendLine(); + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(sb.ToString()) + .AddField(GetText(strs.multiplier), $"{result.Multiplier:0.##}x", true) + .AddField(GetText(strs.won), $"{(long)result.Won}", true) + .WithAuthor(ctx.User); + + + await Response().Embed(eb).SendAsync(); + } + + + public enum GambleTestTarget + { + Slot, + Betroll, + Betflip, + BetflipT, + BetDraw, + BetDrawHL, + BetDrawRB, + Lula, + Rps, + } + + [Cmd] + [OwnerOnly] + public async Task BetTest() + { + var values = Enum.GetValues() + .Select(x => $"`{x}`") + .Join(", "); + + await Response().Confirm(GetText(strs.available_tests), values).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task BetTest(GambleTestTarget target, int tests = 1000) + { + if (tests <= 0) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var streak = 0; + var maxW = 0; + var maxL = 0; + + var dict = new Dictionary(); + for (var i = 0; i < tests; i++) + { + var multi = target switch + { + GambleTestTarget.BetDraw => (await _gs.BetDrawAsync(ctx.User.Id, 0, 1, 0)).AsT0.Multiplier, + GambleTestTarget.BetDrawRB => (await _gs.BetDrawAsync(ctx.User.Id, 0, null, 1)).AsT0.Multiplier, + GambleTestTarget.BetDrawHL => (await _gs.BetDrawAsync(ctx.User.Id, 0, 0, null)).AsT0.Multiplier, + GambleTestTarget.Slot => (await _gs.SlotAsync(ctx.User.Id, 0)).AsT0.Multiplier, + GambleTestTarget.Betflip => (await _gs.BetFlipAsync(ctx.User.Id, 0, 0)).AsT0.Multiplier, + GambleTestTarget.BetflipT => (await _gs.BetFlipAsync(ctx.User.Id, 0, 1)).AsT0.Multiplier, + GambleTestTarget.Lula => (await _gs.LulaAsync(ctx.User.Id, 0)).AsT0.Multiplier, + GambleTestTarget.Rps => (await _gs.RpsAsync(ctx.User.Id, 0, (byte)(i % 3))).AsT0.Multiplier, + GambleTestTarget.Betroll => (await _gs.BetRollAsync(ctx.User.Id, 0)).AsT0.Multiplier, + _ => throw new ArgumentOutOfRangeException(nameof(target)) + }; + + if (dict.ContainsKey(multi)) + dict[multi] += 1; + else + dict.Add(multi, 1); + + if (multi < 1) + { + if (streak <= 0) + --streak; + else + streak = -1; + + maxL = Math.Max(maxL, -streak); + } + else if (multi > 1) + { + if (streak >= 0) + ++streak; + else + streak = 1; + + maxW = Math.Max(maxW, streak); + } + } + + var sb = new StringBuilder(); + decimal payout = 0; + foreach (var key in dict.Keys.OrderByDescending(x => x)) + { + sb.AppendLine($"x**{key}** occured `{dict[key]}` times. {dict[key] * 1.0f / tests * 100}%"); + payout += key * dict[key]; + } + + sb.AppendLine(); + sb.AppendLine($"Longest win streak: `{maxW}`"); + sb.AppendLine($"Longest lose streak: `{maxL}`"); + + await Response() + .Confirm(GetText(strs.test_results_for(target)), + sb.ToString(), + footer: $"Total Bet: {tests} | Payout: {payout:F0} | {payout * 1.0M / tests * 100}%") + .SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingConfig.cs b/src/EllieBot/Modules/Gambling/GamblingConfig.cs new file mode 100644 index 0000000..0100c88 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingConfig.cs @@ -0,0 +1,404 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; +using SixLabors.ImageSharp.PixelFormats; +using YamlDotNet.Serialization; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Gambling.Common; + +[Cloneable] +public sealed partial class GamblingConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 2; + + [Comment("""Currency settings""")] + public CurrencyConfig Currency { get; set; } + + [Comment("""Minimum amount users can bet (>=0)""")] + public int MinBet { get; set; } = 0; + + [Comment(""" + Maximum amount users can bet + Set 0 for unlimited + """)] + public int MaxBet { get; set; } = 0; + + [Comment("""Settings for betflip command""")] + public BetFlipConfig BetFlip { get; set; } + + [Comment("""Settings for betroll command""")] + public BetRollConfig BetRoll { get; set; } + + [Comment("""Automatic currency generation settings.""")] + public GenerationConfig Generation { get; set; } + + [Comment(""" + Settings for timely command + (letting people claim X amount of currency every Y hours) + """)] + public TimelyConfig Timely { get; set; } + + [Comment("""How much will each user's owned currency decay over time.""")] + public DecayConfig Decay { get; set; } + + [Comment("""What is the bot's cut on some transactions""")] + public BotCutConfig BotCuts { get; set; } + + [Comment("""Settings for LuckyLadder command""")] + public LuckyLadderSettings LuckyLadder { get; set; } + + [Comment("""Settings related to waifus""")] + public WaifuConfig Waifu { get; set; } + + [Comment(""" + Amount of currency selfhosters will get PER pledged dollar CENT. + 1 = 100 currency per $. Used almost exclusively on public ellie. + """)] + public decimal PatreonCurrencyPerCent { get; set; } = 1; + + [Comment(""" + Currency reward per vote. + This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting + """)] + public long VoteReward { get; set; } = 100; + + [Comment("""Slot config""")] + public SlotsConfig Slots { get; set; } + + public GamblingConfig() + { + BetRoll = new(); + Waifu = new(); + Currency = new(); + BetFlip = new(); + Generation = new(); + Timely = new(); + Decay = new(); + Slots = new(); + LuckyLadder = new(); + BotCuts = new(); + } +} + +public class CurrencyConfig +{ + [Comment("""What is the emoji/character which represents the currency""")] + public string Sign { get; set; } = "💵"; + + [Comment("""What is the name of the currency""")] + public string Name { get; set; } = "Ellie Money"; + + [Comment(""" + For how long (in days) will the transactions be kept in the database (curtrs) + Set 0 to disable cleanup (keep transactions forever) + """)] + public int TransactionsLifetime { get; set; } = 0; +} + +[Cloneable] +public partial class TimelyConfig +{ + [Comment(""" + How much currency will the users get every time they run .timely command + setting to 0 or less will disable this feature + """)] + public int Amount { get; set; } = 0; + + [Comment(""" + How often (in hours) can users claim currency with .timely command + setting to 0 or less will disable this feature + """)] + public int Cooldown { get; set; } = 24; +} + +[Cloneable] +public partial class BetFlipConfig +{ + [Comment("""Bet multiplier if user guesses correctly""")] + public decimal Multiplier { get; set; } = 1.95M; +} + +[Cloneable] +public partial class BetRollConfig +{ + [Comment(""" + When betroll is played, user will roll a number 0-100. + This setting will describe which multiplier is used for when the roll is higher than the given number. + Doesn't have to be ordered. + """)] + public BetRollPair[] Pairs { get; set; } = Array.Empty(); + + public BetRollConfig() + => Pairs = + [ + new() + { + WhenAbove = 99, + MultiplyBy = 10 + }, + new() + { + WhenAbove = 90, + MultiplyBy = 4 + }, + new() + { + WhenAbove = 66, + MultiplyBy = 2 + } + ]; +} + +[Cloneable] +public partial class GenerationConfig +{ + [Comment(""" + when currency is generated, should it also have a random password + associated with it which users have to type after the .pick command + in order to get it + """)] + public bool HasPassword { get; set; } = true; + + [Comment(""" + Every message sent has a certain % chance to generate the currency + specify the percentage here (1 being 100%, 0 being 0% - for example + default is 0.02, which is 2% + """)] + public decimal Chance { get; set; } = 0.02M; + + [Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")] + public int GenCooldown { get; set; } = 10; + + [Comment("""Minimum amount of currency that can spawn""")] + public int MinAmount { get; set; } = 1; + + [Comment(""" + Maximum amount of currency that can spawn. + Set to the same value as MinAmount to always spawn the same amount + """)] + public int MaxAmount { get; set; } = 1; +} + +[Cloneable] +public partial class DecayConfig +{ + [Comment(""" + Percentage of user's current currency which will be deducted every 24h. + 0 - 1 (1 is 100%, 0.5 50%, 0 disabled) + """)] + public decimal Percent { get; set; } = 0; + + [Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")] + public int MaxDecay { get; set; } = 0; + + [Comment("""Only users who have more than this amount will have their currency decay.""")] + public int MinThreshold { get; set; } = 99; + + [Comment("""How often, in hours, does the decay run. Default is 24 hours""")] + public int HourInterval { get; set; } = 24; +} + +[Cloneable] +public partial class LuckyLadderSettings +{ + [Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")] + public decimal[] Multipliers { get; set; } + + public LuckyLadderSettings() + => Multipliers = [2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M]; +} + +[Cloneable] +public sealed partial class WaifuConfig +{ + [Comment("""Minimum price a waifu can have""")] + public long MinPrice { get; set; } = 50; + + public MultipliersData Multipliers { get; set; } = new(); + + [Comment(""" + Settings for periodic waifu price decay. + Waifu price decays only if the waifu has no claimer. + """)] + public WaifuDecayConfig Decay { get; set; } = new(); + + [Comment(""" + List of items available for gifting. + If negative is true, gift will instead reduce waifu value. + """)] + public List Items { get; set; } = []; + + public WaifuConfig() + => Items = + [ + new("🥔", 5, "Potato"), + new("🍪", 10, "Cookie"), + new("🥖", 20, "Bread"), + new("🍭", 30, "Lollipop"), + new("🌹", 50, "Rose"), + new("🍺", 70, "Beer"), + new("🌮", 85, "Taco"), + new("💌", 100, "LoveLetter"), + new("🥛", 125, "Milk"), + new("🍕", 150, "Pizza"), + new("🍫", 200, "Chocolate"), + new("🍦", 250, "Icecream"), + new("🍣", 300, "Sushi"), + new("🍚", 400, "Rice"), + new("🍉", 500, "Watermelon"), + new("🍱", 600, "Bento"), + new("🎟", 800, "MovieTicket"), + new("🍰", 1000, "Cake"), + new("📔", 1500, "Book"), + new("🐱", 2000, "Cat"), + new("🐶", 2001, "Dog"), + new("🐼", 2500, "Panda"), + new("💄", 3000, "Lipstick"), + new("👛", 3500, "Purse"), + new("📱", 4000, "iPhone"), + new("👗", 4500, "Dress"), + new("💻", 5000, "Laptop"), + new("🎻", 7500, "Violin"), + new("🎹", 8000, "Piano"), + new("🚗", 9000, "Car"), + new("💍", 10000, "Ring"), + new("🛳", 12000, "Ship"), + new("🏠", 15000, "House"), + new("🚁", 20000, "Helicopter"), + new("🚀", 30000, "Spaceship"), + new("🌕", 50000, "Moon") + ]; + + public class WaifuDecayConfig + { + [Comment(""" + Percentage (0 - 100) of the waifu value to reduce. + Set 0 to disable + For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$) + """)] + public int Percent { get; set; } = 0; + + [Comment("""How often to decay waifu values, in hours""")] + public int HourInterval { get; set; } = 24; + + [Comment(""" + Minimum waifu price required for the decay to be applied. + For example if this value is set to 300, any waifu with the price 300 or less will not experience decay. + """)] + public long MinPrice { get; set; } = 300; + } +} + +[Cloneable] +public sealed partial class MultipliersData +{ + [Comment(""" + Multiplier for waifureset. Default 150. + Formula (at the time of writing this): + price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up + """)] + public int WaifuReset { get; set; } = 150; + + [Comment(""" + The minimum amount of currency that you have to pay + in order to buy a waifu who doesn't have a crush on you. + Default is 1.1 + Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her. + (100 * 1.1 = 110) + """)] + public decimal NormalClaim { get; set; } = 1.1m; + + [Comment(""" + The minimum amount of currency that you have to pay + in order to buy a waifu that has a crush on you. + Default is 0.88 + Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her. + (100 * 0.88 = 88) + """)] + public decimal CrushClaim { get; set; } = 0.88M; + + [Comment(""" + When divorcing a waifu, her new value will be her current value multiplied by this number. + Default 0.75 (meaning will lose 25% of her value) + """)] + public decimal DivorceNewValue { get; set; } = 0.75M; + + [Comment(""" + All gift prices will be multiplied by this number. + Default 1 (meaning no effect) + """)] + public decimal AllGiftPrices { get; set; } = 1.0M; + + [Comment(""" + What percentage of the value of the gift will a waifu gain when she's gifted. + Default 0.95 (meaning 95%) + Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095) + """)] + public decimal GiftEffect { get; set; } = 0.95M; + + [Comment(""" + What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'. + Default 0.5 (meaning 50%) + Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950) + """)] + public decimal NegativeGiftEffect { get; set; } = 0.50M; +} + +public sealed class SlotsConfig +{ + [Comment("""Hex value of the color which the numbers on the slot image will have.""")] + public Rgba32 CurrencyFontColor { get; set; } = Color.Red; +} + +[Cloneable] +public sealed partial class WaifuItemModel +{ + public string ItemEmoji { get; set; } + public long Price { get; set; } + public string Name { get; set; } + + [YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)] + public bool Negative { get; set; } + + public WaifuItemModel() + { + } + + public WaifuItemModel( + string itemEmoji, + long price, + string name, + bool negative = false) + { + ItemEmoji = itemEmoji; + Price = price; + Name = name; + Negative = negative; + } + + + public override string ToString() + => Name; +} + +[Cloneable] +public sealed partial class BetRollPair +{ + public int WhenAbove { get; set; } + public float MultiplyBy { get; set; } +} + +[Cloneable] +public sealed partial class BotCutConfig +{ + [Comment(""" + Shop sale cut percentage. + Whenever a user buys something from the shop, bot will take a cut equal to this percentage. + The rest goes to the user who posted the item/role/whatever to the shop. + This is a good way to reduce the amount of currency in circulation therefore keeping the inflation in check. + Default 0.1 (10%). + """)] + public decimal ShopSaleCut { get; set; } = 0.1m; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingConfigService.cs b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs new file mode 100644 index 0000000..6fb8ff2 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingConfigService.cs @@ -0,0 +1,194 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Modules.Gambling.Common; + +namespace EllieBot.Modules.Gambling.Services; + +public sealed class GamblingConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/gambling.yml"; + private static readonly TypedKey _changeKey = new("config.gambling.updated"); + + public override string Name + => "gambling"; + + private readonly IEnumerable _antiGiftSeed = new[] + { + new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true), + new WaifuItemModel("🧻", 10000, "ToiletPaper", true) + }; + + public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("currency.name", + gs => gs.Currency.Name, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("currency.sign", + gs => gs.Currency.Sign, + ConfigParsers.String, + ConfigPrinters.ToString); + + AddParsedProp("minbet", + gs => gs.MinBet, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("maxbet", + gs => gs.MaxBet, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("gen.min", + gs => gs.Generation.MinAmount, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("gen.max", + gs => gs.Generation.MaxAmount, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("gen.cd", + gs => gs.Generation.GenCooldown, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("gen.chance", + gs => gs.Generation.Chance, + decimal.TryParse, + ConfigPrinters.ToString, + val => val is >= 0 and <= 1); + + AddParsedProp("gen.has_pw", + gs => gs.Generation.HasPassword, + bool.TryParse, + ConfigPrinters.ToString); + + AddParsedProp("bf.multi", + gs => gs.BetFlip.Multiplier, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 1); + + AddParsedProp("waifu.min_price", + gs => gs.Waifu.MinPrice, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.reset", + gs => gs.Waifu.Multipliers.WaifuReset, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.crush_claim", + gs => gs.Waifu.Multipliers.CrushClaim, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.normal_claim", + gs => gs.Waifu.Multipliers.NormalClaim, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.divorce_value", + gs => gs.Waifu.Multipliers.DivorceNewValue, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.all_gifts", + gs => gs.Waifu.Multipliers.AllGiftPrices, + decimal.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + AddParsedProp("waifu.multi.gift_effect", + gs => gs.Waifu.Multipliers.GiftEffect, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("waifu.multi.negative_gift_effect", + gs => gs.Waifu.Multipliers.NegativeGiftEffect, + decimal.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("decay.percent", + gs => gs.Decay.Percent, + decimal.TryParse, + ConfigPrinters.ToString, + val => val is >= 0 and <= 1); + + AddParsedProp("decay.maxdecay", + gs => gs.Decay.MaxDecay, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + AddParsedProp("decay.threshold", + gs => gs.Decay.MinThreshold, + int.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + + Migrate(); + } + + public void Migrate() + { + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Waifu.Items = c.Waifu.Items.Concat(_antiGiftSeed).ToList(); + c.Version = 2; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.VoteReward = 100; + }); + } + + if (data.Version < 5) + { + ModifyConfig(c => + { + c.Version = 5; + }); + } + + if (data.Version < 6) + { + ModifyConfig(c => + { + c.Version = 6; + }); + } + + if (data.Version < 7) + { + ModifyConfig(c => + { + c.Version = 7; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingService.cs b/src/EllieBot/Modules/Gambling/GamblingService.cs new file mode 100644 index 0000000..a324437 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingService.cs @@ -0,0 +1,220 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Connect4; + +namespace EllieBot.Modules.Gambling.Services; + +public class GamblingService : IEService, IReadyExecutor +{ + public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new(); + public ConcurrentDictionary Connect4Games { get; } = new(); + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IBotCache _cache; + private readonly GamblingConfigService _gss; + + private static readonly TypedKey _curDecayKey = new("currency:last_decay"); + + public GamblingService( + DbService db, + DiscordSocketClient client, + IBotCache cache, + GamblingConfigService gss) + { + _db = db; + _client = client; + _cache = cache; + _gss = gss; + } + + public Task OnReadyAsync() + => Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync()); + + private async Task TransactionClearLoopAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var lifetime = _gss.Data.Currency.TransactionsLifetime; + if (lifetime <= 0) + continue; + + var now = DateTime.UtcNow; + var days = TimeSpan.FromDays(lifetime); + await using var uow = _db.GetDbContext(); + await uow.Set() + .DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days); + } + catch (Exception ex) + { + Log.Warning(ex, + "An unexpected error occurred in transactions cleanup loop: {ErrorMessage}", + ex.Message); + } + } + } + + private async Task CurrencyDecayLoopAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var config = _gss.Data; + var maxDecay = config.Decay.MaxDecay; + if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0) + continue; + + var now = DateTime.UtcNow; + + await using var uow = _db.GetDbContext(); + var result = await _cache.GetAsync(_curDecayKey); + + if (result.TryPickT0(out var bin, out _) + && (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval))) + { + continue; + } + + Log.Information(""" + --- Decaying users' currency --- + | decay: {ConfigDecayPercent}% + | max: {MaxDecay} + | threshold: {DecayMinTreshold} + """, + config.Decay.Percent * 100, + maxDecay, + config.Decay.MinThreshold); + + if (maxDecay == 0) + maxDecay = int.MaxValue; + + var decay = (double)config.Decay.Percent; + await uow.Set() + .Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id) + .UpdateAsync(old => new() + { + CurrencyAmount = + maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5) + ? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5)) + : old.CurrencyAmount - maxDecay + }); + + await uow.SaveChangesAsync(); + + await _cache.AddAsync(_curDecayKey, now.ToBinary()); + } + catch (Exception ex) + { + Log.Warning(ex, + "An unexpected error occurred in currency decay loop: {ErrorMessage}", + ex.Message); + } + } + } + + private static readonly TypedKey _ecoKey = new("ellie:economy"); + + public async Task GetEconomyAsync() + { + var data = await _cache.GetOrAddAsync(_ecoKey, + async () => + { + await using var uow = _db.GetDbContext(); + var cash = uow.Set().GetTotalCurrency(); + var onePercent = uow.Set().GetTopOnePercentCurrency(_client.CurrentUser.Id); + decimal planted = uow.Set().AsQueryable().Sum(x => x.Amount); + var waifus = uow.Set().GetTotalValue(); + var bot = await uow.Set().GetUserCurrencyAsync(_client.CurrentUser.Id); + decimal bank = await uow.GetTable() + .SumAsyncLinqToDB(x => x.Balance); + + var result = new EconomyResult + { + Cash = cash, + Planted = planted, + Bot = bot, + Waifus = waifus, + OnePercent = onePercent, + Bank = bank + }; + + return result; + }, + TimeSpan.FromMinutes(3)); + + return data; + } + + + private static readonly SemaphoreSlim _timelyLock = new(1, 1); + + private static TypedKey> _timelyKey + = new("timely:claims"); + + public async Task ClaimTimelyAsync(ulong userId, int period) + { + if (period == 0) + return null; + + await _timelyLock.WaitAsync(); + try + { + // get the dictionary from the cache or get a new one + var dict = (await _cache.GetOrAddAsync(_timelyKey, + () => Task.FromResult(new Dictionary())))!; + + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + // try to get users last claim + if (!dict.TryGetValue(userId, out var lastB)) + lastB = dict[userId] = now.ToBinary(); + + var diff = now - DateTime.FromBinary(lastB); + + // if its now, or too long ago => success + if (lastB == nowB || diff > period.Hours()) + { + // update the cache + dict[userId] = nowB; + await _cache.AddAsync(_timelyKey, dict); + + return null; + } + else + { + // otherwise return the remaining time + return period.Hours() - diff; + } + } + finally + { + _timelyLock.Release(); + } + } + + public bool UserHasTimelyReminder(ulong userId) + { + var db = _db.GetDbContext(); + return db.GetTable().Any(x => x.UserId == userId + && x.Type == ReminderType.Timely); + } + + public async Task RemoveAllTimelyClaimsAsync() + => await _cache.RemoveAsync(_timelyKey); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs new file mode 100644 index 0000000..25cbb73 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/GamblingTopLevelModule.cs @@ -0,0 +1,68 @@ +#nullable disable +using EllieBot.Modules.Gambling.Services; +using System.Numerics; + +namespace EllieBot.Modules.Gambling.Common; + +public abstract class GamblingModule : EllieModule +{ + protected GamblingConfig Config + => _lazyConfig.Value; + + protected string CurrencySign + => Config.Currency.Sign; + + protected string CurrencyName + => Config.Currency.Name; + + private readonly Lazy _lazyConfig; + + protected GamblingModule(GamblingConfigService gambService) + => _lazyConfig = new(() => gambService.Data); + + private async Task InternalCheckBet(long amount) + { + if (amount < 1) + return false; + + if (amount < Config.MinBet) + { + await Response().Error(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign)).SendAsync(); + return false; + } + + if (Config.MaxBet > 0 && amount > Config.MaxBet) + { + await Response().Error(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign)).SendAsync(); + return false; + } + + return true; + } + + protected string N(T cur) + where T : INumber + => CurrencyHelper.N(cur, Culture, CurrencySign); + + protected Task CheckBetMandatory(long amount) + { + if (amount < 1) + return Task.FromResult(false); + return InternalCheckBet(amount); + } + + protected Task CheckBetOptional(long amount) + { + if (amount == 0) + return Task.FromResult(true); + return InternalCheckBet(amount); + } +} + +public abstract class GamblingSubmodule : GamblingModule +{ + protected GamblingSubmodule(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/InputRpsPick.cs b/src/EllieBot/Modules/Gambling/InputRpsPick.cs new file mode 100644 index 0000000..d0c76f0 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/InputRpsPick.cs @@ -0,0 +1,3 @@ +#nullable disable +namespace EllieBot.Modules.Gambling; + diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs new file mode 100644 index 0000000..67994c8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs @@ -0,0 +1,114 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class PlantPickCommands : GamblingSubmodule + { + private readonly ILogCommandService _logService; + + public PlantPickCommands(ILogCommandService logService, GamblingConfigService gss) + : base(gss) + => _logService = logService; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Pick(string pass = null) + { + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return; + + var picked = await _service.PickAsync(ctx.Guild.Id, (ITextChannel)ctx.Channel, ctx.User.Id, pass); + + if (picked > 0) + { + var msg = await Response().NoReply().Confirm(strs.picked(N(picked), ctx.User)).SendAsync(); + msg.DeleteAfter(10); + } + + if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages) + { + try + { + _logService.AddDeleteIgnore(ctx.Message.Id); + await ctx.Message.DeleteAsync(); + } + catch { } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null) + { + if (amount < 1) + return; + + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return; + + if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages) + { + _logService.AddDeleteIgnore(ctx.Message.Id); + await ctx.Message.DeleteAsync(); + } + + var success = await _service.PlantAsync(ctx.Guild.Id, + ctx.Channel, + ctx.User.Id, + ctx.User.ToString(), + amount, + pass); + + if (!success) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] +#if GLOBAL_ELLIE + [OwnerOnly] +#endif + public async Task GenCurrency() + { + var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id); + if (enabled) + await Response().Confirm(strs.curgen_enabled).SendAsync(); + else + await Response().Confirm(strs.curgen_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [OwnerOnly] + public Task GenCurList(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + var enabledIn = _service.GetAllGeneratingChannels(); + + return Response() + .Paginated() + .Items(enabledIn.ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithDescription("-"); + + return items.Aggregate(_sender.CreateEmbed().WithOkColor(), + (eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId)); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs new file mode 100644 index 0000000..6b50a1e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/PlantPick/PlantPickService.cs @@ -0,0 +1,385 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling.Services; + +public class PlantPickService : IEService, IExecNoCommand +{ + //channelId/last generation + public ConcurrentDictionary LastGenerations { get; } = new(); + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly IImageCache _images; + private readonly FontProvider _fonts; + private readonly ICurrencyService _cs; + private readonly CommandHandler _cmdHandler; + private readonly EllieRandom _rng; + private readonly DiscordSocketClient _client; + private readonly GamblingConfigService _gss; + + private readonly ConcurrentHashSet _generationChannels; + private readonly SemaphoreSlim _pickLock = new(1, 1); + + public PlantPickService( + DbService db, + CommandHandler cmd, + IBotStrings strings, + IImageCache images, + FontProvider fonts, + ICurrencyService cs, + CommandHandler cmdHandler, + DiscordSocketClient client, + GamblingConfigService gss) + { + _db = db; + _strings = strings; + _images = images; + _fonts = fonts; + _cs = cs; + _cmdHandler = cmdHandler; + _rng = new(); + _client = client; + _gss = gss; + + using var uow = db.GetDbContext(); + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .AsQueryable() + .Include(x => x.GenerateCurrencyChannelIds) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + _generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId))); + } + + public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + => PotentialFlowerGeneration(msg); + + private string GetText(ulong gid, LocStr str) + => _strings.GetText(str, gid); + + public bool ToggleCurrencyGeneration(ulong gid, ulong cid) + { + bool enabled; + using var uow = _db.GetDbContext(); + var guildConfig = uow.GuildConfigsForId(gid, set => set.Include(gc => gc.GenerateCurrencyChannelIds)); + + var toAdd = new GCChannelId + { + ChannelId = cid + }; + if (!guildConfig.GenerateCurrencyChannelIds.Contains(toAdd)) + { + guildConfig.GenerateCurrencyChannelIds.Add(toAdd); + _generationChannels.Add(cid); + enabled = true; + } + else + { + var toDelete = guildConfig.GenerateCurrencyChannelIds.FirstOrDefault(x => x.Equals(toAdd)); + if (toDelete is not null) + uow.Remove(toDelete); + _generationChannels.TryRemove(cid); + enabled = false; + } + + uow.SaveChanges(); + return enabled; + } + + public IEnumerable GetAllGeneratingChannels() + { + using var uow = _db.GetDbContext(); + var chs = uow.Set().GetGeneratingChannels(); + return chs; + } + + /// + /// Get a random currency image stream, with an optional password sticked onto it. + /// + /// Optional password to add to top left corner. + /// Extension of the file, defaults to png + /// Stream of the currency image + public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass) + { + var curImg = await _images.GetCurrencyImageAsync(); + + if (string.IsNullOrWhiteSpace(pass)) + { + // determine the extension + using var load = _ = Image.Load(curImg, out var format); + + // return the image + return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png"); + } + + // get the image stream and extension + return AddPassword(curImg, pass); + } + + /// + /// Add a password to the image. + /// + /// Image to add password to. + /// Password to add to top left corner. + /// Image with the password in the top left corner. + private (Stream, string) AddPassword(byte[] curImg, string pass) + { + // draw lower, it looks better + pass = pass.TrimTo(10, true).ToLowerInvariant(); + using var img = Image.Load(curImg, out var format); + // choose font size based on the image height, so that it's visible + var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold); + img.Mutate(x => + { + // measure the size of the text to be drawing + var size = TextMeasurer.Measure(pass, new TextOptions(font) + { + Origin = new PointF(0, 0) + }); + + // fill the background with black, add 5 pixels on each side to make it look better + x.FillPolygon(Color.ParseHex("00000080"), + new PointF(0, 0), + new PointF(size.Width + 5, 0), + new PointF(size.Width + 5, size.Height + 10), + new PointF(0, size.Height + 10)); + + // draw the password over the background + x.DrawText(pass, font, Color.White, new(0, 0)); + }); + // return image as a stream for easy sending + return (img.ToStream(format), format.FileExtensions.FirstOrDefault() ?? "png"); + } + + private Task PotentialFlowerGeneration(IUserMessage imsg) + { + if (imsg is not SocketUserMessage msg || msg.Author.IsBot) + return Task.CompletedTask; + + if (imsg.Channel is not ITextChannel channel) + return Task.CompletedTask; + + if (!_generationChannels.Contains(channel.Id)) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + var config = _gss.Data; + var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary()); + var rng = new EllieRandom(); + + if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown) + < DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again + return; + + var num = rng.Next(1, 101) + (config.Generation.Chance * 100); + if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration)) + { + var dropAmount = config.Generation.MinAmount; + var dropAmountMax = config.Generation.MaxAmount; + + if (dropAmountMax > dropAmount) + dropAmount = new EllieRandom().Next(dropAmount, dropAmountMax + 1); + + if (dropAmount > 0) + { + var prefix = _cmdHandler.GetPrefix(channel.Guild.Id); + var toSend = dropAmount == 1 + ? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign)) + + " " + + GetText(channel.GuildId, strs.pick_sn(prefix)) + : GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign)) + + " " + + GetText(channel.GuildId, strs.pick_pl(prefix)); + + var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null; + + IUserMessage sent; + var (stream, ext) = await GetRandomCurrencyImageAsync(pw); + + await using (stream) + sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend); + + await AddPlantToDatabase(channel.GuildId, + channel.Id, + _client.CurrentUser.Id, + sent.Id, + dropAmount, + pw); + } + } + } + catch + { + } + }); + return Task.CompletedTask; + } + + /// + /// Generate a hexadecimal string from 1000 to ffff. + /// + /// A hexadecimal string from 1000 to ffff + private string GenerateCurrencyPassword() + { + // generate a number from 1000 to ffff + var num = _rng.Next(4096, 65536); + // convert it to hexadecimal + return num.ToString("x4"); + } + + public async Task PickAsync( + ulong gid, + ITextChannel ch, + ulong uid, + string pass) + { + await _pickLock.WaitAsync(); + try + { + long amount; + ulong[] ids; + await using (var uow = _db.GetDbContext()) + { + // this method will sum all plants with that password, + // remove them, and get messageids of the removed plants + + pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant(); + // gets all plants in this channel with the same password + var entries = uow.Set().AsQueryable() + .Where(x => x.ChannelId == ch.Id && pass == x.Password) + .ToList(); + // sum how much currency that is, and get all of the message ids (so that i can delete them) + amount = entries.Sum(x => x.Amount); + ids = entries.Select(x => x.MessageId).ToArray(); + // remove them from the database + uow.RemoveRange(entries); + + + if (amount > 0) + // give the picked currency to the user + await _cs.AddAsync(uid, amount, new("currency", "collect")); + await uow.SaveChangesAsync(); + } + + try + { + // delete all of the plant messages which have just been picked + _ = ch.DeleteMessagesAsync(ids); + } + catch { } + + // return the amount of currency the user picked + return amount; + } + finally + { + _pickLock.Release(); + } + } + + public async Task SendPlantMessageAsync( + ulong gid, + IMessageChannel ch, + string user, + long amount, + string pass) + { + try + { + // get the text + var prefix = _cmdHandler.GetPrefix(gid); + var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign)); + + if (amount > 1) + msgToSend += " " + GetText(gid, strs.pick_pl(prefix)); + else + msgToSend += " " + GetText(gid, strs.pick_sn(prefix)); + + //get the image + var (stream, ext) = await GetRandomCurrencyImageAsync(pass); + // send it + await using (stream) + { + var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend); + // return sent message's id (in order to be able to delete it when it's picked) + return msg.Id; + } + } + catch (Exception ex) + { + // if sending fails, return null as message id + Log.Warning(ex, "Sending plant message failed: {Message}", ex.Message); + return null; + } + } + + public async Task PlantAsync( + ulong gid, + IMessageChannel ch, + ulong uid, + string user, + long amount, + string pass) + { + // normalize it - no more than 10 chars, uppercase + pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant(); + // has to be either null or alphanumeric + if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric()) + return false; + + // remove currency from the user who's planting + if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put"))) + { + // try to send the message with the currency image + var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass); + if (msgId is null) + { + // if it fails it will return null, if it returns null, refund + await _cs.AddAsync(uid, amount, new("put/collect", "refund")); + return false; + } + + // if it doesn't fail, put the plant in the database for other people to pick + await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass); + return true; + } + + // if user doesn't have enough currency, fail + return false; + } + + private async Task AddPlantToDatabase( + ulong gid, + ulong cid, + ulong uid, + ulong mid, + long amount, + string pass) + { + await using var uow = _db.GetDbContext(); + uow.Set().Add(new() + { + Amount = amount, + GuildId = gid, + ChannelId = cid, + Password = pass, + UserId = uid, + MessageId = mid + }); + await uow.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs new file mode 100644 index 0000000..513aa59 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleCommands.cs @@ -0,0 +1,61 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + public partial class CurrencyRaffleCommands : GamblingSubmodule + { + public enum Mixed { Mixed } + + public CurrencyRaffleCommands(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task RaffleCur(Mixed _, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + => RaffleCur(amount, true); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task RaffleCur([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, bool mixed = false) + { + if (!await CheckBetMandatory(amount)) + return; + + async Task OnEnded(IUser arg, long won) + { + await Response() + .Confirm(GetText(strs.rafflecur_ended(CurrencyName, + Format.Bold(arg.ToString()), + won + CurrencySign))) + .SendAsync(); + } + + var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded); + + if (res.Item1 is not null) + { + await Response() + .Confirm(GetText(strs.rafflecur(res.Item1.GameType.ToString())), + string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")), + footer: GetText(strs.rafflecur_joined(ctx.User.ToString()))) + .SendAsync(); + } + else + { + if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount) + await Response().Error(strs.rafflecur_already_joined).SendAsync(); + else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency) + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs new file mode 100644 index 0000000..d6f5770 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs @@ -0,0 +1,69 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public class CurrencyRaffleGame +{ + public enum Type + { + Mixed, + Normal + } + + public IEnumerable Users + => _users; + + public Type GameType { get; } + + private readonly HashSet _users = new(); + + public CurrencyRaffleGame(Type type) + => GameType = type; + + public bool AddUser(IUser usr, long amount) + { + // if game type is normal, and someone already joined the game + // (that's the user who created it) + if (GameType == Type.Normal && _users.Count > 0 && _users.First().Amount != amount) + return false; + + if (!_users.Add(new() + { + DiscordUser = usr, + Amount = amount + })) + return false; + + return true; + } + + public User GetWinner() + { + var rng = new EllieRandom(); + if (GameType == Type.Mixed) + { + var num = rng.NextLong(0L, Users.Sum(x => x.Amount)); + var sum = 0L; + foreach (var u in Users) + { + sum += u.Amount; + if (sum > num) + return u; + } + } + + var usrs = _users.ToArray(); + return usrs[rng.Next(0, usrs.Length)]; + } + + public class User + { + public IUser DiscordUser { get; set; } + public long Amount { get; set; } + + public override int GetHashCode() + => DiscordUser.GetHashCode(); + + public override bool Equals(object obj) + => obj is User u ? u.DiscordUser == DiscordUser : false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs new file mode 100644 index 0000000..743549e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Raffle/CurrencyRaffleService.cs @@ -0,0 +1,81 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; + +namespace EllieBot.Modules.Gambling.Services; + +public class CurrencyRaffleService : IEService +{ + public enum JoinErrorType + { + NotEnoughCurrency, + AlreadyJoinedOrInvalidAmount + } + + public Dictionary Games { get; } = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly ICurrencyService _cs; + + public CurrencyRaffleService(ICurrencyService cs) + => _cs = cs; + + public async Task<(CurrencyRaffleGame, JoinErrorType?)> JoinOrCreateGame( + ulong channelId, + IUser user, + long amount, + bool mixed, + Func onEnded) + { + await _locker.WaitAsync(); + try + { + var newGame = false; + if (!Games.TryGetValue(channelId, out var crg)) + { + newGame = true; + crg = new(mixed ? CurrencyRaffleGame.Type.Mixed : CurrencyRaffleGame.Type.Normal); + Games.Add(channelId, crg); + } + + //remove money, and stop the game if this + // user created it and doesn't have the money + if (!await _cs.RemoveAsync(user.Id, amount, new("raffle", "join"))) + { + if (newGame) + Games.Remove(channelId); + return (null, JoinErrorType.NotEnoughCurrency); + } + + if (!crg.AddUser(user, amount)) + { + await _cs.AddAsync(user.Id, amount, new("raffle", "refund")); + return (null, JoinErrorType.AlreadyJoinedOrInvalidAmount); + } + + if (newGame) + { + _ = Task.Run(async () => + { + await Task.Delay(60000); + await _locker.WaitAsync(); + try + { + var winner = crg.GetWinner(); + var won = crg.Users.Sum(x => x.Amount); + + await _cs.AddAsync(winner.DiscordUser.Id, won, new("raffle", "win")); + Games.Remove(channelId, out _); + _ = onEnded(winner.DiscordUser, won); + } + catch { } + finally { _locker.Release(); } + }); + } + + return (crg, null); + } + finally + { + _locker.Release(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/IShopService.cs b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs new file mode 100644 index 0000000..f8d19f8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/IShopService.cs @@ -0,0 +1,46 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public interface IShopService +{ + /// + /// Changes the price of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item price + /// Success status + Task ChangeEntryPriceAsync(ulong guildId, int index, int newPrice); + + /// + /// Changes the name of a shop item + /// + /// Id of the guild in which the shop is + /// Index of the item + /// New item name + /// Success status + Task ChangeEntryNameAsync(ulong guildId, int index, string newName); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// First entry's index + /// Second entry's index + /// Whether swap was successful + Task SwapEntriesAsync(ulong guildId, int index1, int index2); + + /// + /// Swaps indexes of 2 items in the shop + /// + /// Id of the guild in which the shop is + /// Current index of the entry to move + /// Destination index of the entry + /// Whether swap was successful + Task MoveEntryAsync(ulong guildId, int fromIndex, int toIndex); + + Task SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId); + Task AddShopCommandAsync(ulong guildId, ulong userId, int price, string command); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs new file mode 100644 index 0000000..8b5230b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/ShopCommands.cs @@ -0,0 +1,590 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; +using EllieBot.Modules.Administration; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class ShopCommands : GamblingSubmodule + { + public enum List + { + List + } + + public enum Role + { + Role + } + + public enum Command + { + Command, + Cmd + } + + private readonly DbService _db; + private readonly ICurrencyService _cs; + + public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf) + : base(gamblingConf) + { + _db = db; + _cs = cs; + } + + private Task ShopInternalAsync(int page = 0) + { + if (page < 0) + throw new ArgumentOutOfRangeException(nameof(page)); + + using var uow = _db.GetDbContext(); + var entries = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)) + .ShopEntries.ToIndexed(); + + return Response() + .Paginated() + .Items(entries.ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, curPage) => + { + if (!items.Any()) + return _sender.CreateEmbed().WithErrorColor().WithDescription(GetText(strs.shop_none)); + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.shop)); + + for (var i = 0; i < items.Count; i++) + { + var entry = items[i]; + embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}", + EntryToString(entry), + true); + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Shop(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ShopInternalAsync(page); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Buy(int index) + { + index -= 1; + if (index < 0) + return; + ShopEntry entry; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)); + var entries = new IndexedCollection(config.ShopEntries); + entry = entries.ElementAtOrDefault(index); + uow.SaveChanges(); + } + + if (entry is null) + { + await Response().Error(strs.shop_item_not_found).SendAsync(); + return; + } + + if (entry.RoleRequirement is ulong reqRoleId) + { + var role = ctx.Guild.GetRole(reqRoleId); + if (role is null) + { + await Response().Error(strs.shop_item_req_role_not_found).SendAsync(); + return; + } + + var guser = (IGuildUser)ctx.User; + if (!guser.RoleIds.Contains(reqRoleId)) + { + await Response() + .Error(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString()))) + .SendAsync(); + return; + } + } + + if (entry.Type == ShopEntryType.Role) + { + var guser = (IGuildUser)ctx.User; + var role = ctx.Guild.GetRole(entry.RoleId); + + if (role is null) + { + await Response().Error(strs.shop_role_not_found).SendAsync(); + return; + } + + if (guser.RoleIds.Any(id => id == role.Id)) + { + await Response().Error(strs.shop_role_already_bought).SendAsync(); + return; + } + + if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + try + { + await guser.AddRoleAsync(role); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding shop role"); + await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund")); + await Response().Error(strs.shop_role_purchase_error).SendAsync(); + return; + } + + var profit = GetProfitAmount(entry.Price); + await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}")); + await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut")); + await Response().Confirm(strs.shop_role_purchase(Format.Bold(role.Name))).SendAsync(); + return; + } + + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + else if (entry.Type == ShopEntryType.List) + { + if (entry.Items.Count == 0) + { + await Response().Error(strs.out_of_stock).SendAsync(); + return; + } + + var item = entry.Items.ToArray()[new EllieRandom().Next(0, entry.Items.Count)]; + + if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + await using (var uow = _db.GetDbContext()) + { + uow.Set().Remove(item); + await uow.SaveChangesAsync(); + } + + try + { + await Response() + .User(ctx.User) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name))) + .AddField(GetText(strs.item), item.Text) + .AddField(GetText(strs.price), entry.Price.ToString(), true) + .AddField(GetText(strs.name), entry.Name, true)) + .SendAsync(); + + await _cs.AddAsync(entry.AuthorId, + GetProfitAmount(entry.Price), + new("shop", "sell", entry.Name)); + } + catch + { + await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name)); + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries); + entry = entries.ElementAtOrDefault(index); + if (entry is not null) + { + if (entry.Items.Add(item)) + uow.SaveChanges(); + } + } + + await Response().Error(strs.shop_buy_error).SendAsync(); + return; + } + + await Response().Confirm(strs.shop_item_purchase).SendAsync(); + } + else + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + else if (entry.Type == ShopEntryType.Command) + { + var guild = ctx.Guild as SocketGuild; + var channel = ctx.Channel as ISocketMessageChannel; + var msg = ctx.Message as SocketUserMessage; + var user = await ctx.Guild.GetUserAsync(entry.AuthorId); + + if (guild is null || channel is null || msg is null || user is null) + { + await Response().Error(strs.shop_command_invalid_context).SendAsync(); + return; + } + + if (!await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString()))) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + else + { + var cmd = entry.Command.Replace("%you%", ctx.User.Id.ToString()); + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Executing shop command") + .WithDescription(cmd); + + var msgTask = Response().Embed(eb).SendAsync(); + + await _cs.AddAsync(entry.AuthorId, + GetProfitAmount(entry.Price), + new("shop", "sell", entry.Name)); + + await _cmdHandler.TryRunCommand(guild, + channel, + new DoAsUserMessage( + msg, + user, + cmd + )); + + try + { + var pendingMsg = await msgTask; + await pendingMsg.EditAsync( + SmartEmbedText.FromEmbed(eb + .WithOkColor() + .WithTitle("Shop command executed") + .Build())); + } + catch + { + } + } + } + } + + private long GetProfitAmount(int price) + => (int)Math.Ceiling((1.0m - Config.BotCuts.ShopSaleCut) * price); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ShopAdd(Command _, int price, [Leftover] string command) + { + if (price < 1) + return; + + + var entry = await _service.AddShopCommandAsync(ctx.Guild.Id, ctx.User.Id, price, command); + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(GuildPerm.ManageRoles)] + public async Task ShopAdd(Role _, int price, [Leftover] IRole role) + { + if (price < 1) + return; + + var entry = new ShopEntry + { + Name = "-", + Price = price, + Type = ShopEntryType.Role, + AuthorId = ctx.User.Id, + RoleId = role.Id, + RoleName = role.Name + }; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries) + { + entry + }; + uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries; + uow.SaveChanges(); + } + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopAdd(List _, int price, [Leftover] string name) + { + if (price < 1) + return; + + var entry = new ShopEntry + { + Name = name.TrimTo(100), + Price = price, + Type = ShopEntryType.List, + AuthorId = ctx.User.Id, + Items = new() + }; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries) + { + entry + }; + uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries; + uow.SaveChanges(); + } + + await Response().Embed(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopListAdd(int index, [Leftover] string itemText) + { + index -= 1; + if (index < 0) + return; + var item = new ShopEntryItem + { + Text = itemText + }; + ShopEntry entry; + var rightType = false; + var added = false; + await using (var uow = _db.GetDbContext()) + { + var entries = new IndexedCollection(uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries); + entry = entries.ElementAtOrDefault(index); + if (entry is not null && (rightType = entry.Type == ShopEntryType.List)) + { + if (entry.Items.Add(item)) + { + added = true; + uow.SaveChanges(); + } + } + } + + if (entry is null) + await Response().Error(strs.shop_item_not_found).SendAsync(); + else if (!rightType) + await Response().Error(strs.shop_item_wrong_type).SendAsync(); + else if (added == false) + await Response().Error(strs.shop_list_item_not_unique).SendAsync(); + else + await Response().Confirm(strs.shop_list_item_added).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopRemove(int index) + { + index -= 1; + if (index < 0) + return; + ShopEntry removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, + set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items)); + + var entries = new IndexedCollection(config.ShopEntries); + removed = entries.ElementAtOrDefault(index); + if (removed is not null) + { + uow.RemoveRange(removed.Items); + uow.Remove(removed); + uow.SaveChanges(); + } + } + + if (removed is null) + await Response().Error(strs.shop_item_not_found).SendAsync(); + else + await Response().Embed(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopChangePrice(int index, int price) + { + if (--index < 0 || price <= 0) + return; + + var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price); + if (succ) + { + await ShopInternalAsync(index / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopChangeName(int index, [Leftover] string newName) + { + if (--index < 0 || string.IsNullOrWhiteSpace(newName)) + return; + + var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName); + if (succ) + { + await ShopInternalAsync(index / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopSwap(int index1, int index2) + { + if (--index1 < 0 || --index2 < 0 || index1 == index2) + return; + + var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2); + if (succ) + { + await ShopInternalAsync(index1 / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopMove(int fromIndex, int toIndex) + { + if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex) + return; + + var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex); + if (succ) + { + await ShopInternalAsync(toIndex / 9); + await ctx.OkAsync(); + } + else + await ctx.ErrorAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ShopReq(int itemIndex, [Leftover] IRole role = null) + { + if (--itemIndex < 0) + return; + + var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id); + if (!succ) + { + await Response().Error(strs.shop_item_not_found).SendAsync(); + return; + } + + if (role is null) + await Response().Confirm(strs.shop_item_role_no_req(itemIndex)).SendAsync(); + else + await Response().Confirm(strs.shop_item_role_req(itemIndex + 1, role)).SendAsync(); + } + + public EmbedBuilder EntryToEmbed(ShopEntry entry) + { + var embed = _sender.CreateEmbed().WithOkColor(); + + if (entry.Type == ShopEntryType.Role) + { + return embed + .AddField(GetText(strs.name), + GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name + ?? "MISSING_ROLE"))), + true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), entry.Type.ToString(), true); + } + + if (entry.Type == ShopEntryType.List) + { + return embed.AddField(GetText(strs.name), entry.Name, true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), GetText(strs.random_unique_item), true); + } + + else if (entry.Type == ShopEntryType.Command) + { + return embed + .AddField(GetText(strs.name), Format.Code(entry.Command), true) + .AddField(GetText(strs.price), N(entry.Price), true) + .AddField(GetText(strs.type), entry.Type.ToString(), true); + } + + //else if (entry.Type == ShopEntryType.Infinite_List) + // return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true)) + // .AddField(GetText(strs.price), entry.Price.ToString(), true) + // .AddField(GetText(strs.type), entry.Type.ToString(), true); + return null; + } + + public string EntryToString(ShopEntry entry) + { + var prepend = string.Empty; + if (entry.RoleRequirement is not null) + prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>"))) + + Environment.NewLine; + + if (entry.Type == ShopEntryType.Role) + return prepend + + GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE"))); + if (entry.Type == ShopEntryType.List) + return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name; + + if (entry.Type == ShopEntryType.Command) + return prepend + Format.Code(entry.Command); + return prepend; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Shop/ShopService.cs b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs new file mode 100644 index 0000000..dfe944a --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Shop/ShopService.cs @@ -0,0 +1,127 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling.Services; + +public class ShopService : IShopService, IEService +{ + private readonly DbService _db; + + public ShopService(DbService db) + => _db = db; + + private IndexedCollection GetEntriesInternal(DbContext uow, ulong guildId) + => uow.GuildConfigsForId(guildId, + set => set.Include(x => x.ShopEntries) + .ThenInclude(x => x.Items)) + .ShopEntries.ToIndexed(); + + public async Task ChangeEntryPriceAsync(ulong guildId, int index, int newPrice) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(newPrice); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + entries[index].Price = newPrice; + await uow.SaveChangesAsync(); + return true; + } + + public async Task ChangeEntryNameAsync(ulong guildId, int index, string newName) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + + if (string.IsNullOrWhiteSpace(newName)) + throw new ArgumentNullException(nameof(newName)); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + entries[index].Name = newName.TrimTo(100); + await uow.SaveChangesAsync(); + return true; + } + + public async Task SwapEntriesAsync(ulong guildId, int index1, int index2) + { + ArgumentOutOfRangeException.ThrowIfNegative(index1); + ArgumentOutOfRangeException.ThrowIfNegative(index2); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2) + return false; + + entries[index1].Index = index2; + entries[index2].Index = index1; + + await uow.SaveChangesAsync(); + return true; + } + + public async Task MoveEntryAsync(ulong guildId, int fromIndex, int toIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(fromIndex); + ArgumentOutOfRangeException.ThrowIfNegative(toIndex); + + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex) + return false; + + var entry = entries[fromIndex]; + entries.RemoveAt(fromIndex); + entries.Insert(toIndex, entry); + + await uow.SaveChangesAsync(); + return true; + } + + public async Task SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId) + { + await using var uow = _db.GetDbContext(); + var entries = GetEntriesInternal(uow, guildId); + + if (index >= entries.Count) + return false; + + var entry = entries[index]; + + entry.RoleRequirement = roleId; + + await uow.SaveChangesAsync(); + return true; + } + + public async Task AddShopCommandAsync(ulong guildId, ulong userId, int price, string command) + { + await using var uow = _db.GetDbContext(); + + var entries = GetEntriesInternal(uow, guildId); + var entry = new ShopEntry() + { + AuthorId = userId, + Command = command, + Type = ShopEntryType.Command, + Price = price, + }; + entries.Add(entry); + uow.GuildConfigsForId(guildId, set => set).ShopEntries = entries; + + await uow.SaveChangesAsync(); + + return entry; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs new file mode 100644 index 0000000..238e97e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Slot/SlotCommands.cs @@ -0,0 +1,230 @@ +#nullable disable warnings +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Services; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using EllieBot.Modules.Gambling; +using EllieBot.Common.TypeReaders; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Gambling; + +public enum GamblingError +{ + InsufficientFunds, +} + +public partial class Gambling +{ + [Group] + public partial class SlotCommands : GamblingSubmodule + { + private static decimal totalBet; + private static decimal totalPaidOut; + + private readonly IImageCache _images; + private readonly FontProvider _fonts; + private readonly DbService _db; + private object _slotStatsLock = new(); + + public SlotCommands( + IImageCache images, + FontProvider fonts, + DbService db, + GamblingConfigService gamb) + : base(gamb) + { + _images = images; + _fonts = fonts; + _db = db; + } + + public Task Test() + => Task.CompletedTask; + + [Cmd] + public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount) + { + if (!await CheckBetMandatory(amount)) + return; + + // var slotInteraction = CreateSlotInteractionIntenal(amount); + + await ctx.Channel.TriggerTypingAsync(); + + if (await InternalSlotAsync(amount) is not SlotResult result) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var text = GetSlotMessageTextInternal(result); + + using var image = await GenerateSlotImageAsync(amount, result); + await using var imgStream = await image.ToStreamAsync(); + + + var eb = _sender.CreateEmbed() + .WithAuthor(ctx.User) + .WithDescription(Format.Bold(text)) + .WithImageUrl($"attachment://result.png") + .WithOkColor(); + + var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again"); + var inter = _inter.Create(ctx.User.Id, bb, smc => + { + smc.DeferAsync(); + return Slot(amount); + }); + + var msg = await ctx.Channel.SendFileAsync(imgStream, + "result.png", + embed: eb.Build(), + components: inter.CreateComponent() + ); + await inter.RunAsync(msg); + } + + // private SlotInteraction CreateSlotInteractionIntenal(long amount) + // { + // return new SlotInteraction((DiscordSocketClient)ctx.Client, + // ctx.User.Id, + // async (smc) => + // { + // try + // { + // if (await InternalSlotAsync(amount) is not SlotResult result) + // { + // await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true); + // return; + // } + // + // var msg = GetSlotMessageInternal(result); + // + // using var image = await GenerateSlotImageAsync(amount, result); + // await using var imgStream = await image.ToStreamAsync(); + // + // var guid = Guid.NewGuid(); + // var imgName = $"result_{guid}.png"; + // + // var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction(); + // + // await smc.Message.ModifyAsync(m => + // { + // m.Content = msg; + // m.Attachments = new[] + // { + // new FileAttachment(imgStream, imgName) + // }; + // m.Components = slotInteraction.CreateComponent(); + // }); + // + // _ = slotInteraction.RunAsync(smc.Message); + // } + // catch (Exception ex) + // { + // Log.Error(ex, "Error pulling slot again"); + // } + // // finally + // // { + // // await Task.Delay(1000); + // // _runningUsers.TryRemove(ctx.User.Id); + // // } + // }); + // } + + private string GetSlotMessageTextInternal(SlotResult result) + { + var multi = result.Multiplier.ToString("0.##"); + var msg = result.WinType switch + { + SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)), + SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)), + SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)), + SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)), + _ => GetText(strs.better_luck), + }; + return msg; + } + + private async Task InternalSlotAsync(long amount) + { + var maybeResult = await _service.SlotAsync(ctx.User.Id, amount); + + if (!maybeResult.TryPickT0(out var result, out var error)) + { + return null; + } + + lock (_slotStatsLock) + { + totalBet += amount; + totalPaidOut += result.Won; + } + + return result; + } + + private async Task> GenerateSlotImageAsync(long amount, SlotResult result) + { + long ownedAmount; + await using (var uow = _db.GetDbContext()) + { + ownedAmount = uow.Set().FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount + ?? 0; + } + + var slotBg = await _images.GetSlotBgAsync(); + var bgImage = Image.Load(slotBg, out _); + var numbers = new int[3]; + result.Rolls.CopyTo(numbers, 0); + + Color fontColor = Config.Slots.CurrencyFontColor; + + bgImage.Mutate(x => x.DrawText(new TextOptions(_fonts.DottyFont.CreateFont(65)) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrappingLength = 140, + Origin = new(298, 100) + }, + ((long)result.Won).ToString(), + fontColor)); + + var bottomFont = _fonts.DottyFont.CreateFont(50); + + bgImage.Mutate(x => x.DrawText(new TextOptions(bottomFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + WrappingLength = 135, + Origin = new(196, 480) + }, + amount.ToString(), + fontColor)); + + bgImage.Mutate(x => x.DrawText(new(bottomFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Origin = new(393, 480) + }, + ownedAmount.ToString(), + fontColor)); + //sw.PrintLap("drew red text"); + + for (var i = 0; i < 3; i++) + { + using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i])); + bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f)); + } + + return bgImage; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/VoteRewardService.cs b/src/EllieBot/Modules/Gambling/VoteRewardService.cs new file mode 100644 index 0000000..62d861b --- /dev/null +++ b/src/EllieBot/Modules/Gambling/VoteRewardService.cs @@ -0,0 +1,106 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Gambling.Services; + +public class VoteModel +{ + [JsonPropertyName("userId")] + public ulong UserId { get; set; } +} + +public class VoteRewardService : IEService, IReadyExecutor +{ + private readonly DiscordSocketClient _client; + private readonly IBotCredentials _creds; + private readonly ICurrencyService _currencyService; + private readonly GamblingConfigService _gamb; + + public VoteRewardService( + DiscordSocketClient client, + IBotCredentials creds, + ICurrencyService currencyService, + GamblingConfigService gamb) + { + _client = client; + _creds = creds; + _currencyService = currencyService; + _gamb = gamb; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var http = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + ServerCertificateCustomValidationCallback = delegate { return true; } + }); + + while (true) + { + await Task.Delay(30000); + + var topggKey = _creds.Votes?.TopggKey; + var topggServiceUrl = _creds.Votes?.TopggServiceUrl; + + try + { + if (!string.IsNullOrWhiteSpace(topggKey) && !string.IsNullOrWhiteSpace(topggServiceUrl)) + { + http.DefaultRequestHeaders.Authorization = new(topggKey); + var uri = new Uri(new(topggServiceUrl), "topgg/new"); + var res = await http.GetStringAsync(uri); + var data = JsonSerializer.Deserialize>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + _gamb.Data.VoteReward, + new("vote", "top.gg", "top.gg vote reward")); + + Log.Information("Rewarding {Count} top.gg voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading top.gg vote rewards"); + } + + var discordsKey = _creds.Votes?.DiscordsKey; + var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl; + + try + { + if (!string.IsNullOrWhiteSpace(discordsKey) && !string.IsNullOrWhiteSpace(discordsServiceUrl)) + { + http.DefaultRequestHeaders.Authorization = new(discordsKey); + var res = await http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new")); + var data = JsonSerializer.Deserialize>(res); + + if (data is { Count: > 0 }) + { + var ids = data.Select(x => x.UserId).ToList(); + + await _currencyService.AddBulkAsync(ids, + _gamb.Data.VoteReward, + new("vote", "discords", "discords.com vote reward")); + + Log.Information("Rewarding {Count} discords.com voters", ids.Count()); + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Critical error loading discords.com vote rewards"); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs new file mode 100644 index 0000000..5f7db11 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs @@ -0,0 +1,393 @@ +#nullable disable +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Waifu; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public partial class Gambling +{ + [Group] + public partial class WaifuClaimCommands : GamblingSubmodule + { + public WaifuClaimCommands(GamblingConfigService gamblingConfService) + : base(gamblingConfService) + { + } + + [Cmd] + public async Task WaifuReset() + { + var price = _service.GetResetPrice(ctx.User); + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.waifu_reset_confirm)) + .WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price))))); + + if (!await PromptUserConfirmAsync(embed)) + return; + + if (await _service.TryReset(ctx.User)) + { + await Response().Confirm(strs.waifu_reset).SendAsync(); + return; + } + + await Response().Error(strs.waifu_reset_fail).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WaifuClaim(long amount, [Leftover] IUser target) + { + if (amount < Config.Waifu.MinPrice) + { + await Response().Error(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign)).SendAsync(); + return; + } + + if (target.Id == ctx.User.Id) + { + await Response().Error(strs.waifu_not_yourself).SendAsync(); + return; + } + + var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount); + + if (result == WaifuClaimResult.InsufficientAmount) + { + await Response() + .Error( + strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f))))) + .SendAsync(); + return; + } + + if (result == WaifuClaimResult.NotEnoughFunds) + { + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + return; + } + + var msg = GetText(strs.waifu_claimed(Format.Bold(target.ToString()), N(amount))); + if (w.Affinity?.UserId == ctx.User.Id) + msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price))); + else + msg = " " + msg; + await Response().Confirm(ctx.User.Mention + msg).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuTransfer(ulong waifuId, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner)) + { + await Response().Error(strs.waifu_transfer_fail).SendAsync(); + return; + } + + await Response() + .Confirm(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task WaifuTransfer(IUser waifu, IUser newOwner) + { + if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner)) + { + await Response().Error(strs.waifu_transfer_fail).SendAsync(); + return; + } + + await Response() + .Confirm(strs.waifu_transfer_success(Format.Bold(waifu.ToString()), + Format.Bold(ctx.User.ToString()), + Format.Bold(newOwner.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(-1)] + public Task Divorce([Leftover] string target) + { + var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target); + if (waifuUserId == default) + return Response().Error(strs.waifu_not_yours).SendAsync(); + + return Divorce(waifuUserId); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task Divorce([Leftover] IGuildUser target) + => Divorce(target.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task Divorce([Leftover] ulong targetId) + { + if (targetId == ctx.User.Id) + return; + + var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId); + + if (result == DivorceResult.SucessWithPenalty) + { + await Response() + .Confirm(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()), + N(amount))) + .SendAsync(); + } + else if (result == DivorceResult.Success) + await Response().Confirm(strs.waifu_divorced_notlike(N(amount))).SendAsync(); + else if (result == DivorceResult.NotYourWife) + await Response().Error(strs.waifu_not_yours).SendAsync(); + else + { + await Response() + .Error(strs.waifu_recent_divorce( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Affinity([Leftover] IGuildUser user = null) + { + if (user?.Id == ctx.User.Id) + { + await Response().Error(strs.waifu_egomaniac).SendAsync(); + return; + } + + var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user); + if (!sucess) + { + if (remaining is not null) + { + await Response() + .Error(strs.waifu_affinity_cooldown( + Format.Bold(((int)remaining?.TotalHours).ToString()), + Format.Bold(remaining?.Minutes.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.waifu_affinity_already).SendAsync(); + + return; + } + + if (user is null) + await Response().Confirm(strs.waifu_affinity_reset).SendAsync(); + else if (oldAff is null) + await Response().Confirm(strs.waifu_affinity_set(Format.Bold(user.ToString()))).SendAsync(); + else + { + await Response() + .Confirm(strs.waifu_affinity_changed(Format.Bold(oldAff.ToString()), + Format.Bold(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WaifuLb(int page = 1) + { + page--; + + if (page < 0) + return; + + if (page > 100) + page = 100; + + var waifus = _service.GetTopWaifusAtPage(page).ToList(); + + if (waifus.Count == 0) + { + await Response().Confirm(strs.waifus_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor(); + + var i = 0; + foreach (var w in waifus) + { + var j = i++; + embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w)); + } + + await Response().Embed(embed).SendAsync(); + } + + private string GetLbString(WaifuLbResult w) + { + var claimer = "no one"; + var status = string.Empty; + + var waifuUsername = w.Username.TrimTo(20); + var claimerUsername = w.Claimer?.TrimTo(20); + + if (w.Claimer is not null) + claimer = $"{claimerUsername}#{w.ClaimerDiscrim}"; + if (w.Affinity is null) + status = $"... but {waifuUsername}'s heart is empty"; + else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim) + status = $"... and {waifuUsername} likes {claimerUsername} too <3"; + else + status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}"; + return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}"; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task WaifuInfo([Leftover] IUser target = null) + { + if (target is null) + target = ctx.User; + + return InternalWaifuInfo(target.Id, target.ToString()); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task WaifuInfo(ulong targetId) + => InternalWaifuInfo(targetId); + + private async Task InternalWaifuInfo(ulong targetId, string name = null) + { + var wi = await _service.GetFullWaifuInfoAsync(targetId); + var affInfo = _service.GetAffinityTitle(wi.AffinityCount); + + var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x); + + var nobody = GetText(strs.nobody); + var itemList = await _service.GetItems(wi.WaifuId); + var itemsStr = !itemList.Any() + ? "-" + : string.Join("\n", + itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _)) + .OrderByDescending(x => waifuItems[x.ItemEmoji].Price) + .GroupBy(x => x.ItemEmoji) + .Take(60) + .Select(x => $"{x.Key} x{x.Count(),-3}") + .Chunk(2) + .Select(x => string.Join(" ", x))); + + var claimsNames = (await _service.GetClaimNames(wi.WaifuId)); + var claimsStr = claimsNames + .Shuffle() + .Take(30) + .Join('\n'); + + var fansList = await _service.GetFansNames(wi.WaifuId); + var fansStr = fansList + .Shuffle() + .Take(30) + .Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x) + .Join('\n'); + + if (string.IsNullOrWhiteSpace(fansStr)) + fansStr = "-"; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.waifu) + + " " + + (wi.FullName ?? name ?? targetId.ToString()) + + " - \"the " + + _service.GetClaimTitle(wi.ClaimCount) + + "\"") + .AddField(GetText(strs.price), N(wi.Price), true) + .AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true) + .AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true) + .AddField(GetText(strs.changes_of_heart), $"{wi.AffinityCount} - \"the {affInfo}\"", true) + .AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true) + .AddField("\u200B", "\u200B", true) + .AddField(GetText(strs.fans(fansList.Count)), fansStr, true) + .AddField($"Waifus ({wi.ClaimCount})", + wi.ClaimCount == 0 ? nobody : claimsStr, + true) + .AddField(GetText(strs.gifts), itemsStr, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task WaifuGift(int page = 1) + { + if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9) + return; + + var waifuItems = _service.GetWaifuItems(); + await Response() + .Paginated() + .Items(waifuItems.OrderBy(x => x.Negative) + .ThenBy(x => x.Price) + .ToList()) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor(); + + items + .ToList() + .ForEach(x => embed.AddField( + $"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}", + Format.Bold(N(x.Price)), + true)); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task WaifuGift(string itemName, [Leftover] IUser waifu) + { + if (waifu.Id == ctx.User.Id) + return; + + var allItems = _service.GetWaifuItems(); + var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant()); + if (item is null) + { + await Response().Error(strs.waifu_gift_not_exist).SendAsync(); + return; + } + + var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item); + + if (sucess) + { + await Response() + .Confirm(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji), + Format.Bold(waifu.ToString()))) + .SendAsync(); + } + else + await Response().Error(strs.not_enough(CurrencySign)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs new file mode 100644 index 0000000..dded8a9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/WaifuService.cs @@ -0,0 +1,582 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Common; +using EllieBot.Modules.Gambling.Common.Waifu; + +namespace EllieBot.Modules.Gambling.Services; + +public class WaifuService : IEService, IReadyExecutor +{ + private readonly DbService _db; + private readonly ICurrencyService _cs; + private readonly IBotCache _cache; + private readonly GamblingConfigService _gss; + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + + public WaifuService( + DbService db, + ICurrencyService cs, + IBotCache cache, + GamblingConfigService gss, + IBotCredentials creds, + DiscordSocketClient client) + { + _db = db; + _cs = cs; + _cache = cache; + _gss = gss; + _creds = creds; + _client = client; + } + + public async Task WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner) + { + if (owner.Id == newOwner.Id || waifuId == newOwner.Id) + return false; + + var settings = _gss.Data; + + await using var uow = _db.GetDbContext(); + var waifu = uow.Set().ByWaifuUserId(waifuId); + var ownerUser = uow.GetOrCreateUser(owner); + + // owner has to be the owner of the waifu + if (waifu is null || waifu.ClaimerId != ownerUser.Id) + return false; + + // if waifu likes the person, gotta pay the penalty + if (waifu.AffinityId == ownerUser.Id) + { + if (!await _cs.RemoveAsync(owner.Id, (long)(waifu.Price * 0.6), new("waifu", "affinity-penalty"))) + // unable to pay 60% penalty + return false; + + waifu.Price = (long)(waifu.Price * 0.7); // half of 60% = 30% price reduction + if (waifu.Price < settings.Waifu.MinPrice) + waifu.Price = settings.Waifu.MinPrice; + } + else // if not, pay 10% fee + { + if (!await _cs.RemoveAsync(owner.Id, waifu.Price / 10, new("waifu", "transfer"))) + return false; + + waifu.Price = (long)(waifu.Price * 0.95); // half of 10% = 5% price reduction + if (waifu.Price < settings.Waifu.MinPrice) + waifu.Price = settings.Waifu.MinPrice; + } + + //new claimerId is the id of the new owner + var newOwnerUser = uow.GetOrCreateUser(newOwner); + waifu.ClaimerId = newOwnerUser.Id; + + await uow.SaveChangesAsync(); + + return true; + } + + public long GetResetPrice(IUser user) + { + var settings = _gss.Data; + using var uow = _db.GetDbContext(); + var waifu = uow.Set().ByWaifuUserId(user.Id); + + if (waifu is null) + return settings.Waifu.MinPrice; + + var divorces = uow.Set().Count(x + => x.Old != null && x.Old.UserId == user.Id && x.UpdateType == WaifuUpdateType.Claimed && x.New == null); + var affs = uow.Set().AsQueryable() + .Where(w => w.User.UserId == user.Id + && w.UpdateType == WaifuUpdateType.AffinityChanged + && w.New != null) + .ToList() + .GroupBy(x => x.New) + .Count(); + + return (long)Math.Ceiling(waifu.Price * 1.25f) + + ((divorces + affs + 2) * settings.Waifu.Multipliers.WaifuReset); + } + + public async Task TryReset(IUser user) + { + await using var uow = _db.GetDbContext(); + var price = GetResetPrice(user); + if (!await _cs.RemoveAsync(user.Id, price, new("waifu", "reset"))) + return false; + + var affs = uow.Set().AsQueryable() + .Where(w => w.User.UserId == user.Id + && w.UpdateType == WaifuUpdateType.AffinityChanged + && w.New != null); + + var divorces = uow.Set().AsQueryable() + .Where(x => x.Old != null + && x.Old.UserId == user.Id + && x.UpdateType == WaifuUpdateType.Claimed + && x.New == null); + + //reset changes of heart to 0 + uow.Set().RemoveRange(affs); + //reset divorces to 0 + uow.Set().RemoveRange(divorces); + var waifu = uow.Set().ByWaifuUserId(user.Id); + //reset price, remove items + //remove owner, remove affinity + waifu.Price = 50; + waifu.Items.Clear(); + waifu.ClaimerId = null; + waifu.AffinityId = null; + + //wives stay though + + await uow.SaveChangesAsync(); + + return true; + } + + public async Task<(WaifuInfo, bool, WaifuClaimResult)> ClaimWaifuAsync(IUser user, IUser target, long amount) + { + var settings = _gss.Data; + WaifuClaimResult result; + WaifuInfo w; + bool isAffinity; + await using (var uow = _db.GetDbContext()) + { + w = uow.Set().ByWaifuUserId(target.Id); + isAffinity = w?.Affinity?.UserId == user.Id; + if (w is null) + { + var claimer = uow.GetOrCreateUser(user); + var waifu = uow.GetOrCreateUser(target); + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + uow.Set().Add(w = new() + { + Waifu = waifu, + Claimer = claimer, + Affinity = null, + Price = amount + }); + uow.Set().Add(new() + { + User = waifu, + Old = null, + New = claimer, + UpdateType = WaifuUpdateType.Claimed + }); + result = WaifuClaimResult.Success; + } + } + else if (isAffinity && amount > w.Price * settings.Waifu.Multipliers.CrushClaim) + { + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + var oldClaimer = w.Claimer; + w.Claimer = uow.GetOrCreateUser(user); + w.Price = amount + (amount / 4); + result = WaifuClaimResult.Success; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = w.Claimer, + UpdateType = WaifuUpdateType.Claimed + }); + } + } + else if (amount >= w.Price * settings.Waifu.Multipliers.NormalClaim) // if no affinity + { + if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim"))) + result = WaifuClaimResult.NotEnoughFunds; + else + { + var oldClaimer = w.Claimer; + w.Claimer = uow.GetOrCreateUser(user); + w.Price = amount; + result = WaifuClaimResult.Success; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = w.Claimer, + UpdateType = WaifuUpdateType.Claimed + }); + } + } + else + result = WaifuClaimResult.InsufficientAmount; + + + await uow.SaveChangesAsync(); + } + + return (w, isAffinity, result); + } + + public async Task<(DiscordUser, bool, TimeSpan?)> ChangeAffinityAsync(IUser user, IGuildUser target) + { + DiscordUser oldAff = null; + var success = false; + TimeSpan? remaining = null; + await using (var uow = _db.GetDbContext()) + { + var w = uow.Set().ByWaifuUserId(user.Id); + var newAff = target is null ? null : uow.GetOrCreateUser(target); + if (w?.Affinity?.UserId == target?.Id) + { + return (null, false, null); + } + + remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id), + 30.Minutes()); + + if (remaining is not null) + { + } + else if (w is null) + { + var thisUser = uow.GetOrCreateUser(user); + uow.Set().Add(new() + { + Affinity = newAff, + Waifu = thisUser, + Price = 1, + Claimer = null + }); + success = true; + + uow.Set().Add(new() + { + User = thisUser, + Old = null, + New = newAff, + UpdateType = WaifuUpdateType.AffinityChanged + }); + } + else + { + if (w.Affinity is not null) + oldAff = w.Affinity; + w.Affinity = newAff; + success = true; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldAff, + New = newAff, + UpdateType = WaifuUpdateType.AffinityChanged + }); + } + + await uow.SaveChangesAsync(); + } + + return (oldAff, success, remaining); + } + + public IEnumerable GetTopWaifusAtPage(int page, int perPage = 9) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetTop(perPage, page * perPage); + } + + public ulong GetWaifuUserId(ulong ownerId, string name) + { + using var uow = _db.GetDbContext(); + return uow.Set().GetWaifuUserId(ownerId, name); + } + + private static TypedKey GetDivorceKey(ulong userId) + => new($"waifu:divorce_cd:{userId}"); + + private static TypedKey GetAffinityKey(ulong userId) + => new($"waifu:affinity:{userId}"); + + public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId) + { + DivorceResult result; + TimeSpan? remaining = null; + long amount = 0; + WaifuInfo w; + await using (var uow = _db.GetDbContext()) + { + w = uow.Set().ByWaifuUserId(targetId); + if (w?.Claimer is null || w.Claimer.UserId != user.Id) + result = DivorceResult.NotYourWife; + else + { + remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours()); + if (remaining is TimeSpan rem) + { + result = DivorceResult.Cooldown; + return (w, result, amount, rem); + } + + amount = w.Price / 2; + + if (w.Affinity?.UserId == user.Id) + { + await _cs.AddAsync(w.Waifu.UserId, amount, new("waifu", "compensation")); + w.Price = (long)Math.Floor(w.Price * _gss.Data.Waifu.Multipliers.DivorceNewValue); + result = DivorceResult.SucessWithPenalty; + } + else + { + await _cs.AddAsync(user.Id, amount, new("waifu", "refund")); + + result = DivorceResult.Success; + } + + var oldClaimer = w.Claimer; + w.Claimer = null; + + uow.Set().Add(new() + { + User = w.Waifu, + Old = oldClaimer, + New = null, + UpdateType = WaifuUpdateType.Claimed + }); + } + + await uow.SaveChangesAsync(); + } + + return (w, result, amount, remaining); + } + + public async Task GiftWaifuAsync(IUser from, IUser giftedWaifu, WaifuItemModel itemObj) + { + if (!await _cs.RemoveAsync(from, itemObj.Price, new("waifu", "item"))) + return false; + + await using var uow = _db.GetDbContext(); + var w = uow.Set().ByWaifuUserId(giftedWaifu.Id, set => set.Include(x => x.Items).Include(x => x.Claimer)); + if (w is null) + { + uow.Set().Add(w = new() + { + Affinity = null, + Claimer = null, + Price = 1, + Waifu = uow.GetOrCreateUser(giftedWaifu) + }); + } + + if (!itemObj.Negative) + { + w.Items.Add(new() + { + Name = itemObj.Name.ToLowerInvariant(), + ItemEmoji = itemObj.ItemEmoji + }); + + if (w.Claimer?.UserId == from.Id) + w.Price += (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect); + else + w.Price += itemObj.Price / 2; + } + else + { + w.Price -= (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.NegativeGiftEffect); + if (w.Price < 1) + w.Price = 1; + } + + await uow.SaveChangesAsync(); + + return true; + } + + public async Task GetFullWaifuInfoAsync(ulong targetId) + { + await using var uow = _db.GetDbContext(); + var wi = await uow.GetWaifuInfoAsync(targetId); + if (wi is null) + { + wi = new() + { + AffinityCount = 0, + AffinityName = null, + ClaimCount = 0, + ClaimerName = null, + DivorceCount = 0, + FullName = null, + Price = 1 + }; + } + + return wi; + } + + public string GetClaimTitle(int count) + { + ClaimTitle title; + if (count == 0) + title = ClaimTitle.Lonely; + else if (count == 1) + title = ClaimTitle.Devoted; + else if (count < 3) + title = ClaimTitle.Rookie; + else if (count < 6) + title = ClaimTitle.Schemer; + else if (count < 10) + title = ClaimTitle.Dilettante; + else if (count < 17) + title = ClaimTitle.Intermediate; + else if (count < 25) + title = ClaimTitle.Seducer; + else if (count < 35) + title = ClaimTitle.Expert; + else if (count < 50) + title = ClaimTitle.Veteran; + else if (count < 75) + title = ClaimTitle.Incubis; + else if (count < 100) + title = ClaimTitle.Harem_King; + else + title = ClaimTitle.Harem_God; + + return title.ToString().Replace('_', ' '); + } + + public string GetAffinityTitle(int count) + { + AffinityTitle title; + if (count < 1) + title = AffinityTitle.Pure; + else if (count < 2) + title = AffinityTitle.Faithful; + else if (count < 4) + title = AffinityTitle.Playful; + else if (count < 8) + title = AffinityTitle.Cheater; + else if (count < 11) + title = AffinityTitle.Tainted; + else if (count < 15) + title = AffinityTitle.Corrupted; + else if (count < 20) + title = AffinityTitle.Lewd; + else if (count < 25) + title = AffinityTitle.Sloot; + else if (count < 35) + title = AffinityTitle.Depraved; + else + title = AffinityTitle.Harlot; + + return title.ToString().Replace('_', ' '); + } + + public IReadOnlyList GetWaifuItems() + { + var conf = _gss.Data; + return conf.Waifu.Items.Select(x + => new WaifuItemModel(x.ItemEmoji, + (long)(x.Price * conf.Waifu.Multipliers.AllGiftPrices), + x.Name, + x.Negative)) + .ToList(); + } + + private static readonly TypedKey _waifuDecayKey = $"waifu:last_decay"; + public async Task OnReadyAsync() + { + // only decay waifu values from shard 0 + if (_client.ShardId != 0) + return; + + while (true) + { + try + { + var multi = _gss.Data.Waifu.Decay.Percent / 100f; + var minPrice = _gss.Data.Waifu.Decay.MinPrice; + var decayInterval = _gss.Data.Waifu.Decay.HourInterval; + + if (multi is < 0f or > 1f || decayInterval < 0) + continue; + + var now = DateTime.UtcNow; + var nowB = now.ToBinary(); + + var result = await _cache.GetAsync(_waifuDecayKey); + + if (result.TryGetValue(out var val)) + { + var lastDecay = DateTime.FromBinary(val); + var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay); + + if (toWait > 0.Hours()) + continue; + } + + await _cache.AddAsync(_waifuDecayKey, nowB); + + await using var uow = _db.GetDbContext(); + + await uow.GetTable() + .Where(x => x.Price > minPrice && x.ClaimerId == null) + .UpdateAsync(old => new() + { + Price = (long)(old.Price * multi) + }); + + } + catch (Exception ex) + { + Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message); + } + finally + { + await Task.Delay(1.Hours()); + } + } + } + + public async Task> GetClaimNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.ClaimerId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + public async Task> GetFansNames(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => ctx.GetTable() + .Where(wi => wi.AffinityId == waifuId) + .Select(wi => wi.WaifuId) + .Contains(x.Id)) + .Select(x => $"{x.Username}#{x.Discriminator}") + .ToListAsyncEF(); + } + + public async Task> GetItems(int waifuId) + { + await using var ctx = _db.GetDbContext(); + return await ctx.GetTable() + .Where(x => x.WaifuInfoId == ctx.GetTable() + .Where(x => x.WaifuId == waifuId) + .Select(x => x.Id) + .FirstOrDefault()) + .ToListAsyncEF(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs new file mode 100644 index 0000000..64cf443 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/AffinityTitle.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum AffinityTitle +{ + Pure, + Faithful, + Playful, + Cheater, + Tainted, + Corrupted, + Lewd, + Sloot, + Depraved, + Harlot +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs new file mode 100644 index 0000000..4b7628f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs @@ -0,0 +1,18 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum ClaimTitle +{ + Lonely, + Devoted, + Rookie, + Schemer, + Dilettante, + Intermediate, + Seducer, + Expert, + Veteran, + Incubis, + Harem_King, + Harem_God +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs new file mode 100644 index 0000000..650bc91 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/DivorceResult.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum DivorceResult +{ + Success, + SucessWithPenalty, + NotYourWife, + Cooldown +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs new file mode 100644 index 0000000..44c6396 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/Extensions.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public class Extensions +{ + +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs new file mode 100644 index 0000000..d68eafb --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/_common/WaifuClaimResult.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common.Waifu; + +public enum WaifuClaimResult +{ + Success, + NotEnoughFunds, + InsufficientAmount +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs new file mode 100644 index 0000000..fa7c5d5 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/Waifu.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuInfo : DbEntity +{ + public int WaifuId { get; set; } + public DiscordUser Waifu { get; set; } + + public int? ClaimerId { get; set; } + public DiscordUser Claimer { get; set; } + + public int? AffinityId { get; set; } + public DiscordUser Affinity { get; set; } + + public long Price { get; set; } + public List Items { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs new file mode 100644 index 0000000..45f3055 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuExtensions.cs @@ -0,0 +1,131 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class WaifuExtensions +{ + public static WaifuInfo ByWaifuUserId( + this DbSet waifus, + ulong userId, + Func, IQueryable> includes = null) + { + if (includes is null) + { + return waifus.Include(wi => wi.Waifu) + .Include(wi => wi.Affinity) + .Include(wi => wi.Claimer) + .Include(wi => wi.Items) + .FirstOrDefault(wi => wi.Waifu.UserId == userId); + } + + return includes(waifus).AsQueryable().FirstOrDefault(wi => wi.Waifu.UserId == userId); + } + + public static IEnumerable GetTop(this DbSet waifus, int count, int skip = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + + if (count == 0) + return []; + + return waifus.Include(wi => wi.Waifu) + .Include(wi => wi.Affinity) + .Include(wi => wi.Claimer) + .OrderByDescending(wi => wi.Price) + .Skip(skip) + .Take(count) + .Select(x => new WaifuLbResult + { + Affinity = x.Affinity == null ? null : x.Affinity.Username, + AffinityDiscrim = x.Affinity == null ? null : x.Affinity.Discriminator, + Claimer = x.Claimer == null ? null : x.Claimer.Username, + ClaimerDiscrim = x.Claimer == null ? null : x.Claimer.Discriminator, + Username = x.Waifu.Username, + Discrim = x.Waifu.Discriminator, + Price = x.Price + }) + .ToList(); + } + + public static decimal GetTotalValue(this DbSet waifus) + => waifus.AsQueryable().Where(x => x.ClaimerId != null).Sum(x => x.Price); + + public static ulong GetWaifuUserId(this DbSet waifus, ulong ownerId, string name) + => waifus.AsQueryable() + .AsNoTracking() + .Where(x => x.Claimer.UserId == ownerId && x.Waifu.Username + "#" + x.Waifu.Discriminator == name) + .Select(x => x.Waifu.UserId) + .FirstOrDefault(); + + public static async Task GetWaifuInfoAsync(this DbContext ctx, ulong userId) + { + await ctx.Set() + .ToLinqToDBTable() + .InsertOrUpdateAsync(() => new() + { + AffinityId = null, + ClaimerId = null, + Price = 1, + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }, + _ => new(), + () => new() + { + WaifuId = ctx.Set().Where(x => x.UserId == userId).Select(x => x.Id).First() + }); + + var toReturn = ctx.Set().AsQueryable() + .Where(w => w.WaifuId + == ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Id) + .FirstOrDefault()) + .Select(w => new WaifuInfoStats + { + WaifuId = w.WaifuId, + FullName = + ctx.Set() + .AsQueryable() + .Where(u => u.UserId == userId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + AffinityCount = + ctx.Set() + .AsQueryable() + .Count(x => x.UserId == w.WaifuId + && x.UpdateType == WaifuUpdateType.AffinityChanged + && x.NewId != null), + AffinityName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.AffinityId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + ClaimCount = ctx.Set().AsQueryable().Count(x => x.ClaimerId == w.WaifuId), + ClaimerName = + ctx.Set() + .AsQueryable() + .Where(u => u.Id == w.ClaimerId) + .Select(u => u.Username + "#" + u.Discriminator) + .FirstOrDefault(), + DivorceCount = + ctx.Set() + .AsQueryable() + .Count(x => x.OldId == w.WaifuId + && x.NewId == null + && x.UpdateType == WaifuUpdateType.Claimed), + Price = w.Price, + }) + .FirstOrDefault(); + + if (toReturn is null) + return null; + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs new file mode 100644 index 0000000..8add339 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuInfoStats.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Db; + +public class WaifuInfoStats +{ + public int WaifuId { get; init; } + public string FullName { get; init; } + public long Price { get; init; } + public string ClaimerName { get; init; } + public string AffinityName { get; init; } + public int AffinityCount { get; init; } + public int DivorceCount { get; init; } + public int ClaimCount { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs new file mode 100644 index 0000000..89125c8 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuItem.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuItem : DbEntity +{ + public WaifuInfo WaifuInfo { get; set; } + public int? WaifuInfoId { get; set; } + public string ItemEmoji { get; set; } + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs new file mode 100644 index 0000000..f83af4f --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuLbResult.cs @@ -0,0 +1,16 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuLbResult +{ + public string Username { get; set; } + public string Discrim { get; set; } + + public string Claimer { get; set; } + public string ClaimerDiscrim { get; set; } + + public string Affinity { get; set; } + public string AffinityDiscrim { get; set; } + + public long Price { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs new file mode 100644 index 0000000..736bd0d --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdate.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class WaifuUpdate : DbEntity +{ + public int UserId { get; set; } + public DiscordUser User { get; set; } + public WaifuUpdateType UpdateType { get; set; } + + public int? OldId { get; set; } + public DiscordUser Old { get; set; } + + public int? NewId { get; set; } + public DiscordUser New { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs new file mode 100644 index 0000000..626bb4c --- /dev/null +++ b/src/EllieBot/Modules/Gambling/Waifus/db/WaifuUpdateType.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public enum WaifuUpdateType +{ + AffinityChanged, + Claimed +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs new file mode 100644 index 0000000..04b8e76 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/Decks/QuadDeck.cs @@ -0,0 +1,19 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Common; + +public class QuadDeck : Deck +{ + protected override void RefillPool() + { + CardPool = new(52 * 4); + for (var j = 1; j < 14; j++) + for (var i = 1; i < 5; i++) + { + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + CardPool.Add(new((CardSuit)i, j)); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs new file mode 100644 index 0000000..45c1c7e --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/GamblingCleanupService.cs @@ -0,0 +1,56 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Gambling; + +public class GamblingCleanupService : IGamblingCleanupService, IEService +{ + private readonly DbService _db; + + public GamblingCleanupService(DbService db) + { + _db = db; + } + + public async Task DeleteWaifus() + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + } + + public async Task DeleteWaifu(ulong userId) + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .Where(x => x.User.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.WaifuInfo.Waifu.UserId == userId) + .DeleteAsync(); + await ctx.GetTable() + .Where(x => x.Claimer.UserId == userId) + .UpdateAsync(old => new WaifuInfo() + { + ClaimerId = null, + }); + await ctx.GetTable() + .Where(x => x.Waifu.UserId == userId) + .DeleteAsync(); + } + + public async Task DeleteCurrency() + { + await using var ctx = _db.GetDbContext(); + await ctx.GetTable().UpdateAsync(_ => new DiscordUser() + { + CurrencyAmount = 0 + }); + + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + await ctx.GetTable().DeleteAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs new file mode 100644 index 0000000..8e266b4 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/IGamblingCleanupService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Gambling; + +public interface IGamblingCleanupService +{ + Task DeleteWaifus(); + Task DeleteWaifu(ulong userId); + Task DeleteCurrency(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs new file mode 100644 index 0000000..4ed31c9 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/IGamblingService.cs @@ -0,0 +1,18 @@ +#nullable disable +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Gambling.Betdraw; +using EllieBot.Modules.Gambling.Rps; +using OneOf; + +namespace EllieBot.Modules.Gambling; + +public interface IGamblingService +{ + Task> LulaAsync(ulong userId, long amount); + Task> BetRollAsync(ulong userId, long amount); + Task> BetFlipAsync(ulong userId, long amount, byte guess); + Task> SlotAsync(ulong userId, long amount); + Task FlipAsync(int count); + Task> RpsAsync(ulong userId, long amount, byte pick); + Task> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs new file mode 100644 index 0000000..2412503 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/NewGamblingService.cs @@ -0,0 +1,269 @@ +#nullable disable +using EllieBot.Modules.Gambling; +using EllieBot.Modules.Gambling.Betdraw; +using EllieBot.Modules.Gambling.Rps; +using EllieBot.Modules.Gambling.Services; +using OneOf; + +namespace EllieBot.Modules.Gambling; + +public sealed class NewGamblingService : IGamblingService, IEService +{ + private readonly GamblingConfigService _bcs; + private readonly ICurrencyService _cs; + + public NewGamblingService(GamblingConfigService bcs, ICurrencyService cs) + { + _bcs = bcs; + _cs = cs; + } + + public async Task> LulaAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("lula", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new LulaGame(_bcs.Data.LuckyLadder.Multipliers); + var result = game.Spin(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("lula", "win")); + } + + return result; + } + + public async Task> BetRollAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betroll", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetrollGame(_bcs.Data.BetRoll.Pairs + .Select(x => (x.WhenAbove, (decimal)x.MultiplyBy)) + .ToList()); + + var result = game.Roll(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betroll", "win")); + } + + return result; + } + + public async Task> BetFlipAsync(ulong userId, long amount, byte guess) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + ArgumentOutOfRangeException.ThrowIfGreaterThan(guess, 1); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betflip", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetflipGame(_bcs.Data.BetFlip.Multiplier); + var result = game.Flip(guess, amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betflip", "win")); + } + + return result; + } + + public async Task> BetDrawAsync(ulong userId, long amount, byte? maybeGuessValue, byte? maybeGuessColor) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (maybeGuessColor is null && maybeGuessValue is null) + throw new ArgumentNullException(); + + if (maybeGuessColor > 1) + throw new ArgumentOutOfRangeException(nameof(maybeGuessColor)); + + if (maybeGuessValue > 1) + throw new ArgumentOutOfRangeException(nameof(maybeGuessValue)); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("betdraw", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new BetdrawGame(); + var result = game.Draw((BetdrawValueGuess?)maybeGuessValue, (BetdrawColorGuess?)maybeGuessColor, amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("betdraw", "win")); + } + + return result; + } + + public async Task> SlotAsync(ulong userId, long amount) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("slot", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var game = new SlotGame(); + var result = game.Spin(amount); + + var won = (long)result.Won; + if (won > 0) + { + await _cs.AddAsync(userId, won, new("slot", "won")); + } + + return result; + } + + public Task FlipAsync(int count) + { + ArgumentOutOfRangeException.ThrowIfLessThan(count, 1); + + var game = new BetflipGame(0); + + var results = new FlipResult[count]; + for (var i = 0; i < count; i++) + { + results[i] = new() + { + Side = game.Flip(0, 0).Side + }; + } + + return Task.FromResult(results); + } + + // + // + // private readonly ConcurrentDictionary _decks = new ConcurrentDictionary(); + // + // public override Task DeckShuffle(DeckShuffleRequest request, ServerCallContext context) + // { + // _decks.AddOrUpdate(request.Id, new Deck(), (key, old) => new Deck()); + // return Task.FromResult(new DeckShuffleReply { }); + // } + // + // public override Task DeckDraw(DeckDrawRequest request, ServerCallContext context) + // { + // if (request.Count < 1 || request.Count > 10) + // throw new ArgumentOutOfRangeException(nameof(request.Id)); + // + // var deck = request.UseNew + // ? new Deck() + // : _decks.GetOrAdd(request.Id, new Deck()); + // + // var list = new List(request.Count); + // for (int i = 0; i < request.Count; i++) + // { + // var card = deck.DrawNoRestart(); + // if (card is null) + // { + // if (i == 0) + // { + // deck.Restart(); + // list.Add(deck.DrawNoRestart()); + // continue; + // } + // + // break; + // } + // + // list.Add(card); + // } + // + // var cards = list + // .Select(x => new Card + // { + // Name = x.ToString().ToLowerInvariant().Replace(' ', '_'), + // Number = x.Number, + // Suit = (CardSuit) x.Suit + // }); + // + // var toReturn = new DeckDrawReply(); + // toReturn.Cards.AddRange(cards); + // + // return Task.FromResult(toReturn); + // } + // + + public async Task> RpsAsync(ulong userId, long amount, byte pick) + { + ArgumentOutOfRangeException.ThrowIfNegative(amount); + ArgumentOutOfRangeException.ThrowIfGreaterThan(pick, 2); + + if (amount > 0) + { + var isTakeSuccess = await _cs.RemoveAsync(userId, amount, new("rps", "bet")); + + if (!isTakeSuccess) + { + return GamblingError.InsufficientFunds; + } + } + + var rps = new RpsGame(); + var result = rps.Play((RpsPick)pick, amount); + + var won = (long)result.Won; + if (won > 0) + { + var extra = result.Result switch + { + RpsResultType.Draw => "draw", + RpsResultType.Win => "win", + _ => "lose" + }; + + await _cs.AddAsync(userId, won, new("rps", extra)); + } + + return result; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs new file mode 100644 index 0000000..24b634a --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/RollDuelGame.cs @@ -0,0 +1,139 @@ +#nullable disable +namespace EllieBot.Modules.Gambling.Common; + +public class RollDuelGame +{ + public enum Reason + { + Normal, + NoFunds, + Timeout + } + + public enum State + { + Waiting, + Running, + Ended + } + + public event Func OnGameTick; + public event Func OnEnded; + + public ulong P1 { get; } + public ulong P2 { get; } + + public long Amount { get; } + + public List<(int, int)> Rolls { get; } = new(); + public State CurrentState { get; private set; } + public ulong Winner { get; private set; } + + private readonly ulong _botId; + + private readonly ICurrencyService _cs; + + private readonly Timer _timeoutTimer; + private readonly EllieRandom _rng = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + + public RollDuelGame( + ICurrencyService cs, + ulong botId, + ulong p1, + ulong p2, + long amount) + { + P1 = p1; + P2 = p2; + _botId = botId; + Amount = amount; + _cs = cs; + + _timeoutTimer = new(async delegate + { + await _locker.WaitAsync(); + try + { + if (CurrentState != State.Waiting) + return; + CurrentState = State.Ended; + await OnEnded?.Invoke(this, Reason.Timeout); + } + catch { } + finally + { + _locker.Release(); + } + }, + null, + TimeSpan.FromSeconds(15), + TimeSpan.FromMilliseconds(-1)); + } + + public async Task StartGame() + { + await _locker.WaitAsync(); + try + { + if (CurrentState != State.Waiting) + return; + _timeoutTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentState = State.Running; + } + finally + { + _locker.Release(); + } + + if (!await _cs.RemoveAsync(P1, Amount, new("rollduel", "bet"))) + { + await OnEnded?.Invoke(this, Reason.NoFunds); + CurrentState = State.Ended; + return; + } + + if (!await _cs.RemoveAsync(P2, Amount, new("rollduel", "bet"))) + { + await _cs.AddAsync(P1, Amount, new("rollduel", "refund")); + await OnEnded?.Invoke(this, Reason.NoFunds); + CurrentState = State.Ended; + return; + } + + int n1, n2; + do + { + n1 = _rng.Next(0, 5); + n2 = _rng.Next(0, 5); + Rolls.Add((n1, n2)); + if (n1 != n2) + { + if (n1 > n2) + Winner = P1; + else + Winner = P2; + var won = (long)(Amount * 2 * 0.98f); + await _cs.AddAsync(Winner, won, new("rollduel", "win")); + + await _cs.AddAsync(_botId, (Amount * 2) - won, new("rollduel", "fee")); + } + + try { await OnGameTick?.Invoke(this); } + catch { } + + await Task.Delay(2500); + if (n1 != n2) + break; + } while (true); + + CurrentState = State.Ended; + await OnEnded?.Invoke(this, Reason.Normal); + } +} + +public struct RollDuelChallenge +{ + public ulong Player1 { get; set; } + public ulong Player2 { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs new file mode 100644 index 0000000..42c6f09 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/BaseShmartInputAmountReader.cs @@ -0,0 +1,95 @@ +using System.Text.RegularExpressions; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Gambling.Services; +using NCalc; +using OneOf; + +namespace EllieBot.Common.TypeReaders; + +public class BaseShmartInputAmountReader +{ + private static readonly Regex _percentRegex = new(@"^((?100|\d{1,2})%)$", RegexOptions.Compiled); + protected readonly DbService _db; + protected readonly GamblingConfigService _gambling; + + public BaseShmartInputAmountReader(DbService db, GamblingConfigService gambling) + { + _db = db; + _gambling = gambling; + } + + public async ValueTask>> ReadAsync(ICommandContext context, string input) + { + var i = input.Trim().ToUpperInvariant(); + + i = i.Replace("K", "000"); + + //can't add m because it will conflict with max atm + + if (await TryHandlePercentage(context, i) is long num) + { + return num; + } + + try + { + var expr = new Expression(i, EvaluateOptions.IgnoreCase); + expr.EvaluateParameter += (str, ev) => EvaluateParam(str, ev, context).GetAwaiter().GetResult(); + return (long)decimal.Parse(expr.Evaluate().ToString()!); + } + catch (Exception) + { + return new OneOf.Types.Error($"Invalid input: {input}"); + } + } + + private async Task EvaluateParam(string name, ParameterArgs args, ICommandContext ctx) + { + switch (name.ToUpperInvariant()) + { + case "PI": + args.Result = Math.PI; + break; + case "E": + args.Result = Math.E; + break; + case "ALL": + case "ALLIN": + args.Result = await Cur(ctx); + break; + case "HALF": + args.Result = await Cur(ctx) / 2; + break; + case "MAX": + args.Result = await Max(ctx); + break; + } + } + + protected virtual async Task Cur(ICommandContext ctx) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().GetUserCurrencyAsync(ctx.User.Id); + } + + protected virtual async Task Max(ICommandContext ctx) + { + var settings = _gambling.Data; + var max = settings.MaxBet; + return max == 0 ? await Cur(ctx) : max; + } + + private async Task TryHandlePercentage(ICommandContext ctx, string input) + { + var m = _percentRegex.Match(input); + + if (m.Captures.Count == 0) + return null; + + if (!long.TryParse(m.Groups["num"].ToString(), out var percent)) + return null; + + return (long)(await Cur(ctx) * (percent / 100.0f)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs new file mode 100644 index 0000000..bcb7c20 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartBankInputAmountReader.cs @@ -0,0 +1,21 @@ +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Common.TypeReaders; + +public sealed class ShmartBankInputAmountReader : BaseShmartInputAmountReader +{ + private readonly IBankService _bank; + + public ShmartBankInputAmountReader(IBankService bank, DbService db, GamblingConfigService gambling) + : base(db, gambling) + { + _bank = bank; + } + + protected override Task Cur(ICommandContext ctx) + => _bank.GetBalanceAsync(ctx.User.Id); + + protected override Task Max(ICommandContext ctx) + => Cur(ctx); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs new file mode 100644 index 0000000..cd94058 --- /dev/null +++ b/src/EllieBot/Modules/Gambling/_common/TypeReaders/ShmartNumberTypeReader.cs @@ -0,0 +1,57 @@ +#nullable disable +using EllieBot.Modules.Gambling.Bank; +using EllieBot.Modules.Gambling.Services; + +namespace EllieBot.Common.TypeReaders; + +public sealed class BalanceTypeReader : TypeReader +{ + private readonly BaseShmartInputAmountReader _tr; + + public BalanceTypeReader(DbService db, GamblingConfigService gambling) + { + _tr = new BaseShmartInputAmountReader(db, gambling); + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + + var result = await _tr.ReadAsync(context, input); + + if (result.TryPickT0(out var val, out var err)) + { + return Discord.Commands.TypeReaderResult.FromSuccess(val); + } + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value); + } +} + +public sealed class BankBalanceTypeReader : TypeReader +{ + private readonly ShmartBankInputAmountReader _tr; + + public BankBalanceTypeReader(IBankService bank, DbService db, GamblingConfigService gambling) + { + _tr = new ShmartBankInputAmountReader(bank, db, gambling); + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + + var result = await _tr.ReadAsync(context, input); + + if (result.TryPickT0(out var val, out var err)) + { + return Discord.Commands.TypeReaderResult.FromSuccess(val); + } + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, err.Value); + } +} \ No newline at end of file -- 2.43.0 From 4261abbf722e166c1f22f83eb46db4a39fdd481f Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:51:06 +1200 Subject: [PATCH 023/340] Added Games module --- .../Modules/Games/Acrophobia/Acrophobia.cs | 200 ++++++++++++ .../Games/Acrophobia/AcrophobiaUser.cs | 22 ++ .../Games/Acrophobia/AcropobiaCommands.cs | 140 ++++++++ .../Games/ChatterBot/ChatterbotService.cs | 215 ++++++++++++ .../Games/ChatterBot/CleverBotCommands.cs | 48 +++ .../ChatterBot/_common/CleverbotResponse.cs | 8 + .../Games/ChatterBot/_common/Gpt3Response.cs | 46 +++ .../ChatterBot/_common/IChatterBotSession.cs | 7 + .../_common/OfficialCleverbotSession.cs | 38 +++ .../ChatterBot/_common/OfficialGpt3Session.cs | 105 ++++++ src/EllieBot/Modules/Games/Games.cs | 47 +++ src/EllieBot/Modules/Games/GamesConfig.cs | 174 ++++++++++ .../Modules/Games/GamesConfigService.cs | 94 ++++++ src/EllieBot/Modules/Games/GamesService.cs | 118 +++++++ src/EllieBot/Modules/Games/GirlRating.cs | 61 ++++ .../Games/Hangman/DefaultHangmanSource.cs | 64 ++++ .../Modules/Games/Hangman/HangmanCommands.cs | 76 +++++ .../Modules/Games/Hangman/HangmanGame.cs | 111 +++++++ .../Modules/Games/Hangman/HangmanService.cs | 136 ++++++++ .../Modules/Games/Hangman/HangmanTerm.cs | 8 + .../Modules/Games/Hangman/IHangmanService.cs | 10 + .../Modules/Games/Hangman/IHangmanSource.cs | 10 + src/EllieBot/Modules/Games/Nunchi/Nunchi.cs | 183 +++++++++++ .../Modules/Games/Nunchi/NunchiCommands.cs | 114 +++++++ .../Games/SpeedTyping/SpeedTypingCommands.cs | 105 ++++++ .../Games/SpeedTyping/TypingArticle.cs | 9 + .../Modules/Games/SpeedTyping/TypingGame.cs | 197 +++++++++++ .../Modules/Games/TicTacToe/TicTacToe.cs | 307 ++++++++++++++++++ .../Games/TicTacToe/TicTacToeCommands.cs | 54 +++ src/EllieBot/Modules/Games/Trivia/Games.cs | 282 ++++++++++++++++ .../QuestionPool/DefaultQuestionPool.cs | 22 ++ .../Trivia/QuestionPool/IQuestionPool.cs | 6 + .../QuestionPool/PokemonQuestionPool.cs | 32 ++ .../Modules/Games/Trivia/TriviaGame.cs | 219 +++++++++++++ .../Games/Trivia/TriviaGamesService.cs | 37 +++ .../Modules/Games/Trivia/TriviaOptions.cs | 44 +++ .../Modules/Games/Trivia/TriviaQuestion.cs | 115 +++++++ .../Modules/Games/Trivia/TriviaUser.cs | 3 + 38 files changed, 3467 insertions(+) create mode 100644 src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs create mode 100644 src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs create mode 100644 src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs create mode 100644 src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs create mode 100644 src/EllieBot/Modules/Games/Games.cs create mode 100644 src/EllieBot/Modules/Games/GamesConfig.cs create mode 100644 src/EllieBot/Modules/Games/GamesConfigService.cs create mode 100644 src/EllieBot/Modules/Games/GamesService.cs create mode 100644 src/EllieBot/Modules/Games/GirlRating.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/HangmanGame.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/HangmanService.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/IHangmanService.cs create mode 100644 src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs create mode 100644 src/EllieBot/Modules/Games/Nunchi/Nunchi.cs create mode 100644 src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs create mode 100644 src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs create mode 100644 src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs create mode 100644 src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs create mode 100644 src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs create mode 100644 src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/Games.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/TriviaGame.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs create mode 100644 src/EllieBot/Modules/Games/Trivia/TriviaUser.cs diff --git a/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs new file mode 100644 index 0000000..ae8ba56 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/Acrophobia.cs @@ -0,0 +1,200 @@ +#nullable disable +using CommandLine; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games.Common.Acrophobia; + +public sealed class AcrophobiaGame : IDisposable +{ + public enum Phase + { + Submission, + Voting, + Ended + } + + public enum UserInputResult + { + Submitted, + SubmissionFailed, + Voted, + VotingFailed, + Failed + } + + public event Func OnStarted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnVotingStarted = + delegate { return Task.CompletedTask; }; + + public event Func OnUserVoted = delegate { return Task.CompletedTask; }; + + public event Func>, Task> OnEnded = delegate + { + return Task.CompletedTask; + }; + + public Phase CurrentPhase { get; private set; } = Phase.Submission; + public ImmutableArray StartingLetters { get; private set; } + public Options Opts { get; } + + private readonly Dictionary _submissions = new(); + private readonly SemaphoreSlim _locker = new(1, 1); + private readonly EllieRandom _rng; + + private readonly HashSet _usersWhoVoted = []; + + public AcrophobiaGame(Options options) + { + Opts = options; + _rng = new(); + InitializeStartingLetters(); + } + + public async Task Run() + { + await OnStarted(this); + await Task.Delay(Opts.SubmissionTime * 1000); + await _locker.WaitAsync(); + try + { + if (_submissions.Count == 0) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, ImmutableArray.Create>()); + return; + } + + if (_submissions.Count == 1) + { + CurrentPhase = Phase.Ended; + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + return; + } + + CurrentPhase = Phase.Voting; + + await OnVotingStarted(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + + await Task.Delay(Opts.VoteTime * 1000); + await _locker.WaitAsync(); + try + { + CurrentPhase = Phase.Ended; + await OnEnded(this, _submissions.ToArray().ToImmutableArray()); + } + finally { _locker.Release(); } + } + + private void InitializeStartingLetters() + { + var wordCount = _rng.Next(3, 6); + + var lettersArr = new char[wordCount]; + + for (var i = 0; i < wordCount; i++) + { + var randChar = (char)_rng.Next(65, 91); + lettersArr[i] = randChar == 'X' ? (char)_rng.Next(65, 88) : randChar; + } + + StartingLetters = lettersArr.ToImmutableArray(); + } + + public async Task UserInput(ulong userId, string userName, string input) + { + var user = new AcrophobiaUser(userId, userName, input.ToLowerInvariant().ToTitleCase()); + + await _locker.WaitAsync(); + try + { + switch (CurrentPhase) + { + case Phase.Submission: + if (_submissions.ContainsKey(user) || !IsValidAnswer(input)) + break; + + _submissions.Add(user, 0); + return true; + case Phase.Voting: + AcrophobiaUser toVoteFor; + if (!int.TryParse(input, out var index) + || --index < 0 + || index >= _submissions.Count + || (toVoteFor = _submissions.ToArray()[index].Key).UserId == user.UserId + || !_usersWhoVoted.Add(userId)) + break; + ++_submissions[toVoteFor]; + _ = Task.Run(() => OnUserVoted(userName)); + return true; + } + + return false; + } + finally + { + _locker.Release(); + } + } + + private bool IsValidAnswer(string input) + { + input = input.ToUpperInvariant(); + + var inputWords = input.Split(' '); + + if (inputWords.Length + != StartingLetters.Length) // number of words must be the same as the number of the starting letters + return false; + + for (var i = 0; i < StartingLetters.Length; i++) + { + var letter = StartingLetters[i]; + + if (!inputWords[i] + .StartsWith(letter.ToString(), StringComparison.InvariantCulture)) // all first letters must match + return false; + } + + return true; + } + + public void Dispose() + { + CurrentPhase = Phase.Ended; + OnStarted = null; + OnEnded = null; + OnUserVoted = null; + OnVotingStarted = null; + _usersWhoVoted.Clear(); + _submissions.Clear(); + _locker.Dispose(); + } + + public class Options : IEllieCommandOptions + { + [Option('s', + "submission-time", + Required = false, + Default = 60, + HelpText = "Time after which the submissions are closed and voting starts.")] + public int SubmissionTime { get; set; } = 60; + + [Option('v', + "vote-time", + Required = false, + Default = 60, + HelpText = "Time after which the voting is closed and the winner is declared.")] + public int VoteTime { get; set; } = 30; + + public void NormalizeOptions() + { + if (SubmissionTime is < 15 or > 300) + SubmissionTime = 60; + if (VoteTime is < 15 or > 120) + VoteTime = 30; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs new file mode 100644 index 0000000..2de2917 --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcrophobiaUser.cs @@ -0,0 +1,22 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.Acrophobia; + +public class AcrophobiaUser +{ + public string UserName { get; } + public ulong UserId { get; } + public string Input { get; } + + public AcrophobiaUser(ulong userId, string userName, string input) + { + UserName = userName; + UserId = userId; + Input = input; + } + + public override int GetHashCode() + => UserId.GetHashCode(); + + public override bool Equals(object obj) + => obj is AcrophobiaUser x ? x.UserId == UserId : false; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs new file mode 100644 index 0000000..30defba --- /dev/null +++ b/src/EllieBot/Modules/Games/Acrophobia/AcropobiaCommands.cs @@ -0,0 +1,140 @@ +#nullable disable +using EllieBot.Modules.Games.Common.Acrophobia; +using EllieBot.Modules.Games.Services; +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class AcropobiaCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public AcropobiaCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task Acrophobia(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new AcrophobiaGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = new AcrophobiaGame(options); + if (_service.AcrophobiaGames.TryAdd(channel.Id, game)) + { + try + { + game.OnStarted += Game_OnStarted; + game.OnEnded += Game_OnEnded; + game.OnVotingStarted += Game_OnVotingStarted; + game.OnUserVoted += Game_OnUserVoted; + _client.MessageReceived += ClientMessageReceived; + await game.Run(); + } + finally + { + _client.MessageReceived -= ClientMessageReceived; + _service.AcrophobiaGames.TryRemove(channel.Id, out game); + game?.Dispose(); + } + } + else + await Response().Error(strs.acro_running).SendAsync(); + + Task ClientMessageReceived(SocketMessage msg) + { + if (msg.Channel.Id != ctx.Channel.Id) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + var success = await game.UserInput(msg.Author.Id, msg.Author.ToString(), msg.Content); + if (success) + await msg.DeleteAsync(); + } + catch { } + }); + + return Task.CompletedTask; + } + } + + private Task Game_OnStarted(AcrophobiaGame game) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription( + GetText(strs.acro_started(Format.Bold(string.Join(".", game.StartingLetters))))) + .WithFooter(GetText(strs.acro_started_footer(game.Opts.SubmissionTime))); + + return Response().Embed(embed).SendAsync(); + } + + private Task Game_OnUserVoted(string user) + => Response().Confirm(GetText(strs.acrophobia), GetText(strs.acro_vote_cast(Format.Bold(user)))).SendAsync(); + + private async Task Game_OnVotingStarted( + AcrophobiaGame game, + ImmutableArray> submissions) + { + if (submissions.Length == 0) + { + await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_ended_no_sub)).SendAsync(); + return; + } + + if (submissions.Length == 1) + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(GetText( + strs.acro_winner_only( + Format.Bold(submissions.First().Key.UserName)))) + .WithFooter(submissions.First().Key.Input)).SendAsync(); + return; + } + + + var i = 0; + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia) + " - " + GetText(strs.submissions_closed)) + .WithDescription(GetText(strs.acro_nym_was( + Format.Bold(string.Join(".", game.StartingLetters)) + + "\n" + + $@"-- +{submissions.Aggregate("", (agg, cur) => agg + $"`{++i}.` **{cur.Key.Input}**\n")} +--"))) + .WithFooter(GetText(strs.acro_vote)); + + await Response().Embed(embed).SendAsync(); + } + + private async Task Game_OnEnded(AcrophobiaGame game, ImmutableArray> votes) + { + if (!votes.Any() || votes.All(x => x.Value == 0)) + { + await Response().Error(GetText(strs.acrophobia), GetText(strs.acro_no_votes_cast)).SendAsync(); + return; + } + + var table = votes.OrderByDescending(v => v.Value); + var winner = table.First(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.acrophobia)) + .WithDescription(GetText(strs.acro_winner(Format.Bold(winner.Key.UserName), + Format.Bold(winner.Value.ToString())))) + .WithFooter(winner.Key.Input); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs b/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs new file mode 100644 index 0000000..30dc17e --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/ChatterbotService.cs @@ -0,0 +1,215 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Common.ChatterBot; +using EllieBot.Modules.Patronage; +using EllieBot.Modules.Permissions; + +namespace EllieBot.Modules.Games.Services; + +public class ChatterBotService : IExecOnMessage +{ + public ConcurrentDictionary> ChatterBotGuilds { get; } + + public int Priority + => 1; + + private readonly FeatureLimitKey _flKey; + + private readonly DiscordSocketClient _client; + private readonly IPermissionChecker _perms; + private readonly CommandHandler _cmd; + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + private readonly IPatronageService _ps; + private readonly GamesConfigService _gcs; + private readonly IMessageSenderService _sender; + + public ChatterBotService( + DiscordSocketClient client, + IPermissionChecker perms, + IBot bot, + CommandHandler cmd, + IHttpClientFactory factory, + IBotCredentials creds, + IPatronageService ps, + GamesConfigService gcs, + IMessageSenderService sender) + { + _client = client; + _perms = perms; + _cmd = cmd; + _creds = creds; + _sender = sender; + _httpFactory = factory; + _ps = ps; + _perms = perms; + _gcs = gcs; + + _flKey = new FeatureLimitKey() + { + Key = CleverBotResponseStr.CLEVERBOT_RESPONSE, + PrettyName = "Cleverbot Replies" + }; + + ChatterBotGuilds = new(bot.AllGuildConfigs + .Where(gc => gc.CleverbotEnabled) + .ToDictionary(gc => gc.GuildId, + _ => new Lazy(() => CreateSession(), true))); + } + + public IChatterBotSession CreateSession() + { + switch (_gcs.Data.ChatBot) + { + case ChatBotImplementation.Cleverbot: + if (!string.IsNullOrWhiteSpace(_creds.CleverbotApiKey)) + return new OfficialCleverbotSession(_creds.CleverbotApiKey, _httpFactory); + + Log.Information("Cleverbot will not work as the api key is missing"); + return null; + case ChatBotImplementation.Gpt3: + if (!string.IsNullOrWhiteSpace(_creds.Gpt3ApiKey)) + return new OfficialGpt3Session(_creds.Gpt3ApiKey, + _gcs.Data.ChatGpt.ModelName, + _gcs.Data.ChatGpt.ChatHistory, + _gcs.Data.ChatGpt.MaxTokens, + _gcs.Data.ChatGpt.MinTokens, + _gcs.Data.ChatGpt.PersonalityPrompt, + _client.CurrentUser.Username, + _httpFactory); + + Log.Information("Gpt3 will not work as the api key is missing"); + return null; + default: + return null; + } + } + + public string PrepareMessage(IUserMessage msg, out IChatterBotSession cleverbot) + { + var channel = msg.Channel as ITextChannel; + cleverbot = null; + + if (channel is null) + return null; + + if (!ChatterBotGuilds.TryGetValue(channel.Guild.Id, out var lazyCleverbot)) + return null; + + cleverbot = lazyCleverbot.Value; + + var ellieId = _client.CurrentUser.Id; + var normalMention = $"<@{ellieId}> "; + var nickMention = $"<@!{ellieId}> "; + string message; + if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture)) + message = msg.Content[normalMention.Length..].Trim(); + else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture)) + message = msg.Content[nickMention.Length..].Trim(); + else + return null; + + return message; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg) + { + if (guild is not SocketGuild sg) + return false; + + try + { + var message = PrepareMessage(usrMsg, out var cbs); + if (message is null || cbs is null) + return false; + + var res = await _perms.CheckPermsAsync(sg, + usrMsg.Channel, + usrMsg.Author, + CleverBotResponseStr.CLEVERBOT_RESPONSE, + CleverBotResponseStr.CLEVERBOT_RESPONSE); + + if (!res.IsAllowed) + return false; + + var channel = (ITextChannel)usrMsg.Channel; + var conf = _ps.GetConfig(); + if (!_creds.IsOwner(sg.OwnerId) && conf.IsEnabled) + { + var quota = await _ps.TryGetFeatureLimitAsync(_flKey, sg.OwnerId, 0); + + uint? daily = quota.Quota is int dVal and < 0 + ? (uint)-dVal + : null; + + uint? monthly = quota.Quota is int mVal and >= 0 + ? (uint)mVal + : null; + + var maybeLimit = await _ps.TryIncrementQuotaCounterAsync(sg.OwnerId, + sg.OwnerId == usrMsg.Author.Id, + FeatureType.Limit, + _flKey.Key, + null, + daily, + monthly); + + if (maybeLimit.TryPickT1(out var ql, out var counters)) + { + if (ql.Quota == 0) + { + await _sender.Response(channel) + .Error(null, + text: + "In order to use the cleverbot feature, the owner of this server should be [Patron Tier X](https://patreon.com/join/elliebot) on patreon.", + footer: + "You may disable the cleverbot feature, and this message via '.cleverbot' command") + .SendAsync(); + + return true; + } + + await _sender.Response(channel) + .Error( + null!, + $"You've reached your quota limit of **{ql.Quota}** responses {ql.QuotaPeriod.ToFullName()} for the cleverbot feature.", + footer: "You may wait for the quota reset or .") + .SendAsync(); + + return true; + } + } + + _ = channel.TriggerTypingAsync(); + var response = await cbs.Think(message, usrMsg.Author.ToString()); + await _sender.Response(channel) + .Confirm(response) + .SendAsync(); + + Log.Information(""" + CleverBot Executed + Server: {GuildName} [{GuildId}] + Channel: {ChannelName} [{ChannelId}] + UserId: {Author} [{AuthorId}] + Message: {Content} + """, + guild.Name, + guild.Id, + usrMsg.Channel?.Name, + usrMsg.Channel?.Id, + usrMsg.Author, + usrMsg.Author.Id, + usrMsg.Content); + + return true; + } + catch (Exception ex) + { + Log.Warning(ex, "Error in cleverbot"); + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs b/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs new file mode 100644 index 0000000..1a41953 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/CleverBotCommands.cs @@ -0,0 +1,48 @@ +#nullable disable +using EllieBot.Db; +using EllieBot.Modules.Games.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class ChatterBotCommands : EllieModule + { + private readonly DbService _db; + + public ChatterBotCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task Cleverbot() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.ChatterBotGuilds.TryRemove(channel.Guild.Id, out _)) + { + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, false); + await uow.SaveChangesAsync(); + } + + await Response().Confirm(strs.cleverbot_disabled).SendAsync(); + return; + } + + _service.ChatterBotGuilds.TryAdd(channel.Guild.Id, new(() => _service.CreateSession(), true)); + + await using (var uow = _db.GetDbContext()) + { + uow.Set().SetCleverbotEnabled(ctx.Guild.Id, true); + await uow.SaveChangesAsync(); + } + + await Response().Confirm(strs.cleverbot_enabled).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs new file mode 100644 index 0000000..2f83164 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/CleverbotResponse.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class CleverbotResponse +{ + public string Cs { get; set; } + public string Output { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs new file mode 100644 index 0000000..ad8692a --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/Gpt3Response.cs @@ -0,0 +1,46 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class Gpt3Response +{ + [JsonPropertyName("choices")] + public Choice[] Choices { get; set; } +} + +public class Choice +{ + [JsonPropertyName("message")] + public Message Message { get; init; } +} + +public class Message { + [JsonPropertyName("content")] + public string Content { get; init; } +} + +public class Gpt3ApiRequest +{ + [JsonPropertyName("model")] + public string Model { get; init; } + + [JsonPropertyName("messages")] + public List Messages { get; init; } + + [JsonPropertyName("temperature")] + public int Temperature { get; init; } + + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; init; } +} + +public class GPTMessage +{ + [JsonPropertyName("role")] + public string Role {get; init;} + [JsonPropertyName("content")] + public string Content {get; init;} + [JsonPropertyName("name")] + public string Name {get; init;} +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs new file mode 100644 index 0000000..847d661 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/IChatterBotSession.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public interface IChatterBotSession +{ + Task Think(string input, string username); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs new file mode 100644 index 0000000..83dc060 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialCleverbotSession.cs @@ -0,0 +1,38 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OfficialCleverbotSession : IChatterBotSession +{ + private string QueryString + => $"https://www.cleverbot.com/getreply?key={_apiKey}" + "&wrapper=elliebot" + "&input={0}" + "&cs={1}"; + + private readonly string _apiKey; + private readonly IHttpClientFactory _httpFactory; + private string cs; + + public OfficialCleverbotSession(string apiKey, IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + } + + public async Task Think(string input, string username) + { + using var http = _httpFactory.CreateClient(); + var dataString = await http.GetStringAsync(string.Format(QueryString, input, cs ?? "")); + try + { + var data = JsonConvert.DeserializeObject(dataString); + + cs = data?.Cs; + return data?.Output; + } + catch + { + Log.Warning("Unexpected cleverbot response received: {ResponseString}", dataString); + return null; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs new file mode 100644 index 0000000..4711fd6 --- /dev/null +++ b/src/EllieBot/Modules/Games/ChatterBot/_common/OfficialGpt3Session.cs @@ -0,0 +1,105 @@ +#nullable disable +using Newtonsoft.Json; +using System.Net.Http.Json; +using SharpToken; + +namespace EllieBot.Modules.Games.Common.ChatterBot; + +public class OfficialGpt3Session : IChatterBotSession +{ + private string Uri + => $"https://api.openai.com/v1/chat/completions"; + + private readonly string _apiKey; + private readonly string _model; + private readonly int _maxHistory; + private readonly int _maxTokens; + private readonly int _minTokens; + private readonly string _ellieUsername; + private readonly GptEncoding _encoding; + private List messages = new(); + private readonly IHttpClientFactory _httpFactory; + + + + public OfficialGpt3Session( + string apiKey, + ChatGptModel model, + int chatHistory, + int maxTokens, + int minTokens, + string personality, + string ellieUsername, + IHttpClientFactory factory) + { + _apiKey = apiKey; + _httpFactory = factory; + switch (model) + { + case ChatGptModel.Gpt35Turbo: + _model = "gpt-3.5-turbo"; + break; + case ChatGptModel.Gpt4: + _model = "gpt-4"; + break; + case ChatGptModel.Gpt432k: + _model = "gpt-4-32k"; + break; + } + _maxHistory = chatHistory; + _maxTokens = maxTokens; + _minTokens = minTokens; + _ellieUsername = ellieUsername; + _encoding = GptEncoding.GetEncodingForModel(_model); + messages.Add(new GPTMessage(){Role = "user", Content = personality, Name = _ellieUsername}); + } + + public async Task Think(string input, string username) + { + messages.Add(new GPTMessage(){Role = "user", Content = input, Name = username}); + while(messages.Count > _maxHistory + 2){ + messages.RemoveAt(1); + } + int tokensUsed = 0; + foreach(GPTMessage message in messages){ + tokensUsed += _encoding.Encode(message.Content).Count; + } + tokensUsed *= 2; //Unsure why this is the case, but the token count chatgpt reports back is double what I calculate. + //check if we have the minimum number of tokens available to use. Remove messages until we have enough, otherwise exit out and inform the user why. + while(_maxTokens - tokensUsed <= _minTokens){ + if(messages.Count > 2){ + int tokens = _encoding.Encode(messages[1].Content).Count * 2; + tokensUsed -= tokens; + messages.RemoveAt(1); + } + else{ + return "Token count exceeded, please increase the number of tokens in the bot config and restart."; + } + } + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Authorization = new("Bearer", _apiKey); + var data = await http.PostAsJsonAsync(Uri, new Gpt3ApiRequest() + { + Model = _model, + Messages = messages, + MaxTokens = _maxTokens - tokensUsed, + Temperature = 1, + }); + var dataString = await data.Content.ReadAsStringAsync(); + try + { + var response = JsonConvert.DeserializeObject(dataString); + string message = response?.Choices[0]?.Message?.Content; + //Can't rely on the return to except, now that we need to add it to the messages list. + _ = message ?? throw new ArgumentNullException(nameof(message)); + messages.Add(new GPTMessage(){Role = "assistant", Content = message, Name = _ellieUsername}); + return message; + } + catch + { + Log.Warning("Unexpected GPT-3 response received: {ResponseString}", dataString); + return null; + } + } +} + diff --git a/src/EllieBot/Modules/Games/Games.cs b/src/EllieBot/Modules/Games/Games.cs new file mode 100644 index 0000000..c14d6ee --- /dev/null +++ b/src/EllieBot/Modules/Games/Games.cs @@ -0,0 +1,47 @@ +#nullable disable +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +/* more games +- Shiritori +- Simple RPG adventure +*/ +public partial class Games : EllieModule +{ + private readonly IImageCache _images; + private readonly IHttpClientFactory _httpFactory; + private readonly Random _rng = new(); + + public Games(IImageCache images, IHttpClientFactory factory) + { + _images = images; + _httpFactory = factory; + } + + [Cmd] + public async Task Choose([Leftover] string list = null) + { + if (string.IsNullOrWhiteSpace(list)) + return; + var listArr = list.Split(';'); + if (listArr.Length < 2) + return; + var rng = new EllieRandom(); + await Response().Confirm("🤔", listArr[rng.Next(0, listArr.Length)]).SendAsync(); + } + + [Cmd] + public async Task EightBall([Leftover] string question = null) + { + if (string.IsNullOrWhiteSpace(question)) + return; + + var res = _service.GetEightballResponse(ctx.User.Id, question); + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithDescription(ctx.User.ToString()) + .AddField("❓ " + GetText(strs.question), question) + .AddField("🎱 " + GetText(strs._8ball), res)).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfig.cs b/src/EllieBot/Modules/Games/GamesConfig.cs new file mode 100644 index 0000000..1502c39 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfig.cs @@ -0,0 +1,174 @@ +#nullable disable +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Games.Common; + +[Cloneable] +public sealed partial class GamesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + [Comment("Hangman related settings (.hangman command)")] + public HangmanConfig Hangman { get; set; } = new() + { + CurrencyReward = 0 + }; + + [Comment("Trivia related settings (.t command)")] + public TriviaConfig Trivia { get; set; } = new() + { + CurrencyReward = 0, + MinimumWinReq = 1 + }; + + [Comment("List of responses for the .8ball command. A random one will be selected every time")] + public List EightBallResponses { get; set; } = + [ + "Most definitely yes.", + "For sure.", + "Totally!", + "Of course!", + "As I see it, yes.", + "My sources say yes.", + "Yes.", + "Most likely.", + "Perhaps...", + "Maybe...", + "Hm, not sure.", + "It is uncertain.", + "Ask me again later.", + "Don't count on it.", + "Probably not.", + "Very doubtful.", + "Most likely no.", + "Nope.", + "No.", + "My sources say no.", + "Don't even think about it.", + "Definitely no.", + "NO - It may cause disease contraction!" + ]; + + [Comment("List of animals which will be used for the animal race game (.race)")] + public List RaceAnimals { get; set; } = + [ + new() + { + Icon = "🐼", + Name = "Panda" + }, + + new() + { + Icon = "🐻", + Name = "Bear" + }, + + new() + { + Icon = "🐧", + Name = "Pengu" + }, + + new() + { + Icon = "🐨", + Name = "Koala" + }, + + new() + { + Icon = "🐬", + Name = "Dolphin" + }, + + new() + { + Icon = "🐞", + Name = "Ladybird" + }, + + new() + { + Icon = "🦀", + Name = "Crab" + }, + + new() + { + Icon = "🦄", + Name = "Unicorn" + } + ]; + + [Comment(@"Which chatbot API should bot use. +'cleverbot' - bot will use Cleverbot API. +'gpt3' - bot will use GPT-3 API")] + public ChatBotImplementation ChatBot { get; set; } = ChatBotImplementation.Gpt3; + + public ChatGptConfig ChatGpt { get; set; } = new(); +} + +[Cloneable] +public sealed partial class ChatGptConfig +{ + [Comment(@"Which GPT-3 Model should bot use. + gpt35turbo - cheapest + gpt4 - 30x more expensive, higher quality + gp432k - same model as above, but with a 32k token limit")] + public ChatGptModel ModelName { get; set; } = ChatGptModel.Gpt35Turbo; + + [Comment(@"How should the chat bot behave, what's its personality? (Usage of this counts towards the max tokens)")] + public string PersonalityPrompt { get; set; } = "You are a chat bot willing to have a conversation with anyone about anything."; + + [Comment(@"The maximum number of messages in a conversation that can be remembered. (This will increase the number of tokens used)")] + public int ChatHistory { get; set; } = 5; + + [Comment(@"The maximum number of tokens to use per GPT-3 API call")] + public int MaxTokens { get; set; } = 100; + + [Comment(@"The minimum number of tokens to use per GPT-3 API call, such that chat history is removed to make room.")] + public int MinTokens { get; set; } = 30; +} + +[Cloneable] +public sealed partial class HangmanConfig +{ + [Comment("The amount of currency awarded to the winner of a hangman game")] + public long CurrencyReward { get; set; } +} + +[Cloneable] +public sealed partial class TriviaConfig +{ + [Comment("The amount of currency awarded to the winner of the trivia game.")] + public long CurrencyReward { get; set; } + + [Comment(""" + Users won't be able to start trivia games which have + a smaller win requirement than the one specified by this setting. + """)] + public int MinimumWinReq { get; set; } = 1; +} + +[Cloneable] +public sealed partial class RaceAnimal +{ + public string Icon { get; set; } + public string Name { get; set; } +} + +public enum ChatBotImplementation +{ + Cleverbot, + Gpt3 +} + +public enum ChatGptModel +{ + Gpt35Turbo, + Gpt4, + Gpt432k +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesConfigService.cs b/src/EllieBot/Modules/Games/GamesConfigService.cs new file mode 100644 index 0000000..6446c23 --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesConfigService.cs @@ -0,0 +1,94 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Modules.Games.Common; + +namespace EllieBot.Modules.Games.Services; + +public sealed class GamesConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/games.yml"; + private static readonly TypedKey _changeKey = new("config.games.updated"); + public override string Name { get; } = "games"; + + public GamesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("trivia.min_win_req", + gs => gs.Trivia.MinimumWinReq, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("trivia.currency_reward", + gs => gs.Trivia.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + AddParsedProp("hangman.currency_reward", + gs => gs.Hangman.CurrencyReward, + long.TryParse, + ConfigPrinters.ToString, + val => val >= 0); + AddParsedProp("chatbot", + gs => gs.ChatBot, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.modelName", + gs => gs.ChatGpt.ModelName, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + AddParsedProp("gpt.personality", + gs => gs.ChatGpt.PersonalityPrompt, + ConfigParsers.String, + ConfigPrinters.ToString); + AddParsedProp("gpt.chathistory", + gs => gs.ChatGpt.ChatHistory, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("gpt.max_tokens", + gs => gs.ChatGpt.MaxTokens, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + AddParsedProp("gpt.min_tokens", + gs => gs.ChatGpt.MinTokens, + int.TryParse, + ConfigPrinters.ToString, + val => val > 0); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.Hangman = new() + { + CurrencyReward = 0 + }; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + c.ChatBot = ChatBotImplementation.Cleverbot; + }); + } + + if (data.Version < 3) + { + ModifyConfig(c => + { + c.Version = 3; + c.ChatGpt.ModelName = ChatGptModel.Gpt35Turbo; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GamesService.cs b/src/EllieBot/Modules/Games/GamesService.cs new file mode 100644 index 0000000..9f4f61b --- /dev/null +++ b/src/EllieBot/Modules/Games/GamesService.cs @@ -0,0 +1,118 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Common.Acrophobia; +using EllieBot.Modules.Games.Common.Nunchi; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Games.Services; + +public class GamesService : IEService, IReadyExecutor +{ + private const string TYPING_ARTICLES_PATH = "data/typing_articles3.json"; + + public ConcurrentDictionary GirlRatings { get; } = new(); + + public IReadOnlyList EightBallResponses + => _gamesConfig.Data.EightBallResponses; + + public List TypingArticles { get; } = new(); + + //channelId, game + public ConcurrentDictionary AcrophobiaGames { get; } = new(); + public Dictionary TicTacToeGames { get; } = new(); + public ConcurrentDictionary RunningContests { get; } = new(); + public ConcurrentDictionary NunchiGames { get; } = new(); + + public AsyncLazy Ratings { get; } + private readonly GamesConfigService _gamesConfig; + + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _8BallCache; + private readonly Random _rng; + + public GamesService(GamesConfigService gamesConfig, IHttpClientFactory httpFactory) + { + _gamesConfig = gamesConfig; + _httpFactory = httpFactory; + _8BallCache = new MemoryCache(new MemoryCacheOptions + { + SizeLimit = 500_000 + }); + + Ratings = new(GetRatingTexts); + _rng = new EllieRandom(); + + try + { + TypingArticles = JsonConvert.DeserializeObject>(File.ReadAllText(TYPING_ARTICLES_PATH)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error while loading typing articles: {ErrorMessage}", ex.Message); + TypingArticles = new(); + } + } + + public async Task OnReadyAsync() + { + // reset rating once a day + using var timer = new PeriodicTimer(TimeSpan.FromDays(1)); + while (await timer.WaitForNextTickAsync()) + GirlRatings.Clear(); + } + + private async Task GetRatingTexts() + { + using var http = _httpFactory.CreateClient(); + var text = await http.GetStringAsync( + "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/rategirl/rates.json"); + return JsonConvert.DeserializeObject(text); + } + + public void AddTypingArticle(IUser user, string text) + { + TypingArticles.Add(new() + { + Source = user.ToString(), + Extra = $"Text added on {DateTime.UtcNow} by {user}.", + Text = text.SanitizeMentions(true) + }); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(TypingArticles)); + } + + public string GetEightballResponse(ulong userId, string question) + => _8BallCache.GetOrCreate($"8ball:{userId}:{question}", + e => + { + e.Size = question.Length; + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); + return EightBallResponses[_rng.Next(0, EightBallResponses.Count)]; + }); + + public TypingArticle RemoveTypingArticle(int index) + { + var articles = TypingArticles; + if (index < 0 || index >= articles.Count) + return null; + + var removed = articles[index]; + TypingArticles.RemoveAt(index); + + File.WriteAllText(TYPING_ARTICLES_PATH, JsonConvert.SerializeObject(articles)); + return removed; + } + + public class RatingTexts + { + public string Nog { get; set; } + public string Tra { get; set; } + public string Fun { get; set; } + public string Uni { get; set; } + public string Wif { get; set; } + public string Dat { get; set; } + public string Dan { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/GirlRating.cs b/src/EllieBot/Modules/Games/GirlRating.cs new file mode 100644 index 0000000..4576216 --- /dev/null +++ b/src/EllieBot/Modules/Games/GirlRating.cs @@ -0,0 +1,61 @@ +#nullable disable +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Games.Common; + +public class GirlRating +{ + public double Crazy { get; } + public double Hot { get; } + public int Roll { get; } + public string Advice { get; } + + public AsyncLazy Stream { get; } + private readonly IImageCache _images; + + public GirlRating( + IImageCache images, + double crazy, + double hot, + int roll, + string advice) + { + _images = images; + Crazy = crazy; + Hot = hot; + Roll = roll; + Advice = advice; // convenient to have it here, even though atm there are only few different ones. + + Stream = new(async () => + { + try + { + var bgBytes = await _images.GetRategirlBgAsync(); + using var img = Image.Load(bgBytes); + const int minx = 35; + const int miny = 385; + const int length = 345; + + var pointx = (int)(minx + (length * (Hot / 10))); + var pointy = (int)(miny - (length * ((Crazy - 4) / 6))); + + var dotBytes = await _images.GetRategirlDotAsync(); + using (var pointImg = Image.Load(dotBytes)) + { + img.Mutate(x => x.DrawImage(pointImg, new(pointx - 10, pointy - 10), new GraphicsOptions())); + } + + var imgStream = new MemoryStream(); + img.SaveAsPng(imgStream); + return imgStream; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting RateGirl image"); + return null; + } + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs new file mode 100644 index 0000000..333e8f0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/DefaultHangmanSource.cs @@ -0,0 +1,64 @@ +using EllieBot.Common.Yml; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public sealed class DefaultHangmanSource : IHangmanSource +{ + private IReadOnlyDictionary termsDict = new Dictionary(); + private readonly Random _rng; + + public DefaultHangmanSource() + { + _rng = new EllieRandom(); + Reload(); + } + + public void Reload() + { + if (!Directory.Exists("data/hangman")) + { + Log.Error("Hangman game won't work. Folder 'data/hangman' is missing"); + return; + } + + var qs = new Dictionary(); + foreach (var file in Directory.EnumerateFiles("data/hangman/", "*.yml")) + { + try + { + var data = Yaml.Deserializer.Deserialize(File.ReadAllText(file)); + qs[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = data; + } + catch (Exception ex) + { + Log.Error(ex, "Loading {HangmanFile} failed", file); + } + } + + termsDict = qs; + + Log.Information("Loaded {HangmanCategoryCount} hangman categories", qs.Count); + } + + public IReadOnlyCollection GetCategories() + => termsDict.Keys.ToList(); + + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term) + { + if (category is null) + { + var cats = GetCategories(); + category = cats.ElementAt(_rng.Next(0, cats.Count)); + } + + if (termsDict.TryGetValue(category, out var terms)) + { + term = terms[_rng.Next(0, terms.Length)]; + return true; + } + + term = null; + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs new file mode 100644 index 0000000..acc0323 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanCommands.cs @@ -0,0 +1,76 @@ +using EllieBot.Modules.Games.Hangman; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class HangmanCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangmanlist() + => await Response().Confirm(GetText(strs.hangman_types(prefix)), _service.GetHangmanTypes().Join('\n')).SendAsync(); + + private static string Draw(HangmanGame.State state) + => $""" + . ┌─────┐ + .┃...............┋ + .┃...............┋ + .┃{(state.Errors > 0 ? ".............😲" : "")} + .┃{(state.Errors > 1 ? "............./" : "")} {(state.Errors > 2 ? "|" : "")} {(state.Errors > 3 ? "\\" : "")} + .┃{(state.Errors > 4 ? "............../" : "")} {(state.Errors > 5 ? "\\" : "")} + /-\ + """; + + public static EmbedBuilder GetEmbed(IMessageSenderService sender, HangmanGame.State state) + { + if (state.Phase == HangmanGame.Phase.Running) + { + return sender.CreateEmbed() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + if (state.Phase == HangmanGame.Phase.Ended && state.Failed) + { + return sender.CreateEmbed() + .WithErrorColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + return sender.CreateEmbed() + .WithOkColor() + .AddField("Hangman", Draw(state)) + .AddField("Guess", Format.Code(state.Word)) + .WithFooter(state.MissedLetters.Join(' ')); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Hangman([Leftover] string? type = null) + { + if (!_service.StartHangman(ctx.Channel.Id, type, out var hangman)) + { + await Response().Error(strs.hangman_running).SendAsync(); + return; + } + + var eb = GetEmbed(_sender, hangman); + eb.WithDescription(GetText(strs.hangman_game_started)); + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task HangmanStop() + { + if (await _service.StopHangman(ctx.Channel.Id)) + await Response().Confirm(strs.hangman_stopped).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs new file mode 100644 index 0000000..1625b55 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanGame.cs @@ -0,0 +1,111 @@ +#nullable disable +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanGame +{ + public enum GuessResult { NoAction, AlreadyTried, Incorrect, Guess, Win } + + public enum Phase { Running, Ended } + + private Phase CurrentPhase { get; set; } + + private readonly HashSet _incorrect = new(); + private readonly HashSet _correct = new(); + private readonly HashSet _remaining = new(); + + private readonly string _word; + private readonly string _imageUrl; + + public HangmanGame(HangmanTerm term) + { + _word = term.Word; + _imageUrl = term.ImageUrl; + + _remaining = _word.ToLowerInvariant().Where(x => char.IsLetter(x)).Select(char.ToLowerInvariant).ToHashSet(); + } + + public State GetState(GuessResult guessResult = GuessResult.NoAction) + => new(_incorrect.Count, + CurrentPhase, + CurrentPhase == Phase.Ended ? _word : GetScrambledWord(), + guessResult, + _incorrect.ToList(), + CurrentPhase == Phase.Ended ? _imageUrl : string.Empty); + + private string GetScrambledWord() + { + Span output = stackalloc char[_word.Length * 2]; + for (var i = 0; i < _word.Length; i++) + { + var ch = _word[i]; + if (ch == ' ') + output[i * 2] = ' '; + if (!char.IsLetter(ch) || !_remaining.Contains(char.ToLowerInvariant(ch))) + output[i * 2] = ch; + else + output[i * 2] = '_'; + + output[(i * 2) + 1] = ' '; + } + + return new(output); + } + + public State Guess(string guess) + { + if (CurrentPhase != Phase.Running) + return GetState(); + + guess = guess.Trim(); + if (guess.Length > 1) + { + if (guess.Equals(_word, StringComparison.InvariantCultureIgnoreCase)) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + return GetState(); + } + + var charGuess = guess[0]; + if (!char.IsLetter(charGuess)) + return GetState(); + + if (_incorrect.Contains(charGuess) || _correct.Contains(charGuess)) + return GetState(GuessResult.AlreadyTried); + + if (_remaining.Remove(charGuess)) + { + if (_remaining.Count == 0) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Win); + } + + _correct.Add(charGuess); + return GetState(GuessResult.Guess); + } + + _incorrect.Add(charGuess); + if (_incorrect.Count > 5) + { + CurrentPhase = Phase.Ended; + return GetState(GuessResult.Incorrect); + } + + return GetState(GuessResult.Incorrect); + } + + public record State( + int Errors, + Phase Phase, + string Word, + GuessResult GuessResult, + List MissedLetters, + string ImageUrl) + { + public bool Failed + => Errors > 5; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanService.cs b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs new file mode 100644 index 0000000..0b5c9c0 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanService.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Services; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanService : IHangmanService, IExecNoCommand +{ + private readonly ConcurrentDictionary _hangmanGames = new(); + private readonly IHangmanSource _source; + private readonly IMessageSenderService _sender; + private readonly GamesConfigService _gcs; + private readonly ICurrencyService _cs; + private readonly IMemoryCache _cdCache; + private readonly object _locker = new(); + + public HangmanService( + IHangmanSource source, + IMessageSenderService sender, + GamesConfigService gcs, + ICurrencyService cs, + IMemoryCache cdCache) + { + _source = source; + _sender = sender; + _gcs = gcs; + _cs = cs; + _cdCache = cdCache; + } + + public bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? state) + { + state = null; + if (!_source.GetTerm(category, out var term)) + return false; + + + var game = new HangmanGame(term); + lock (_locker) + { + var hc = _hangmanGames.GetOrAdd(channelId, game); + if (hc == game) + { + state = hc.GetState(); + return true; + } + + return false; + } + } + + public ValueTask StopHangman(ulong channelId) + { + lock (_locker) + { + if (_hangmanGames.TryRemove(channelId, out _)) + return new(true); + } + + return new(false); + } + + public IReadOnlyCollection GetHangmanTypes() + => _source.GetCategories(); + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (_hangmanGames.ContainsKey(msg.Channel.Id)) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (_cdCache.TryGetValue(msg.Author.Id, out _)) + return; + + HangmanGame.State state; + long rew = 0; + lock (_locker) + { + if (!_hangmanGames.TryGetValue(msg.Channel.Id, out var game)) + return; + + state = game.Guess(msg.Content.ToLowerInvariant()); + + if (state.GuessResult == HangmanGame.GuessResult.NoAction) + return; + + if (state.GuessResult is HangmanGame.GuessResult.Incorrect or HangmanGame.GuessResult.AlreadyTried) + { + _cdCache.Set(msg.Author.Id, + string.Empty, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3) + }); + } + + if (state.Phase == HangmanGame.Phase.Ended) + { + if (_hangmanGames.TryRemove(msg.Channel.Id, out _)) + rew = _gcs.Data.Hangman.CurrencyReward; + } + } + + if (rew > 0) + await _cs.AddAsync(msg.Author, rew, new("hangman", "win")); + + await SendState((ITextChannel)msg.Channel, msg.Author, msg.Content, state); + } + } + + private Task SendState( + ITextChannel channel, + IUser user, + string content, + HangmanGame.State state) + { + var embed = Games.HangmanCommands.GetEmbed(_sender, state); + if (state.GuessResult == HangmanGame.GuessResult.Guess) + embed.WithDescription($"{user} guessed the letter {content}!").WithOkColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect && state.Failed) + embed.WithDescription($"{user} Letter {content} doesn't exist! Game over!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Incorrect) + embed.WithDescription($"{user} Letter {content} doesn't exist!").WithErrorColor(); + else if (state.GuessResult == HangmanGame.GuessResult.AlreadyTried) + embed.WithDescription($"{user} Letter {content} has already been used.").WithPendingColor(); + else if (state.GuessResult == HangmanGame.GuessResult.Win) + embed.WithDescription($"{user} won!").WithOkColor(); + + if (!string.IsNullOrWhiteSpace(state.ImageUrl) && Uri.IsWellFormedUriString(state.ImageUrl, UriKind.Absolute)) + embed.WithImageUrl(state.ImageUrl); + + return _sender.Response(channel).Embed(embed).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs new file mode 100644 index 0000000..22e5144 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/HangmanTerm.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Modules.Games.Hangman; + +public sealed class HangmanTerm +{ + public string Word { get; set; } + public string ImageUrl { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs new file mode 100644 index 0000000..da8d027 --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanService.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public interface IHangmanService +{ + bool StartHangman(ulong channelId, string? category, [NotNullWhen(true)] out HangmanGame.State? hangmanController); + ValueTask StopHangman(ulong channelId); + IReadOnlyCollection GetHangmanTypes(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs new file mode 100644 index 0000000..d28199b --- /dev/null +++ b/src/EllieBot/Modules/Games/Hangman/IHangmanSource.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Games.Hangman; + +public interface IHangmanSource : IEService +{ + public IReadOnlyCollection GetCategories(); + public void Reload(); + public bool GetTerm(string? category, [NotNullWhen(true)] out HangmanTerm? term); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs new file mode 100644 index 0000000..6fa579c --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/Nunchi.cs @@ -0,0 +1,183 @@ +#nullable disable +using System.Collections.Immutable; + +namespace EllieBot.Modules.Games.Common.Nunchi; + +public sealed class NunchiGame : IDisposable +{ + public enum Phase + { + Joining, + Playing, + WaitingForNextRound, + Ended + } + + private const int KILL_TIMEOUT = 20 * 1000; + private const int NEXT_ROUND_TIMEOUT = 5 * 1000; + + public event Func OnGameStarted; + public event Func OnRoundStarted; + public event Func OnUserGuessed; + public event Func OnRoundEnded; // tuple of the user who failed + public event Func OnGameEnded; // name of the user who won + + public int CurrentNumber { get; private set; } = new EllieRandom().Next(0, 100); + public Phase CurrentPhase { get; private set; } = Phase.Joining; + + public ImmutableArray<(ulong Id, string Name)> Participants + => participants.ToImmutableArray(); + + public int ParticipantCount + => participants.Count; + + private readonly SemaphoreSlim _locker = new(1, 1); + + private HashSet<(ulong Id, string Name)> participants = []; + private readonly HashSet<(ulong Id, string Name)> _passed = []; + private Timer killTimer; + + public NunchiGame(ulong creatorId, string creatorName) + => participants.Add((creatorId, creatorName)); + + public async Task Join(ulong userId, string userName) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Joining) + return false; + + return participants.Add((userId, userName)); + } + finally { _locker.Release(); } + } + + public async Task Initialize() + { + CurrentPhase = Phase.Joining; + await Task.Delay(30000); + await _locker.WaitAsync(); + try + { + if (participants.Count < 3) + { + CurrentPhase = Phase.Ended; + return false; + } + + killTimer = new(async _ => + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + //if some players took too long to type a number, boot them all out and start a new round + participants = new HashSet<(ulong, string)>(_passed); + EndRound(); + } + finally { _locker.Release(); } + }, + null, + KILL_TIMEOUT, + KILL_TIMEOUT); + + CurrentPhase = Phase.Playing; + _ = OnGameStarted?.Invoke(this); + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + return true; + } + finally { _locker.Release(); } + } + + public async Task Input(ulong userId, string userName, int input) + { + await _locker.WaitAsync(); + try + { + if (CurrentPhase != Phase.Playing) + return; + + var userTuple = (Id: userId, Name: userName); + + // if the user is not a member of the race, + // or he already successfully typed the number + // ignore the input + if (!participants.Contains(userTuple) || !_passed.Add(userTuple)) + return; + + //if the number is correct + if (CurrentNumber == input - 1) + { + //increment current number + ++CurrentNumber; + if (_passed.Count == participants.Count - 1) + { + // if only n players are left, and n - 1 type the correct number, round is over + + // if only 2 players are left, game is over + if (participants.Count == 2) + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, userTuple.Name); + } + else // else just start the new round without the user who was the last + { + var failure = participants.Except(_passed).First(); + + OnUserGuessed?.Invoke(this); + EndRound(failure); + return; + } + } + + OnUserGuessed?.Invoke(this); + } + else + { + //if the user failed + + EndRound(userTuple); + } + } + finally { _locker.Release(); } + } + + private void EndRound((ulong, string)? failure = null) + { + killTimer.Change(KILL_TIMEOUT, KILL_TIMEOUT); + CurrentNumber = new EllieRandom().Next(0, 100); // reset the counter + _passed.Clear(); // reset all users who passed (new round starts) + if (failure is not null) + participants.Remove(failure.Value); // remove the dude who failed from the list of players + + _ = OnRoundEnded?.Invoke(this, failure); + if (participants.Count <= 1) // means we have a winner or everyone was booted out + { + killTimer.Change(Timeout.Infinite, Timeout.Infinite); + CurrentPhase = Phase.Ended; + _ = OnGameEnded?.Invoke(this, participants.Count > 0 ? participants.First().Name : null); + return; + } + + CurrentPhase = Phase.WaitingForNextRound; + Task.Run(async () => + { + await Task.Delay(NEXT_ROUND_TIMEOUT); + CurrentPhase = Phase.Playing; + _ = OnRoundStarted?.Invoke(this, CurrentNumber); + }); + } + + public void Dispose() + { + OnGameEnded = null; + OnGameStarted = null; + OnRoundEnded = null; + OnRoundStarted = null; + OnUserGuessed = null; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs new file mode 100644 index 0000000..80e6c42 --- /dev/null +++ b/src/EllieBot/Modules/Games/Nunchi/NunchiCommands.cs @@ -0,0 +1,114 @@ +#nullable disable +using EllieBot.Modules.Games.Common.Nunchi; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class NunchiCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public NunchiCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Nunchi() + { + var newNunchi = new NunchiGame(ctx.User.Id, ctx.User.ToString()); + NunchiGame nunchi; + + //if a game was already active + if ((nunchi = _service.NunchiGames.GetOrAdd(ctx.Guild.Id, newNunchi)) != newNunchi) + { + // join it + // if you failed joining, that means game is running or just ended + if (!await nunchi.Join(ctx.User.Id, ctx.User.ToString())) + return; + + await Response().Error(strs.nunchi_joined(nunchi.ParticipantCount)).SendAsync(); + return; + } + + + try { await Response().Confirm(strs.nunchi_created).SendAsync(); } + catch { } + + nunchi.OnGameEnded += NunchiOnGameEnded; + //nunchi.OnGameStarted += Nunchi_OnGameStarted; + nunchi.OnRoundEnded += Nunchi_OnRoundEnded; + nunchi.OnUserGuessed += Nunchi_OnUserGuessed; + nunchi.OnRoundStarted += Nunchi_OnRoundStarted; + _client.MessageReceived += ClientMessageReceived; + + var success = await nunchi.Initialize(); + if (!success) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + game.Dispose(); + await Response().Confirm(strs.nunchi_failed_to_start).SendAsync(); + } + + Task ClientMessageReceived(SocketMessage arg) + { + _ = Task.Run(async () => + { + if (arg.Channel.Id != ctx.Channel.Id) + return; + + if (!int.TryParse(arg.Content, out var number)) + return; + try + { + await nunchi.Input(arg.Author.Id, arg.Author.ToString(), number); + } + catch + { + } + }); + return Task.CompletedTask; + } + + Task NunchiOnGameEnded(NunchiGame arg1, string arg2) + { + if (_service.NunchiGames.TryRemove(ctx.Guild.Id, out var game)) + { + _client.MessageReceived -= ClientMessageReceived; + game.Dispose(); + } + + if (arg2 is null) + return Response().Confirm(strs.nunchi_ended_no_winner).SendAsync(); + return Response().Confirm(strs.nunchi_ended(Format.Bold(arg2))).SendAsync(); + } + } + + private Task Nunchi_OnRoundStarted(NunchiGame arg, int cur) + => Response() + .Confirm(strs.nunchi_round_started(Format.Bold(arg.ParticipantCount.ToString()), + Format.Bold(cur.ToString()))) + .SendAsync(); + + private Task Nunchi_OnUserGuessed(NunchiGame arg) + => Response().Confirm(strs.nunchi_next_number(Format.Bold(arg.CurrentNumber.ToString()))).SendAsync(); + + private Task Nunchi_OnRoundEnded(NunchiGame arg1, (ulong Id, string Name)? arg2) + { + if (arg2.HasValue) + return Response().Confirm(strs.nunchi_round_ended(Format.Bold(arg2.Value.Name))).SendAsync(); + return Response() + .Confirm(strs.nunchi_round_ended_boot( + Format.Bold("\n" + + string.Join("\n, ", + arg1.Participants.Select(x + => x.Name))))) + .SendAsync(); // this won't work if there are too many users + } + + private Task Nunchi_OnGameStarted(NunchiGame arg) + => Response().Confirm(strs.nunchi_started(Format.Bold(arg.ParticipantCount.ToString()))).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs new file mode 100644 index 0000000..65b8bbd --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/SpeedTypingCommands.cs @@ -0,0 +1,105 @@ +#nullable disable +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class SpeedTypingCommands : EllieModule + { + private readonly GamesService _games; + private readonly DiscordSocketClient _client; + + public SpeedTypingCommands(DiscordSocketClient client, GamesService games) + { + _games = games; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TypeStart(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TypingGame.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + var game = _service.RunningContests.GetOrAdd(ctx.Guild.Id, + _ => new(_games, _client, channel, prefix, options, _sender)); + + if (game.IsActive) + await Response().Error($"Contest already running in {game.Channel.Mention} channel.").SendAsync(); + else + await game.Start(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task TypeStop() + { + if (_service.RunningContests.TryRemove(ctx.Guild.Id, out var game)) + { + await game.Stop(); + return; + } + + await Response().Error("No contest to stop on this channel.").SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typeadd([Leftover] string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + _games.AddTypingArticle(ctx.User, text); + + await Response().Confirm("Added new article for typing game.").SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Typelist(int page = 1) + { + if (page < 1) + return; + + var articles = _games.TypingArticles.Skip((page - 1) * 15).Take(15).ToArray(); + + if (!articles.Any()) + { + await Response().Error($"{ctx.User.Mention} `No articles found on that page.`").SendAsync(); + return; + } + + var i = (page - 1) * 15; + await Response() + .Confirm("List of articles for Type Race", + string.Join("\n", articles.Select(a => $"`#{++i}` - {a.Text.TrimTo(50)}"))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task Typedel(int index) + { + var removed = _service.RemoveTypingArticle(--index); + + if (removed is null) + return; + + var embed = _sender.CreateEmbed() + .WithTitle($"Removed typing article #{index + 1}") + .WithDescription(removed.Text.TrimTo(50)) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs new file mode 100644 index 0000000..cb55893 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingArticle.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Games.Common; + +public class TypingArticle +{ + public string Source { get; set; } + public string Extra { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs new file mode 100644 index 0000000..d712525 --- /dev/null +++ b/src/EllieBot/Modules/Games/SpeedTyping/TypingGame.cs @@ -0,0 +1,197 @@ +#nullable disable +using CommandLine; +using EllieBot.Modules.Games.Services; +using System.Diagnostics; + +namespace EllieBot.Modules.Games.Common; + +public class TypingGame +{ + public const float WORD_VALUE = 4.5f; + public ITextChannel Channel { get; } + public string CurrentSentence { get; private set; } + public bool IsActive { get; private set; } + private readonly Stopwatch _sw; + private readonly List _finishedUserIds; + private readonly DiscordSocketClient _client; + private readonly GamesService _games; + private readonly string _prefix; + private readonly Options _options; + private readonly IMessageSenderService _sender; + + public TypingGame( + GamesService games, + DiscordSocketClient client, + ITextChannel channel, + string prefix, + Options options, + IMessageSenderService sender) + { + _games = games; + _client = client; + _prefix = prefix; + _options = options; + _sender = sender; + + Channel = channel; + IsActive = false; + _sw = new(); + _finishedUserIds = new(); + } + + public async Task Stop() + { + if (!IsActive) + return false; + _client.MessageReceived -= AnswerReceived; + _finishedUserIds.Clear(); + IsActive = false; + _sw.Stop(); + _sw.Reset(); + try + { + await _sender.Response(Channel) + .Confirm("Typing contest stopped.") + .SendAsync(); + } + catch + { + } + + return true; + } + + public async Task Start() + { + if (IsActive) + return; // can't start running game + IsActive = true; + CurrentSentence = GetRandomSentence(); + var i = (int)(CurrentSentence.Length / WORD_VALUE * 1.7f); + try + { + await _sender.Response(Channel) + .Confirm( + $":clock2: Next contest will last for {i} seconds. Type the bolded text as fast as you can.") + .SendAsync(); + + + var time = _options.StartTime; + + var msg = await _sender.Response(Channel).Confirm($"Starting new typing contest in **{time}**...").SendAsync(); + + do + { + await Task.Delay(2000); + time -= 2; + try { await msg.ModifyAsync(m => m.Content = $"Starting new typing contest in **{time}**.."); } + catch { } + } while (time > 2); + + await msg.ModifyAsync(m => + { + m.Content = CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture); + }); + _sw.Start(); + HandleAnswers(); + + while (i > 0) + { + await Task.Delay(1000); + i--; + if (!IsActive) + return; + } + } + catch { } + finally + { + await Stop(); + } + } + + public string GetRandomSentence() + { + if (_games.TypingArticles.Any()) + return _games.TypingArticles[new EllieRandom().Next(0, _games.TypingArticles.Count)].Text; + return $"No typing articles found. Use {_prefix}typeadd command to add a new article for typing."; + } + + private void HandleAnswers() + => _client.MessageReceived += AnswerReceived; + + private Task AnswerReceived(SocketMessage imsg) + { + _ = Task.Run(async () => + { + try + { + if (imsg.Author.IsBot) + return; + if (imsg is not SocketUserMessage msg) + return; + + if (Channel is null || Channel.Id != msg.Channel.Id) + return; + + var guess = msg.Content; + + var distance = CurrentSentence.LevenshteinDistance(guess); + var decision = Judge(distance, guess.Length); + if (decision && !_finishedUserIds.Contains(msg.Author.Id)) + { + var elapsed = _sw.Elapsed; + var wpm = CurrentSentence.Length / WORD_VALUE / elapsed.TotalSeconds * 60; + _finishedUserIds.Add(msg.Author.Id); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{msg.Author} finished the race!") + .AddField("Place", $"#{_finishedUserIds.Count}", true) + .AddField("WPM", $"{wpm:F1} *[{elapsed.TotalSeconds:F2}sec]*", true) + .AddField("Errors", distance.ToString(), true); + + await _sender.Response(Channel) + .Embed(embed) + .SendAsync(); + + if (_finishedUserIds.Count % 4 == 0) + { + await _sender.Response(Channel) + .Confirm( + $""" + :exclamation: A lot of people finished, here is the text for those still typing: + + **{Format.Sanitize(CurrentSentence.Replace(" ", " \x200B", StringComparison.InvariantCulture)).SanitizeMentions(true)}** + """) + .SendAsync(); + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error receiving typing game answer: {ErrorMessage}", ex.Message); + } + }); + return Task.CompletedTask; + } + + private static bool Judge(int errors, int textLength) + => errors <= textLength / 25; + + public class Options : IEllieCommandOptions + { + [Option('s', + "start-time", + Default = 5, + Required = false, + HelpText = "How long does it take for the race to start. Default 5.")] + public int StartTime { get; set; } = 5; + + public void NormalizeOptions() + { + if (StartTime is < 3 or > 30) + StartTime = 5; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs new file mode 100644 index 0000000..fa8070d --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToe.cs @@ -0,0 +1,307 @@ +#nullable disable +using CommandLine; +using System.Text; + +namespace EllieBot.Modules.Games.Common; + +public class TicTacToe +{ + public event Action OnEnded; + private readonly ITextChannel _channel; + private readonly IGuildUser[] _users; + private readonly int?[,] _state; + private Phase phase; + private int curUserIndex; + private readonly SemaphoreSlim _moveLock; + + private IGuildUser winner; + + private readonly string[] _numbers = + [ + ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:" + ]; + + private IUserMessage previousMessage; + private Timer timeoutTimer; + private readonly IBotStrings _strings; + private readonly DiscordSocketClient _client; + private readonly Options _options; + private readonly IMessageSenderService _sender; + + public TicTacToe( + IBotStrings strings, + DiscordSocketClient client, + ITextChannel channel, + IGuildUser firstUser, + Options options, + IMessageSenderService sender) + { + _channel = channel; + _strings = strings; + _client = client; + _options = options; + _sender = sender; + + _users = [firstUser, null]; + _state = new int?[,] { { null, null, null }, { null, null, null }, { null, null, null } }; + + phase = Phase.Starting; + _moveLock = new(1, 1); + } + + private string GetText(LocStr key) + => _strings.GetText(key, _channel.GuildId); + + public string GetState() + { + var sb = new StringBuilder(); + for (var i = 0; i < _state.GetLength(0); i++) + { + for (var j = 0; j < _state.GetLength(1); j++) + { + sb.Append(_state[i, j] is null ? _numbers[(i * 3) + j] : GetIcon(_state[i, j])); + if (j < _state.GetLength(1) - 1) + sb.Append("┃"); + } + + if (i < _state.GetLength(0) - 1) + sb.AppendLine("\n──────────"); + } + + return sb.ToString(); + } + + public EmbedBuilder GetEmbed(string title = null) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(Environment.NewLine + GetState()) + .WithAuthor(GetText(strs.vs(_users[0], _users[1]))); + + if (!string.IsNullOrWhiteSpace(title)) + embed.WithTitle(title); + + if (winner is null) + { + if (phase == Phase.Ended) + embed.WithFooter(GetText(strs.ttt_no_moves)); + else + embed.WithFooter(GetText(strs.ttt_users_move(_users[curUserIndex]))); + } + else + embed.WithFooter(GetText(strs.ttt_has_won(winner))); + + return embed; + } + + private static string GetIcon(int? val) + { + switch (val) + { + case 0: + return "❌"; + case 1: + return "⭕"; + case 2: + return "❎"; + case 3: + return "🅾"; + default: + return "⬛"; + } + } + + public async Task Start(IGuildUser user) + { + if (phase is Phase.Started or Phase.Ended) + { + await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_already_running)).SendAsync(); + return; + } + + if (_users[0] == user) + { + await _sender.Response(_channel).Error(user.Mention + GetText(strs.ttt_against_yourself)).SendAsync(); + return; + } + + _users[1] = user; + + phase = Phase.Started; + + timeoutTimer = new(async _ => + { + await _moveLock.WaitAsync(); + try + { + if (phase == Phase.Ended) + return; + + phase = Phase.Ended; + if (_users[1] is not null) + { + winner = _users[curUserIndex ^= 1]; + var del = previousMessage?.DeleteAsync(); + try + { + await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.ttt_time_expired))).SendAsync(); + if (del is not null) + await del; + } + catch { } + } + + OnEnded?.Invoke(this); + } + catch { } + finally + { + _moveLock.Release(); + } + }, + null, + _options.TurnTimer * 1000, + Timeout.Infinite); + + _client.MessageReceived += Client_MessageReceived; + + + previousMessage = await _sender.Response(_channel).Embed(GetEmbed(GetText(strs.game_started))).SendAsync(); + } + + private bool IsDraw() + { + for (var i = 0; i < 3; i++) + for (var j = 0; j < 3; j++) + { + if (_state[i, j] is null) + return false; + } + + return true; + } + + private Task Client_MessageReceived(SocketMessage msg) + { + _ = Task.Run(async () => + { + await _moveLock.WaitAsync(); + try + { + var curUser = _users[curUserIndex]; + if (phase == Phase.Ended || msg.Author?.Id != curUser.Id) + return; + + if (int.TryParse(msg.Content, out var index) + && --index >= 0 + && index <= 9 + && _state[index / 3, index % 3] is null) + { + _state[index / 3, index % 3] = curUserIndex; + + // i'm lazy + if (_state[index / 3, 0] == _state[index / 3, 1] && _state[index / 3, 1] == _state[index / 3, 2]) + { + _state[index / 3, 0] = curUserIndex + 2; + _state[index / 3, 1] = curUserIndex + 2; + _state[index / 3, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (_state[0, index % 3] == _state[1, index % 3] + && _state[1, index % 3] == _state[2, index % 3]) + { + _state[0, index % 3] = curUserIndex + 2; + _state[1, index % 3] = curUserIndex + 2; + _state[2, index % 3] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 0] + && _state[0, 0] == _state[1, 1] + && _state[1, 1] == _state[2, 2]) + { + _state[0, 0] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 2] = curUserIndex + 2; + + phase = Phase.Ended; + } + else if (curUserIndex == _state[0, 2] + && _state[0, 2] == _state[1, 1] + && _state[1, 1] == _state[2, 0]) + { + _state[0, 2] = curUserIndex + 2; + _state[1, 1] = curUserIndex + 2; + _state[2, 0] = curUserIndex + 2; + + phase = Phase.Ended; + } + + var reason = string.Empty; + + if (phase == Phase.Ended) // if user won, stop receiving moves + { + reason = GetText(strs.ttt_matched_three); + winner = _users[curUserIndex]; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + else if (IsDraw()) + { + reason = GetText(strs.ttt_a_draw); + phase = Phase.Ended; + _client.MessageReceived -= Client_MessageReceived; + OnEnded?.Invoke(this); + } + + _ = Task.Run(async () => + { + var del1 = msg.DeleteAsync(); + var del2 = previousMessage?.DeleteAsync(); + try { previousMessage = await _sender.Response(_channel).Embed(GetEmbed(reason)).SendAsync(); } + catch { } + + try { await del1; } + catch { } + + try + { + if (del2 is not null) + await del2; + } + catch { } + }); + curUserIndex ^= 1; + + timeoutTimer.Change(_options.TurnTimer * 1000, Timeout.Infinite); + } + } + finally + { + _moveLock.Release(); + } + }); + + return Task.CompletedTask; + } + + public class Options : IEllieCommandOptions + { + [Option('t', "turn-timer", Required = false, Default = 15, HelpText = "Turn time in seconds. Default 15.")] + public int TurnTimer { get; set; } = 15; + + public void NormalizeOptions() + { + if (TurnTimer is < 5 or > 60) + TurnTimer = 15; + } + } + + private enum Phase + { + Starting, + Started, + Ended + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs new file mode 100644 index 0000000..904f8db --- /dev/null +++ b/src/EllieBot/Modules/Games/TicTacToe/TicTacToeCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +using EllieBot.Modules.Games.Common; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TicTacToeCommands : EllieModule + { + private readonly SemaphoreSlim _sem = new(1, 1); + private readonly DiscordSocketClient _client; + + public TicTacToeCommands(DiscordSocketClient client) + => _client = client; + + [Cmd] + [RequireContext(ContextType.Guild)] + [EllieOptions] + public async Task TicTacToe(params string[] args) + { + var (options, _) = OptionsParser.ParseFrom(new TicTacToe.Options(), args); + var channel = (ITextChannel)ctx.Channel; + + await _sem.WaitAsync(1000); + try + { + if (_service.TicTacToeGames.TryGetValue(channel.Id, out var game)) + { + _ = Task.Run(async () => + { + await game.Start((IGuildUser)ctx.User); + }); + return; + } + + game = new(Strings, _client, channel, (IGuildUser)ctx.User, options, _sender); + _service.TicTacToeGames.Add(channel.Id, game); + await Response().Confirm(strs.ttt_created(ctx.User)).SendAsync(); + + game.OnEnded += _ => + { + _service.TicTacToeGames.Remove(channel.Id); + _sem.Dispose(); + }; + } + finally + { + _sem.Release(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/Games.cs b/src/EllieBot/Modules/Games/Trivia/Games.cs new file mode 100644 index 0000000..ff7ffbd --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/Games.cs @@ -0,0 +1,282 @@ +using System.Net; +using System.Text; +using EllieBot.Modules.Games.Common.Trivia; +using EllieBot.Modules.Games.Services; + +namespace EllieBot.Modules.Games; + +public partial class Games +{ + [Group] + public partial class TriviaCommands : EllieModule + { + private readonly ILocalDataCache _cache; + private readonly ICurrencyService _cs; + private readonly GamesConfigService _gamesConfig; + private readonly DiscordSocketClient _client; + + public TriviaCommands( + DiscordSocketClient client, + ILocalDataCache cache, + ICurrencyService cs, + GamesConfigService gamesConfig) + { + _cache = cache; + _cs = cs; + _gamesConfig = gamesConfig; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + [EllieOptions] + public async Task Trivia(params string[] args) + { + var (opts, _) = OptionsParser.ParseFrom(new TriviaOptions(), args); + + var config = _gamesConfig.Data; + if (opts.WinRequirement != 0 + && config.Trivia.MinimumWinReq > 0 + && config.Trivia.MinimumWinReq > opts.WinRequirement) + return; + + var trivia = new TriviaGame(opts, _cache); + if (_service.RunningTrivias.TryAdd(ctx.Guild.Id, trivia)) + { + RegisterEvents(trivia); + await trivia.RunAsync(); + return; + } + + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var tg)) + { + await Response().Error(strs.trivia_already_running).SendAsync(); + await tg.TriggerQuestionAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tl() + { + if (_service.RunningTrivias.TryGetValue(ctx.Guild.Id, out var trivia)) + { + await trivia.TriggerStatsAsync(); + return; + } + + await Response().Error(strs.trivia_none).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Tq() + { + var channel = (ITextChannel)ctx.Channel; + + if (_service.RunningTrivias.TryGetValue(channel.Guild.Id, out var trivia)) + { + if (trivia.Stop()) + { + try + { + await Response() + .Confirm(GetText(strs.trivia_game), GetText(strs.trivia_stopping)) + .SendAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error sending trivia stopping message"); + } + } + + return; + } + + await Response().Error(strs.trivia_none).SendAsync(); + } + + private string GetLeaderboardString(TriviaGame tg) + { + var sb = new StringBuilder(); + + foreach (var (id, pts) in tg.GetLeaderboard()) + sb.AppendLine(GetText(strs.trivia_points(Format.Bold($"<@{id}>"), pts))); + + return sb.ToString(); + } + + private EmbedBuilder? questionEmbed = null; + private IUserMessage? questionMessage = null; + private bool showHowToQuit = false; + + private void RegisterEvents(TriviaGame trivia) + { + trivia.OnQuestion += OnTriviaQuestion; + trivia.OnHint += OnTriviaHint; + trivia.OnGuess += OnTriviaGuess; + trivia.OnEnded += OnTriviaEnded; + trivia.OnStats += OnTriviaStats; + trivia.OnTimeout += OnTriviaTimeout; + } + + private void UnregisterEvents(TriviaGame trivia) + { + trivia.OnQuestion -= OnTriviaQuestion; + trivia.OnHint -= OnTriviaHint; + trivia.OnGuess -= OnTriviaGuess; + trivia.OnEnded -= OnTriviaEnded; + trivia.OnStats -= OnTriviaStats; + trivia.OnTimeout -= OnTriviaTimeout; + } + + private async Task OnTriviaHint(TriviaGame game, TriviaQuestion question) + { + try + { + if (questionMessage is null) + { + game.Stop(); + return; + } + + if (questionEmbed is not null) + await questionMessage.ModifyAsync(m + => m.Embed = questionEmbed.WithFooter(question.GetHint()).Build()); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) + { + Log.Warning("Unable to edit message to show hint. Stopping trivia"); + game.Stop(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error editing trivia message"); + } + } + + private async Task OnTriviaQuestion(TriviaGame game, TriviaQuestion question) + { + try + { + questionEmbed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .AddField(GetText(strs.category), question.Category) + .AddField(GetText(strs.question), question.Question); + + showHowToQuit = !showHowToQuit; + if (showHowToQuit) + questionEmbed.WithFooter(GetText(strs.trivia_quit($"{prefix}tq"))); + + if (Uri.IsWellFormedUriString(question.ImageUrl, UriKind.Absolute)) + questionEmbed.WithImageUrl(question.ImageUrl); + + questionMessage = await Response().Embed(questionEmbed).SendAsync(); + } + catch (HttpException ex) when (ex.HttpCode is HttpStatusCode.NotFound + or HttpStatusCode.Forbidden + or HttpStatusCode.BadRequest) + { + Log.Warning("Unable to send trivia questions. Stopping immediately"); + game.Stop(); + throw; + } + } + + private async Task OnTriviaTimeout(TriviaGame _, TriviaQuestion question) + { + try + { + var embed = _sender.CreateEmbed() + .WithErrorColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_times_up(Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + await Response().Embed(embed).SendAsync(); + } + catch + { + // ignored + } + } + + private async Task OnTriviaStats(TriviaGame game) + { + try + { + await Response().Confirm(GetText(strs.leaderboard), GetLeaderboardString(game)).SendAsync(); + } + catch + { + // ignored + } + } + + private async Task OnTriviaEnded(TriviaGame game) + { + try + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.trivia_ended)) + .WithTitle(GetText(strs.leaderboard)) + .WithDescription(GetLeaderboardString(game))).SendAsync(); + } + catch + { + // ignored + } + finally + { + _service.RunningTrivias.TryRemove(ctx.Guild.Id, out _); + } + + UnregisterEvents(game); + } + + private async Task OnTriviaGuess( + TriviaGame _, + TriviaUser user, + TriviaQuestion question, + bool isWin) + { + try + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.trivia_game)) + .WithDescription(GetText(strs.trivia_win(user.Name, + Format.Bold(question.Answer)))); + + if (Uri.IsWellFormedUriString(question.AnswerImageUrl, UriKind.Absolute)) + embed.WithImageUrl(question.AnswerImageUrl); + + + if (isWin) + { + await Response().Embed(embed).SendAsync(); + + var reward = _gamesConfig.Data.Trivia.CurrencyReward; + if (reward > 0) + await _cs.AddAsync(user.Id, reward, new("trivia", "win")); + + return; + } + + embed.WithDescription(GetText(strs.trivia_guess(user.Name, + Format.Bold(question.Answer)))); + + await Response().Embed(embed).SendAsync(); + } + catch + { + // ignored + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs new file mode 100644 index 0000000..b82dd62 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/DefaultQuestionPool.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class DefaultQuestionPool : IQuestionPool +{ + private readonly ILocalDataCache _cache; + private readonly EllieRandom _rng; + + public DefaultQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + public async Task GetQuestionAsync() + { + var pool = await _cache.GetTriviaQuestionsAsync(); + + if(pool is null or {Length: 0}) + return default; + + return new(pool[_rng.Next(0, pool.Length)]); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs new file mode 100644 index 0000000..636ae16 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/IQuestionPool.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public interface IQuestionPool +{ + Task GetQuestionAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs new file mode 100644 index 0000000..53f56cf --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/QuestionPool/PokemonQuestionPool.cs @@ -0,0 +1,32 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class PokemonQuestionPool : IQuestionPool +{ + public int QuestionsCount => 905; // xd + private readonly EllieRandom _rng; + private readonly ILocalDataCache _cache; + + public PokemonQuestionPool(ILocalDataCache cache) + { + _cache = cache; + _rng = new EllieRandom(); + } + + public async Task GetQuestionAsync() + { + var pokes = await _cache.GetPokemonMapAsync(); + + if (pokes is null or { Count: 0 }) + return default; + + var num = _rng.Next(1, QuestionsCount + 1); + return new(new() + { + Question = "Who's That Pokémon?", + Answer = pokes[num].ToTitleCase(), + Category = "Pokemon", + ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png", + AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png" + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs new file mode 100644 index 0000000..4223104 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGame.cs @@ -0,0 +1,219 @@ +using System.Threading.Channels; +using Exception = System.Exception; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public sealed class TriviaGame +{ + private readonly TriviaOptions _opts; + + + private readonly IQuestionPool _questionPool; + + #region Events + public event Func OnQuestion = static delegate { return Task.CompletedTask; }; + public event Func OnHint = static delegate { return Task.CompletedTask; }; + public event Func OnStats = static delegate { return Task.CompletedTask; }; + public event Func OnGuess = static delegate { return Task.CompletedTask; }; + public event Func OnTimeout = static delegate { return Task.CompletedTask; }; + public event Func OnEnded = static delegate { return Task.CompletedTask; }; + #endregion + + private bool _isStopped; + + public TriviaQuestion? CurrentQuestion { get; set; } + + + private readonly ConcurrentDictionary _users = new (); + + private readonly Channel<(TriviaUser User, string Input)> _inputs + = Channel.CreateUnbounded<(TriviaUser, string)>(new UnboundedChannelOptions + { + AllowSynchronousContinuations = true, + SingleReader = true, + SingleWriter = false, + }); + + public TriviaGame(TriviaOptions options, ILocalDataCache cache) + { + _opts = options; + + _questionPool = _opts.IsPokemon + ? new PokemonQuestionPool(cache) + : new DefaultQuestionPool(cache); + + } + public async Task RunAsync() + { + await GameLoop(); + } + + private async Task GameLoop() + { + Task TimeOutFactory() => Task.Delay(_opts.QuestionTimer * 1000 / 2); + + var errorCount = 0; + var inactivity = 0; + + // loop until game is stopped + // each iteration is one round + var firstRun = true; + try + { + while (!_isStopped) + { + if (errorCount >= 5) + { + Log.Warning("Trivia errored 5 times and will quit"); + break; + } + + // wait for 3 seconds before posting the next question + if (firstRun) + { + firstRun = false; + } + else + { + await Task.Delay(3000); + } + + var maybeQuestion = await _questionPool.GetQuestionAsync(); + + if (maybeQuestion is not { } question) + { + // if question is null (ran out of question, or other bugg ) - stop + break; + } + + CurrentQuestion = question; + try + { + // clear out all of the past guesses + while (_inputs.Reader.TryRead(out _)) + ; + + await OnQuestion(this, question); + } + catch (Exception ex) + { + Log.Warning(ex, "Error executing OnQuestion: {Message}", ex.Message); + errorCount++; + continue; + } + + + // just keep looping through user inputs until someone guesses the answer + // or the timer expires + var halfGuessTimerTask = TimeOutFactory(); + var hintSent = false; + var guessed = false; + while (true) + { + using var readCancel = new CancellationTokenSource(); + var readTask = _inputs.Reader.ReadAsync(readCancel.Token).AsTask(); + + // wait for either someone to attempt to guess + // or for timeout + var task = await Task.WhenAny(readTask, halfGuessTimerTask); + + // if the task which completed is the timeout task + if (task == halfGuessTimerTask) + { + readCancel.Cancel(); + + // if hint is already sent, means time expired + // break (end the round) + if (hintSent) + break; + + // else, means half time passed, send a hint + hintSent = true; + // start a new countdown of the same length + halfGuessTimerTask = TimeOutFactory(); + if (!_opts.NoHint) + { + // send a hint out + await OnHint(this, question); + } + + continue; + } + + // otherwise, read task is successful, and we're gonna + // get the user input data + var (user, input) = await readTask; + + // check the guess + if (question.IsAnswerCorrect(input)) + { + // add 1 point to the user + var val = _users.AddOrUpdate(user.Id, 1, (_, points) => ++points); + guessed = true; + + // reset inactivity counter + inactivity = 0; + errorCount = 0; + + var isWin = false; + // if user won the game, tell the game to stop + if (_opts.WinRequirement != 0 && val >= _opts.WinRequirement) + { + _isStopped = true; + isWin = true; + } + + // call onguess + await OnGuess(this, user, question, isWin); + break; + } + } + + if (!guessed) + { + await OnTimeout(this, question); + + if (_opts.Timeout != 0 && ++inactivity >= _opts.Timeout) + { + Log.Information("Trivia game is stopping due to inactivity"); + break; + } + } + } + } + catch (Exception ex) + { + Log.Error(ex, "Fatal error in trivia game: {ErrorMessage}", ex.Message); + } + finally + { + // make sure game is set as ended + _isStopped = true; + _ = OnEnded(this); + } + } + + public IReadOnlyList<(ulong User, int points)> GetLeaderboard() + => _users.Select(x => (x.Key, x.Value)).ToArray(); + + public ValueTask InputAsync(TriviaUser user, string input) + => _inputs.Writer.WriteAsync((user, input)); + + public bool Stop() + { + var isStopped = _isStopped; + _isStopped = true; + return !isStopped; + } + + public async ValueTask TriggerStatsAsync() + { + await OnStats(this); + } + + public async Task TriggerQuestionAsync() + { + if(CurrentQuestion is TriviaQuestion q) + await OnQuestion(this, q); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs new file mode 100644 index 0000000..6fc4ab6 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaGamesService.cs @@ -0,0 +1,37 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Games.Common.Trivia; + +namespace EllieBot.Modules.Games; + +public sealed class TriviaGamesService : IReadyExecutor, IEService +{ + private readonly DiscordSocketClient _client; + public ConcurrentDictionary RunningTrivias { get; } = new(); + + public TriviaGamesService(DiscordSocketClient client) + { + _client = client; + } + + public Task OnReadyAsync() + { + _client.MessageReceived += OnMessageReceived; + + return Task.CompletedTask; + } + + private async Task OnMessageReceived(SocketMessage msg) + { + if (msg.Author.IsBot) + return; + + var umsg = msg as SocketUserMessage; + + if (umsg?.Channel is not IGuildChannel gc) + return; + + if (RunningTrivias.TryGetValue(gc.GuildId, out var tg)) + await tg.InputAsync(new(umsg.Author.Mention, umsg.Author.Id), umsg.Content); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs new file mode 100644 index 0000000..47bdc0b --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaOptions.cs @@ -0,0 +1,44 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public class TriviaOptions : IEllieCommandOptions +{ + [Option('p', "pokemon", Required = false, Default = false, HelpText = "Whether it's 'Who's that pokemon?' trivia.")] + public bool IsPokemon { get; set; } = false; + + [Option("nohint", Required = false, Default = false, HelpText = "Don't show any hints.")] + public bool NoHint { get; set; } = false; + + [Option('w', + "win-req", + Required = false, + Default = 10, + HelpText = "Winning requirement. Set 0 for an infinite game. Default 10.")] + public int WinRequirement { get; set; } = 10; + + [Option('q', + "question-timer", + Required = false, + Default = 30, + HelpText = "How long until the question ends. Default 30.")] + public int QuestionTimer { get; set; } = 30; + + [Option('t', + "timeout", + Required = false, + Default = 10, + HelpText = "Number of questions of inactivity in order stop. Set 0 for never. Default 10.")] + public int Timeout { get; set; } = 10; + + public void NormalizeOptions() + { + if (WinRequirement < 0) + WinRequirement = 10; + if (QuestionTimer is < 10 or > 300) + QuestionTimer = 30; + if (Timeout is < 0 or > 20) + Timeout = 10; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs new file mode 100644 index 0000000..299c762 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaQuestion.cs @@ -0,0 +1,115 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Games.Common.Trivia; + +public class TriviaQuestion +{ + public const int MAX_STRING_LENGTH = 22; + + //represents the min size to judge levDistance with + private static readonly HashSet> _strictness = + [ + new(9, 0), + new(14, 1), + new(19, 2), + new(22, 3) + ]; + + public string Category + => _qModel.Category; + + public string Question + => _qModel.Question; + + public string ImageUrl + => _qModel.ImageUrl; + + public string AnswerImageUrl + => _qModel.AnswerImageUrl ?? ImageUrl; + + public string Answer + => _qModel.Answer; + + public string CleanAnswer + => cleanAnswer ?? (cleanAnswer = Clean(Answer)); + + private string cleanAnswer; + private readonly TriviaQuestionModel _qModel; + + public TriviaQuestion(TriviaQuestionModel qModel) + { + _qModel = qModel; + } + + public string GetHint() + => Scramble(Answer); + + public bool IsAnswerCorrect(string guess) + { + if (Answer.Equals(guess, StringComparison.InvariantCulture)) + return true; + var cleanGuess = Clean(guess); + if (CleanAnswer.Equals(cleanGuess, StringComparison.InvariantCulture)) + return true; + + var levDistanceClean = CleanAnswer.LevenshteinDistance(cleanGuess); + var levDistanceNormal = Answer.LevenshteinDistance(guess); + return JudgeGuess(CleanAnswer.Length, cleanGuess.Length, levDistanceClean) + || JudgeGuess(Answer.Length, guess.Length, levDistanceNormal); + } + + private static bool JudgeGuess(int guessLength, int answerLength, int levDistance) + { + foreach (var level in _strictness) + { + if (guessLength <= level.Item1 || answerLength <= level.Item1) + { + if (levDistance <= level.Item2) + return true; + return false; + } + } + + return false; + } + + private static string Clean(string str) + { + str = " " + str.ToLowerInvariant() + " "; + str = Regex.Replace(str, @"\s+", " "); + str = Regex.Replace(str, @"[^\w\d\s]", ""); + //Here's where custom modification can be done + str = Regex.Replace(str, @"\s(a|an|the|of|in|for|to|as|at|be)\s", " "); + //End custom mod and cleanup whitespace + str = Regex.Replace(str, @"^\s+", ""); + str = Regex.Replace(str, @"\s+$", ""); + //Trim the really long answers + str = str.Length <= MAX_STRING_LENGTH ? str : str[..MAX_STRING_LENGTH]; + return str; + } + + private static string Scramble(string word) + { + var letters = word.ToCharArray(); + var count = 0; + for (var i = 0; i < letters.Length; i++) + { + if (letters[i] == ' ') + continue; + + count++; + if (count <= letters.Length / 5) + continue; + + if (count % 3 == 0) + continue; + + if (letters[i] != ' ') + letters[i] = '_'; + } + + return string.Join(" ", + new string(letters).Replace(" ", " \u2000", StringComparison.InvariantCulture).AsEnumerable()); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs new file mode 100644 index 0000000..b61e827 --- /dev/null +++ b/src/EllieBot/Modules/Games/Trivia/TriviaUser.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Games.Common.Trivia; + +public record class TriviaUser(string Name, ulong Id); \ No newline at end of file -- 2.43.0 From 79e7252f99df1c045050d6b62a14672d6712f232 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:51:19 +1200 Subject: [PATCH 024/340] Added Help module --- .../Modules/Help/CommandJsonObject.cs | 13 + src/EllieBot/Modules/Help/CommandsOptions.cs | 26 + src/EllieBot/Modules/Help/Help.cs | 593 ++++++++++++++++++ src/EllieBot/Modules/Help/HelpService.cs | 44 ++ 4 files changed, 676 insertions(+) create mode 100644 src/EllieBot/Modules/Help/CommandJsonObject.cs create mode 100644 src/EllieBot/Modules/Help/CommandsOptions.cs create mode 100644 src/EllieBot/Modules/Help/Help.cs create mode 100644 src/EllieBot/Modules/Help/HelpService.cs diff --git a/src/EllieBot/Modules/Help/CommandJsonObject.cs b/src/EllieBot/Modules/Help/CommandJsonObject.cs new file mode 100644 index 0000000..062a0b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandJsonObject.cs @@ -0,0 +1,13 @@ +#nullable disable +namespace EllieBot.Modules.Help; + +internal class CommandJsonObject +{ + public string[] Aliases { get; set; } + public string Description { get; set; } + public string[] Usage { get; set; } + public string Submodule { get; set; } + public string Module { get; set; } + public List Options { get; set; } + public string[] Requirements { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/CommandsOptions.cs b/src/EllieBot/Modules/Help/CommandsOptions.cs new file mode 100644 index 0000000..ecbb06c --- /dev/null +++ b/src/EllieBot/Modules/Help/CommandsOptions.cs @@ -0,0 +1,26 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Help.Common; + +public class CommandsOptions : IEllieCommandOptions +{ + public enum ViewType + { + Hide, + Cross, + All + } + + [Option('v', + "view", + Required = false, + Default = ViewType.Hide, + HelpText = + "Specifies how to output the list of commands. 0 - Hide commands which you can't use, 1 - Cross out commands which you can't use, 2 - Show all.")] + public ViewType View { get; set; } = ViewType.Hide; + + public void NormalizeOptions() + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/Help.cs b/src/EllieBot/Modules/Help/Help.cs new file mode 100644 index 0000000..79367b9 --- /dev/null +++ b/src/EllieBot/Modules/Help/Help.cs @@ -0,0 +1,593 @@ +#nullable disable +using EllieBot.Modules.Help.Common; +using EllieBot.Modules.Help.Services; +using Newtonsoft.Json; +using System.Text; +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules.Help; + +public sealed partial class Help : EllieModule +{ + public const string PATREON_URL = "https://patreon.com/toastie_t0ast"; + public const string PAYPAL_URL = "https://paypal.me/EmotionChild"; + + private readonly ICommandsUtilityService _cus; + private readonly CommandService _cmds; + private readonly BotConfigService _bss; + private readonly IPermissionChecker _perms; + private readonly IServiceProvider _services; + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + + private readonly AsyncLazy _lazyClientId; + private readonly IMarmaladeLoaderService _marmalades; + + public Help( + ICommandsUtilityService _cus, + IPermissionChecker perms, + CommandService cmds, + BotConfigService bss, + IServiceProvider services, + DiscordSocketClient client, + IBotStrings strings, + IMarmaladeLoaderService marmalades) + { + this._cus = _cus; + _cmds = cmds; + _bss = bss; + _perms = perms; + _services = services; + _client = client; + _strings = strings; + _marmalades = marmalades; + + _lazyClientId = new(async () => (await _client.GetApplicationInfoAsync()).Id); + } + + public async Task GetHelpString() + { + var botSettings = _bss.Data; + if (string.IsNullOrWhiteSpace(botSettings.HelpText) || botSettings.HelpText == "-") + return default; + + var clientId = await _lazyClientId.Value; + var repCtx = new ReplacementContext(Context) + .WithOverride("{0}", () => clientId.ToString()) + .WithOverride("{1}", () => prefix) + .WithOverride("%prefix%", () => prefix) + .WithOverride("%bot.prefix%", () => prefix); + + var text = SmartText.CreateFrom(botSettings.HelpText); + return await repSvc.ReplaceAsync(text, repCtx); + } + + [Cmd] + public async Task Modules(int page = 1) + { + if (--page < 0) + return; + + var topLevelModules = new List(); + foreach (var m in _cmds.Modules.GroupBy(x => x.GetTopLevelModule()).OrderBy(x => x.Key.Name).Select(x => x.Key)) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + m.Name, + null); + +#if GLOBAL_ELLIE + if (m.Preconditions.Any(x => x is NoPublicBotAttribute)) + continue; +#endif + + if (result.IsAllowed) + topLevelModules.Add(m); + } + + var menu = new SelectMenuBuilder() + .WithPlaceholder("Select a module to see its commands") + .WithCustomId("cmds:modules_select"); + + foreach (var m in topLevelModules) + menu.AddOption(m.Name, m.Name, GetModuleEmoji(m.Name)); + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + var val = smc.Data.Values.FirstOrDefault(); + if (val is null) + return; + + await Commands(val); + }); + + await Response() + .Paginated() + .Items(topLevelModules) + .PageSize(12) + .CurrentPage(page) + .Interaction(inter) + .AddFooter(false) + .Page((items, _) => + { + var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.list_of_modules)); + + if (!items.Any()) + { + embed = embed.WithOkColor().WithDescription(GetText(strs.module_page_empty)); + return embed; + } + + items + .ToList() + .ForEach(module => embed.AddField($"{GetModuleEmoji(module.Name)} {module.Name}", + GetModuleDescription(module.Name) + + "\n" + + Format.Code(GetText(strs.module_footer(prefix, module.Name.ToLowerInvariant()))), + true)); + + return embed; + }) + .SendAsync(); + } + + private string GetModuleDescription(string moduleName) + { + var key = GetModuleLocStr(moduleName); + + if (key.Key == strs.module_description_missing.Key) + { + var desc = _marmalades + .GetLoadedMarmalades(Culture) + .FirstOrDefault(m => m.Canaries + .Any(x => x.Name.Equals(moduleName, + StringComparison.InvariantCultureIgnoreCase))) + ?.Description; + + if (desc is not null) + return desc; + } + + return GetText(key); + } + + private LocStr GetModuleLocStr(string moduleName) + { + switch (moduleName.ToLowerInvariant()) + { + case "help": + return strs.module_description_help; + case "administration": + return strs.module_description_administration; + case "expressions": + return strs.module_description_expressions; + case "searches": + return strs.module_description_searches; + case "utility": + return strs.module_description_utility; + case "games": + return strs.module_description_games; + case "gambling": + return strs.module_description_gambling; + case "music": + return strs.module_description_music; + case "nsfw": + return strs.module_description_nsfw; + case "permissions": + return strs.module_description_permissions; + case "xp": + return strs.module_description_xp; + case "marmalade": + return strs.module_description_marmalade; + case "patronage": + return strs.module_description_patronage; + default: + return strs.module_description_missing; + } + } + + private string GetModuleEmoji(string moduleName) + { + moduleName = moduleName.ToLowerInvariant(); + switch (moduleName) + { + case "help": + return "❓"; + case "administration": + return "🛠️"; + case "expressions": + return "🗣️"; + case "searches": + return "🔍"; + case "utility": + return "🔧"; + case "games": + return "🎲"; + case "gambling": + return "💰"; + case "music": + return "🎶"; + case "nsfw": + return "😳"; + case "permissions": + return "🚓"; + case "xp": + return "📝"; + case "patronage": + return "💝"; + default: + return "📖"; + } + } + + [Cmd] + [EllieOptions] + public async Task Commands(string module = null, params string[] args) + { + if (string.IsNullOrWhiteSpace(module)) + { + await Modules(); + return; + } + + var (opts, _) = OptionsParser.ParseFrom(new CommandsOptions(), args); + + // Find commands for that module + // don't show commands which are blocked + // order by name + var allowed = new List(); + + var mdls = _cmds.Commands + .Where(c => c.Module.GetTopLevelModule() + .Name + .StartsWith(module, StringComparison.InvariantCultureIgnoreCase)) + .ToArray(); + + if (mdls.Length == 0) + { + var group = _cmds.Modules + .Where(x => x.Parent is not null) + .FirstOrDefault(x => string.Equals(x.Name.Replace("Commands", ""), + module, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + } + + foreach (var cmd in mdls) + { + var result = await _perms.CheckPermsAsync(ctx.Guild, + ctx.Channel, + ctx.User, + cmd.Module.GetTopLevelModule().Name, + cmd.Name); + + if (result.IsAllowed) + allowed.Add(cmd); + } + + + var cmds = allowed.OrderBy(c => c.Aliases[0]) + .DistinctBy(x => x.Aliases[0]) + .ToList(); + + + // check preconditions for all commands, but only if it's not 'all' + // because all will show all commands anyway, no need to check + var succ = new HashSet(); + if (opts.View != CommandsOptions.ViewType.All) + { + succ = + [ + ..(await cmds.Select(async x => + { + var pre = await x.CheckPreconditionsAsync(Context, _services); + return (Cmd: x, Succ: pre.IsSuccess); + }) + .WhenAll()).Where(x => x.Succ) + .Select(x => x.Cmd) + ]; + + if (opts.View == CommandsOptions.ViewType.Hide) + // if hidden is specified, completely remove these commands from the list + cmds = cmds.Where(x => succ.Contains(x)).ToList(); + } + + var cmdsWithGroup = cmds.GroupBy(c => c.Module.GetGroupName()) + .OrderBy(x => x.Key == x.First().Module.Name ? int.MaxValue : x.Count()) + .ToList(); + + if (cmdsWithGroup.Count == 0) + { + if (opts.View != CommandsOptions.ViewType.Hide) + await Response().Error(strs.module_not_found).SendAsync(); + else + await Response().Error(strs.module_not_found_or_cant_exec).SendAsync(); + return; + } + + var sb = new SelectMenuBuilder() + .WithCustomId("cmds:submodule_select") + .WithPlaceholder("Select a submodule to see detailed commands"); + + var groups = cmdsWithGroup.ToArray(); + var embed = _sender.CreateEmbed().WithOkColor(); + foreach (var g in groups) + { + sb.AddOption(g.Key, g.Key); + var transformed = g + .Select(x => + { + //if cross is specified, and the command doesn't satisfy the requirements, cross it out + if (opts.View == CommandsOptions.ViewType.Cross) + { + return $"{(succ.Contains(x) ? "✅" : "❌")} {prefix + x.Aliases[0]}"; + } + + + if (x.Aliases.Count == 1) + return prefix + x.Aliases[0]; + + return prefix + x.Aliases[0] + " | " + prefix + x.Aliases[1]; + }); + + embed.AddField(g.Key, "" + string.Join("\n", transformed) + "", true); + } + + embed.WithFooter(GetText(strs.commands_instr(prefix))); + + + var inter = _inter.Create(ctx.User.Id, + sb, + async (smc) => + { + var groupName = smc.Data.Values.FirstOrDefault(); + var mdl = _cmds.Modules.FirstOrDefault(x + => string.Equals(x.Name.Replace("Commands", ""), groupName, StringComparison.InvariantCultureIgnoreCase)); + await smc.DeferAsync(); + await Group(mdl); + } + ); + + await Response().Embed(embed).Interaction(inter).SendAsync(); + } + + private async Task Group(ModuleInfo group) + { + var menu = new SelectMenuBuilder() + .WithCustomId("cmds:group_select") + .WithPlaceholder("Select a command to see its details"); + + foreach (var cmd in group.Commands.DistinctBy(x => x.Aliases[0])) + { + menu.AddOption(prefix + cmd.Aliases[0], cmd.Aliases[0]); + } + + var inter = _inter.Create(ctx.User.Id, + menu, + async (smc) => + { + await smc.DeferAsync(); + + await H(smc.Data.Values.FirstOrDefault()); + }); + + await Response() + .Paginated() + .Items(group.Commands.DistinctBy(x => x.Aliases[0]).ToArray()) + .PageSize(25) + .Interaction(inter) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.cmd_group_commands(group.Name))) + .WithOkColor(); + + foreach (var cmd in items) + { + string cmdName; + if (cmd.Aliases.Count > 1) + cmdName = Format.Code(prefix + cmd.Aliases[0]) + " | " + Format.Code(prefix + cmd.Aliases[1]); + else + cmdName = Format.Code(prefix + cmd.Aliases.First()); + + eb.AddField(cmdName, cmd.RealSummary(_strings, _marmalades, Culture, prefix)); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [Priority(0)] + public async Task H([Leftover] string fail) + { + var prefixless = + _cmds.Commands.FirstOrDefault(x => x.Aliases.Any(cmdName => cmdName.ToLowerInvariant() == fail)); + if (prefixless is not null) + { + await H(prefixless); + return; + } + + if (fail.StartsWith(prefix)) + fail = fail.Substring(prefix.Length); + + var group = _cmds.Modules + .SelectMany(x => x.Submodules) + .FirstOrDefault(x => string.Equals(x.Group, + fail, + StringComparison.InvariantCultureIgnoreCase)); + + if (group is not null) + { + await Group(group); + return; + } + + await Response().Error(strs.command_not_found).SendAsync(); + } + + [Cmd] + [Priority(1)] + public async Task H([Leftover] CommandInfo com = null) + { + var channel = ctx.Channel; + if (com is null) + { + try + { + var ch = channel is ITextChannel ? await ctx.User.CreateDMChannelAsync() : channel; + var data = await GetHelpString(); + if (data == default) + return; + + await Response().Channel(ch).Text(data).SendAsync(); + try + { + await ctx.OkAsync(); + } + catch + { + } // ignore if bot can't react + } + catch (Exception) + { + await Response().Error(strs.cant_dm).SendAsync(); + } + + return; + } + + var embed = _cus.GetCommandHelp(com, ctx.Guild); + await _sender.Response(channel).Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GenCmdList() + { + _ = ctx.Channel.TriggerTypingAsync(); + + // order commands by top level module name + // and make a dictionary of > + var cmdData = _cmds.Commands.GroupBy(x => x.Module.GetTopLevelModule().Name) + .OrderBy(x => x.Key) + .ToDictionary(x => x.Key, + x => x.DistinctBy(c => c.Aliases.First()) + .Select(com => + { + List optHelpStr = null; + + var opt = CommandsUtilityService.GetEllieOptionType(com.Attributes); + if (opt is not null) + optHelpStr = CommandsUtilityService.GetCommandOptionHelpList(opt); + + return new CommandJsonObject + { + Aliases = com.Aliases.Select(alias => prefix + alias).ToArray(), + Description = com.RealSummary(_strings, _marmalades, Culture, prefix), + Usage = com.RealRemarksArr(_strings, _marmalades, Culture, prefix), + Submodule = com.Module.Name, + Module = com.Module.GetTopLevelModule().Name, + Options = optHelpStr, + Requirements = CommandsUtilityService.GetCommandRequirements(com) + }; + }) + .ToList()); + + var readableData = JsonConvert.SerializeObject(cmdData, Formatting.Indented); + + // send the indented file to chat + await using var rDataStream = new MemoryStream(Encoding.ASCII.GetBytes(readableData)); + await ctx.Channel.SendFileAsync(rDataStream, "cmds.json", GetText(strs.commandlist_regen)); + } + + [Cmd] + public async Task Guide() + => await Response() + .Confirm(strs.guide("https://commands.elliebot.net", + "https://docs.elliebot.net/")) + .SendAsync(); + + + private Task SelfhostAction(SocketMessageComponent smc) + => smc.RespondConfirmAsync(_sender, + """ + - In case you don't want or cannot Donate to EllieBot project, but you + - EllieBot is a free and [open source](https://toastielab.dev/Emotions-stuff/Ellie) project which means you can run your own "selfhosted" instance on your computer. + + *Keep in mind that running the bot on your computer means that the bot will be offline when you turn off your computer* + + - You can find the selfhosting guides by using the `.guide` command and clicking on the second link that pops up. + - If you decide to selfhost the bot, still consider [supporting the project](https://patreon.com/join/toastie_t0ast) to keep the development going :) + """, + true); + + [Cmd] + [OnlyPublicBot] + public async Task Donate() + { + var selfhostInter = _inter.Create(ctx.User.Id, + new ButtonBuilder( + emote: new Emoji("🖥️"), + customId: "donate:selfhosting", + label: "Selfhosting"), + SelfhostAction); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Thank you for considering to donate to the EllieBot project!"); + + eb + .WithDescription(""" + EllieBot relies on donations to keep the servers, services and APIs running. + Donating will give you access to some exclusive features. You can read about them on the [patreon page](https://patreon.com/join/toastie_t0ast) + """) + .AddField("Donation Instructions", + $""" + 🗒️ Before pledging it is recommended to open your DMs as Ellie will send you a welcome message with instructions after you pledge has been processed and confirmed. + + **Step 1:** ❤️ Pledge on Patreon ❤️ + + `1.` Go to and choose a tier. + `2.` Make sure your payment is processed and accepted. + + **Step 2** 🤝 Connect your Discord account 🤝 + + `1.` Go to your profile settings on Patreon and connect your Discord account to it. + *please make sure you're logged into the correct Discord account* + + If you do not know how to do it, you may [follow instructions here](https://support.patreon.com/hc/en-us/articles/212052266-How-do-I-connect-Discord-to-Patreon-Patron-) + + **Step 3** ⏰ Wait a short while (usually 1-3 minutes) ⏰ + + Ellie will DM you the welcome instructions, and you will receive your rewards! + 🎉 **Enjoy!** 🎉 + """); + + try + { + await Response() + .Channel(await ctx.User.CreateDMChannelAsync()) + .Embed(eb) + .Interaction(selfhostInter) + .SendAsync(); + + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Help/HelpService.cs b/src/EllieBot/Modules/Help/HelpService.cs new file mode 100644 index 0000000..5904c03 --- /dev/null +++ b/src/EllieBot/Modules/Help/HelpService.cs @@ -0,0 +1,44 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Help.Services; + +public class HelpService : IExecNoCommand, IEService +{ + private readonly BotConfigService _bss; + private readonly IReplacementService _rs; + private readonly IMessageSenderService _sender; + + public HelpService(BotConfigService bss, IReplacementService repSvc, IMessageSenderService sender) + { + _bss = bss; + _rs = repSvc; + _sender = sender; + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + var settings = _bss.Data; + if (guild is null) + { + if (string.IsNullOrWhiteSpace(settings.DmHelpText) || settings.DmHelpText == "-") + return; + + // only send dm help text if it contains one of the keywords, if they're specified + // if they're not, then reply to every DM + if (settings.DmHelpTextKeywords is not null + && !settings.DmHelpTextKeywords.Any(k => msg.Content.Contains(k))) + { + return; + } + + var repCtx = new ReplacementContext(guild: guild, channel: msg.Channel, users: msg.Author) + .WithOverride("%prefix%", () => _bss.Data.Prefix) + .WithOverride("%bot.prefix%", () => _bss.Data.Prefix); + + var text = SmartText.CreateFrom(settings.DmHelpText); + text = await _rs.ReplaceAsync(text, repCtx); + + await _sender.Response(msg.Channel).Text(text).SendAsync(); + } + } +} \ No newline at end of file -- 2.43.0 From 243d754d10435ffb78ffcb03570888e8d918ab92 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:51:49 +1200 Subject: [PATCH 025/340] Added Music module --- .../Marmalade/IMarmaladesRepositoryService.cs | 6 + src/EllieBot/Modules/Marmalade/Marmalade.cs | 243 ++++++++++++++++++ .../Modules/Marmalade/MarmaladeItem.cs | 8 + .../Marmalade/MarmaladesRepositoryService.cs | 67 +++++ 4 files changed, 324 insertions(+) create mode 100644 src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs create mode 100644 src/EllieBot/Modules/Marmalade/Marmalade.cs create mode 100644 src/EllieBot/Modules/Marmalade/MarmaladeItem.cs create mode 100644 src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs diff --git a/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs b/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs new file mode 100644 index 0000000..155f56b --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/IMarmaladesRepositoryService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules; + +public interface IMarmaladesRepositoryService +{ + Task> GetModuleItemsAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/Marmalade.cs b/src/EllieBot/Modules/Marmalade/Marmalade.cs new file mode 100644 index 0000000..530b23f --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/Marmalade.cs @@ -0,0 +1,243 @@ +using Ellie.Common.Marmalade; + +namespace EllieBot.Modules; + +[OwnerOnly] +[NoPublicBot] +public partial class Marmalade : EllieModule +{ + private readonly IMarmaladesRepositoryService _repo; + + public Marmalade(IMarmaladesRepositoryService repo) + { + _repo = repo; + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeLoad(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var unloaded = _service.GetAllMarmalades() + .Where(x => !loaded.Contains(x)) + .Select(x => Format.Code(x.ToString())) + .ToArray(); + + if (unloaded.Length == 0) + { + await Response().Pending(strs.no_marmalade_available).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(unloaded) + .PageSize(10) + .Page((items, _) => + { + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.list_of_unloaded)) + .WithDescription(items.Join('\n')); + }) + .SendAsync(); + return; + } + + var res = await _service.LoadMarmaladeAsync(name); + if (res == MarmaladeLoadResult.Success) + await Response().Confirm(strs.marmalade_loaded(Format.Code(name))).SendAsync(); + else + { + var locStr = res switch + { + MarmaladeLoadResult.Empty => strs.marmalade_empty, + MarmaladeLoadResult.AlreadyLoaded => strs.marmalade_already_loaded(Format.Code(name)), + MarmaladeLoadResult.NotFound => strs.marmalade_invalid_not_found, + MarmaladeLoadResult.UnknownError => strs.error_occured, + _ => strs.error_occured + }; + + await Response().Error(locStr).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeUnload(string? name = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + var loaded = _service.GetLoadedMarmalades(); + if (loaded.Count == 0) + { + await Response().Pending(strs.no_marmalade_loaded).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.loaded_marmalades)) + .WithDescription(loaded.Select(x => x.Name) + .Join("\n"))) + .SendAsync(); + + return; + } + + var res = await _service.UnloadMarmaladeAsync(name); + if (res == MarmaladeUnloadResult.Success) + await Response().Confirm(strs.marmalade_unloaded(Format.Code(name))).SendAsync(); + else + { + var locStr = res switch + { + MarmaladeUnloadResult.NotLoaded => strs.marmalade_not_loaded, + MarmaladeUnloadResult.PossiblyUnable => strs.marmalade_possibly_cant_unload, + _ => strs.error_occured + }; + + await Response().Error(locStr).SendAsync(); + } + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeList() + { + var all = _service.GetAllMarmalades(); + + if (all.Count == 0) + { + await Response().Pending(strs.no_marmalade_available).SendAsync(); + return; + } + + var loaded = _service.GetLoadedMarmalades() + .Select(x => x.Name) + .ToHashSet(); + + var output = all + .Select(m => + { + var emoji = loaded.Contains(m) ? "`✅`" : "`🔴`"; + return $"{emoji} `{m}`"; + }) + .ToArray(); + + + await Response() + .Paginated() + .Items(output) + .PageSize(10) + .Page((items, _) => _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithDescription(items.Join('\n'))) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeInfo(string? name = null) + { + var marmalades = _service.GetLoadedMarmalades(); + + if (name is not null) + { + var found = marmalades.FirstOrDefault(x => string.Equals(x.Name, + name, + StringComparison.InvariantCultureIgnoreCase)); + + if (found is null) + { + await Response().Error(strs.marmalade_name_not_found).SendAsync(); + return; + } + + var cmdCount = found.Canaries.Sum(x => x.Commands.Count); + var cmdNames = found.Canaries + .SelectMany(x => Format.Code(string.IsNullOrWhiteSpace(x.Prefix) + ? x.Name + : $"{x.Prefix} {x.Name}")) + .Join("\n"); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.marmalade_info)) + .WithTitle(found.Name) + .WithDescription(found.Description) + .AddField(GetText(strs.canaries_count(found.Canaries.Count)), + found.Canaries.Count == 0 + ? "-" + : found.Canaries.Select(x => x.Name).Join('\n'), + true) + .AddField(GetText(strs.commands_count(cmdCount)), + string.IsNullOrWhiteSpace(cmdNames) + ? "-" + : cmdNames, + true); + + await Response().Embed(eb).SendAsync(); + return; + } + + if (marmalades.Count == 0) + { + await Response().Pending(strs.no_marmalade_loaded).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(marmalades) + .PageSize(9) + .CurrentPage(0) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithOkColor(); + + foreach (var marmalade in items) + { + eb.AddField(marmalade.Name, + $""" + `Canaries:` {marmalade.Canaries.Count} + `Commands:` {marmalade.Canaries.Sum(x => x.Commands.Count)} + -- + {marmalade.Description} + """); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task MarmaladeSearch() + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.list_of_marmalades)) + .WithOkColor(); + + foreach (var item in await _repo.GetModuleItemsAsync()) + { + eb.AddField(item.Name, + $""" + {item.Description} + `{item.Command}` + """, + true); + } + + await Response().Embed(eb).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs b/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs new file mode 100644 index 0000000..54c2889 --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/MarmaladeItem.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules; + +public sealed class ModuleItem +{ + public required string Name { get; init; } + public required string Description { get; init; } + public required string Command { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs b/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs new file mode 100644 index 0000000..6cc12b5 --- /dev/null +++ b/src/EllieBot/Modules/Marmalade/MarmaladesRepositoryService.cs @@ -0,0 +1,67 @@ +namespace EllieBot.Modules; + +public class MarmaladesRepositoryService : IMarmaladesRepositoryService, IEService +{ + public async Task> GetModuleItemsAsync() + { + // Simulate retrieving data from a database or API + await Task.Delay(100); + return + [ + new() + { + Name = "RSS Reader", + Description = "Keep up to date with your favorite websites", + Command = ".mainstall rss" + }, + new() + { + Name = "Password Manager", + Description = "Safely store and manage all your passwords", + Command = ".mainstall passwordmanager" + }, + new() + { + Name = "Browser Extension", + Description = "Enhance your browsing experience with useful tools", + Command = ".mainstall browserextension" + }, + new() + { + Name = "Video Downloader", + Description = "Download videos from popular websites", + Command = ".mainstall videodownloader" + }, + new() + { + Name = "Virtual Private Network", + Description = "Securely browse the web and protect your privacy", + Command = ".mainstall vpn" + }, + new() + { + Name = "Ad Blocker", + Description = "Block annoying ads and improve page load times", + Command = ".mainstall adblocker" + }, + new() + { + Name = "Cloud Storage", + Description = "Store and share your files online", + Command = ".mainstall cloudstorage" + }, + new() + { + Name = "Social Media Manager", + Description = "Manage all your social media accounts in one place", + Command = ".mainstall socialmediamanager" + }, + new() + { + Name = "Code Editor", + Description = "Write and edit code online", + Command = ".mainstall codeeditor" + } + ]; + } +} \ No newline at end of file -- 2.43.0 From 1f6858e1f3ea65ddc43806d8646b2f058dca8243 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:53:04 +1200 Subject: [PATCH 026/340] Acutally added Music module The last commit contained the Marmalade module -_- --- src/EllieBot/Modules/Music/Music.cs | 755 ++++++++++++++++++ .../Modules/Music/PlaylistCommands.cs | 246 ++++++ .../Music/Services/AyuVoiceStateService.cs | 217 +++++ .../Modules/Music/Services/IMusicService.cs | 36 + .../Modules/Music/Services/MusicService.cs | 437 ++++++++++ .../Modules/Music/Services/extractor/Misc.cs | 74 ++ .../Music/Services/extractor/YtLoader.cs | 130 +++ .../Music/_common/ICachableTrackData.cs | 12 + .../Music/_common/ILocalTrackResolver.cs | 7 + .../Modules/Music/_common/IMusicPlayer.cs | 41 + .../Modules/Music/_common/IMusicQueue.cs | 23 + .../Music/_common/IPlatformQueryResolver.cs | 6 + .../Modules/Music/_common/IQueuedTrackInfo.cs | 9 + .../Modules/Music/_common/IRadioResolver.cs | 6 + .../Modules/Music/_common/ITrackCacher.cs | 25 + .../Modules/Music/_common/ITrackInfo.cs | 12 + .../Music/_common/ITrackResolveProvider.cs | 6 + .../Modules/Music/_common/IVoiceProxy.cs | 15 + .../Modules/Music/_common/IYoutubeResolver.cs | 11 + .../Music/_common/Impl/CachableTrackData.cs | 19 + .../Music/_common/Impl/MultimediaTimer.cs | 95 +++ .../Music/_common/Impl/MusicExtensions.cs | 57 ++ .../Music/_common/Impl/MusicPlatform.cs | 9 + .../Modules/Music/_common/Impl/MusicPlayer.cs | 531 ++++++++++++ .../Modules/Music/_common/Impl/MusicQueue.cs | 345 ++++++++ .../Music/_common/Impl/RemoteTrackInfo.cs | 16 + .../Music/_common/Impl/SimpleTrackInfo.cs | 30 + .../Modules/Music/_common/Impl/TrackCacher.cs | 105 +++ .../Modules/Music/_common/Impl/VoiceProxy.cs | 102 +++ .../_common/Resolvers/LocalTrackResolver.cs | 122 +++ .../_common/Resolvers/RadioResolveStrategy.cs | 106 +++ .../_common/Resolvers/TrackResolveProvider.cs | 49 ++ .../_common/Resolvers/YtdlYoutubeResolver.cs | 315 ++++++++ .../db/MusicPlayerSettingsExtensions.cs | 27 + .../Modules/Music/_common/db/MusicPlaylist.cs | 10 + .../_common/db/MusicPlaylistExtensions.cs | 18 + .../Modules/Music/_common/db/MusicSettings.cs | 61 ++ 37 files changed, 4085 insertions(+) create mode 100644 src/EllieBot/Modules/Music/Music.cs create mode 100644 src/EllieBot/Modules/Music/PlaylistCommands.cs create mode 100644 src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs create mode 100644 src/EllieBot/Modules/Music/Services/IMusicService.cs create mode 100644 src/EllieBot/Modules/Music/Services/MusicService.cs create mode 100644 src/EllieBot/Modules/Music/Services/extractor/Misc.cs create mode 100644 src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs create mode 100644 src/EllieBot/Modules/Music/_common/ICachableTrackData.cs create mode 100644 src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/IMusicPlayer.cs create mode 100644 src/EllieBot/Modules/Music/_common/IMusicQueue.cs create mode 100644 src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs create mode 100644 src/EllieBot/Modules/Music/_common/IRadioResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/ITrackCacher.cs create mode 100644 src/EllieBot/Modules/Music/_common/ITrackInfo.cs create mode 100644 src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs create mode 100644 src/EllieBot/Modules/Music/_common/IVoiceProxy.cs create mode 100644 src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs create mode 100644 src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs create mode 100644 src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs create mode 100644 src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs create mode 100644 src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs create mode 100644 src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs create mode 100644 src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs create mode 100644 src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs create mode 100644 src/EllieBot/Modules/Music/_common/db/MusicSettings.cs diff --git a/src/EllieBot/Modules/Music/Music.cs b/src/EllieBot/Modules/Music/Music.cs new file mode 100644 index 0000000..3b1393c --- /dev/null +++ b/src/EllieBot/Modules/Music/Music.cs @@ -0,0 +1,755 @@ +#nullable disable +using EllieBot.Modules.Music.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +[NoPublicBot] +public sealed partial class Music : EllieModule +{ + public enum All { All = -1 } + + public enum InputRepeatType + { + N = 0, No = 0, None = 0, + T = 1, Track = 1, S = 1, Song = 1, + Q = 2, Queue = 2, Playlist = 2, Pl = 2 + } + + public const string MUSIC_ICON_URL = "https://i.imgur.com/nhKS3PT.png"; + + private const int LQ_ITEMS_PER_PAGE = 9; + + private static readonly SemaphoreSlim _voiceChannelLock = new(1, 1); + private readonly ILogCommandService _logService; + + public Music(ILogCommandService logService) + => _logService = logService; + + private async Task ValidateAsync() + { + var user = (IGuildUser)ctx.User; + var userVoiceChannelId = user.VoiceChannel?.Id; + + if (userVoiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return false; + } + + var currentUser = await ctx.Guild.GetCurrentUserAsync(); + if (currentUser.VoiceChannel?.Id != userVoiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return false; + } + + return true; + } + + private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null) + { + botUser ??= await ctx.Guild.GetCurrentUserAsync(); + await _voiceChannelLock.WaitAsync(); + try + { + if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _)) + await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId); + } + finally + { + _voiceChannelLock.Release(); + } + } + + private async Task QueuePreconditionInternalAsync() + { + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return false; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return false; + } + + return true; + } + + private async Task QueueByQuery(string query, bool asNext = false, MusicPlatform? forcePlatform = null) + { + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var (trackInfo, index) = await mp.TryEnqueueTrackAsync(query, ctx.User.ToString(), asNext, forcePlatform); + if (trackInfo is null) + { + await Response().Error(strs.track_not_found).SendAsync(); + return; + } + + try + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.queued_track) + " #" + (index + 1), MUSIC_ICON_URL) + .WithDescription($"{trackInfo.PrettyName()}\n{GetText(strs.queue)} ") + .WithFooter(trackInfo.Platform.ToString()); + + if (!string.IsNullOrWhiteSpace(trackInfo.Thumbnail)) + embed.WithThumbnailUrl(trackInfo.Thumbnail); + + var queuedMessage = await _service.SendToOutputAsync(ctx.Guild.Id, embed); + queuedMessage?.DeleteAfter(10, _logService); + if (mp.IsStopped) + { + var msg = await Response().Pending(strs.queue_stopped(Format.Code(prefix + "play"))).SendAsync(); + msg.DeleteAfter(10, _logService); + } + } + catch + { + // ignored + } + } + + private async Task MoveToIndex(int index) + { + if (--index < 0) + return; + + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.MoveTo(index); + } + + // join vc + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Join() + { + var user = (IGuildUser)ctx.User; + + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + await _service.JoinVoiceChannelAsync(user.GuildId, voiceChannelId.Value); + } + + // leave vc (destroy) + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Destroy() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.LeaveVoiceChannelAsync(ctx.Guild.Id); + } + + // play - no args = next + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(2)] + public Task Play() + => Next(); + + // play - index = skip to that index + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Play(int index) + => MoveToIndex(index); + + // play - query = q(query) + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task Play([Leftover] string query) + => QueueByQuery(query); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Queue([Leftover] string query) + => QueueByQuery(query); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task QueueNext([Leftover] string query) + => QueueByQuery(query, true); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Volume(int vol) + { + if (vol is < 0 or > 100) + { + await Response().Error(strs.volume_input_invalid).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.SetVolumeAsync(ctx.Guild.Id, vol); + await Response().Confirm(strs.volume_set(vol)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Next() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + var success = await _service.PlayAsync(ctx.Guild.Id, ((IGuildUser)ctx.User).VoiceChannel.Id); + if (!success) + await Response().Error(strs.no_player).SendAsync(); + } + + // list queue, relevant page + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListQueue() + { + // show page with the current track + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + await ListQueue((mp.CurrentIndex / LQ_ITEMS_PER_PAGE) + 1); + } + + // list queue, specify page + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListQueue(int page) + { + if (--page < 0) + return; + + IReadOnlyCollection tracks; + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || (tracks = mp.GetQueuedTracks()).Count == 0) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + EmbedBuilder PrintAction(IReadOnlyList tracks, int curPage) + { + var desc = string.Empty; + var current = mp.GetCurrentTrack(out var currentIndex); + if (current is not null) + desc = $"`🔊` {current.PrettyFullName()}\n\n" + desc; + + var repeatType = mp.Repeat; + var add = string.Empty; + if (mp.IsStopped) + add += Format.Bold(GetText(strs.queue_stopped(Format.Code(prefix + "play")))) + "\n"; + // var mps = mp.MaxPlaytimeSeconds; + // if (mps > 0) + // add += Format.Bold(GetText(strs.song_skips_after(TimeSpan.FromSeconds(mps).ToString("HH\\:mm\\:ss")))) + "\n"; + if (repeatType == PlayerRepeatType.Track) + add += "🔂 " + GetText(strs.repeating_track) + "\n"; + else + { + if (mp.AutoPlay) + add += "↪ " + GetText(strs.autoplaying) + "\n"; + // if (mp.FairPlay && !mp.Autoplay) + // add += " " + GetText(strs.fairplay) + "\n"; + if (repeatType == PlayerRepeatType.Queue) + add += "🔁 " + GetText(strs.repeating_queue) + "\n"; + } + + + desc += tracks + .Select((v, index) => + { + index += LQ_ITEMS_PER_PAGE * curPage; + if (index == currentIndex) + return $"**⇒**`{index + 1}.` {v.PrettyFullName()}"; + + return $"`{index + 1}.` {v.PrettyFullName()}"; + }) + .Join('\n'); + + if (!string.IsNullOrWhiteSpace(add)) + desc = add + "\n" + desc; + + var embed = _sender.CreateEmbed() + .WithAuthor( + GetText(strs.player_queue(curPage + 1, (tracks.Count / LQ_ITEMS_PER_PAGE) + 1)), + MUSIC_ICON_URL) + .WithDescription(desc) + .WithFooter( + $" {mp.PrettyVolume()} | 🎶 {tracks.Count} | ⌛ {mp.PrettyTotalTime()} ") + .WithOkColor(); + + return embed; + } + + await Response() + .Paginated() + .Items(tracks) + .PageSize(LQ_ITEMS_PER_PAGE) + .CurrentPage(page) + .AddFooter(false) + .Page(PrintAction) + .SendAsync(); + } + + // search + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueSearch([Leftover] string query) + { + _ = ctx.Channel.TriggerTypingAsync(); + + var videos = await _service.SearchVideosAsync(query); + + if (videos.Count == 0) + { + await Response().Error(strs.track_not_found).SendAsync(); + return; + } + + + var embeds = videos.Select((x, i) => _sender.CreateEmbed() + .WithOkColor() + .WithThumbnailUrl(x.Thumbnail) + .WithDescription($"`{i + 1}.` {Format.Bold(x.Title)}\n\t{x.Url}")) + .ToList(); + + var msg = await Response() + .Text(strs.queue_search_results) + .Embeds(embeds) + .SendAsync(); + + try + { + var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id, str => int.TryParse(str, out _)); + if (input is null || !int.TryParse(input, out var index) || (index -= 1) < 0 || index >= videos.Count) + { + _logService.AddDeleteIgnore(msg.Id); + try + { + await msg.DeleteAsync(); + } + catch + { + } + + return; + } + + query = videos[index].Url; + + await Play(query); + } + finally + { + _logService.AddDeleteIgnore(msg.Id); + try + { + await msg.DeleteAsync(); + } + catch + { + } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public async Task TrackRemove(int index) + { + if (index < 1) + { + await Response().Error(strs.removed_track_error).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + if (!mp.TryRemoveTrackAt(index - 1, out var track)) + { + await Response().Error(strs.removed_track_error).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithAuthor(GetText(strs.removed_track) + " #" + index, MUSIC_ICON_URL) + .WithDescription(track.PrettyName()) + .WithFooter(track.PrettyInfo()) + .WithErrorColor(); + + await _service.SendToOutputAsync(ctx.Guild.Id, embed); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task TrackRemove(All _ = All.All) + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.Clear(); + await Response().Confirm(strs.queue_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Stop() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.Stop(); + } + + private PlayerRepeatType InputToDbType(InputRepeatType type) + => type switch + { + InputRepeatType.None => PlayerRepeatType.None, + InputRepeatType.Queue => PlayerRepeatType.Queue, + InputRepeatType.Track => PlayerRepeatType.Track, + _ => PlayerRepeatType.Queue + }; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueRepeat(InputRepeatType type = InputRepeatType.Queue) + { + var valid = await ValidateAsync(); + if (!valid) + return; + + await _service.SetRepeatAsync(ctx.Guild.Id, InputToDbType(type)); + + if (type == InputRepeatType.None) + await Response().Confirm(strs.repeating_none).SendAsync(); + else if (type == InputRepeatType.Queue) + await Response().Confirm(strs.repeating_queue).SendAsync(); + else + await Response().Confirm(strs.repeating_track).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Pause() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp) || mp.GetCurrentTrack(out _) is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.TogglePause(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Radio(string radioLink) + => QueueByQuery(radioLink, false, MusicPlatform.Radio); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public Task Local([Leftover] string path) + => QueueByQuery(path, false, MusicPlatform.Local); + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task LocalPlaylist([Leftover] string dirPath) + { + if (string.IsNullOrWhiteSpace(dirPath)) + return; + + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return; + } + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + await _service.EnqueueDirectoryAsync(mp, dirPath, ctx.User.ToString()); + + await Response().Confirm(strs.dir_queue_complete).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task TrackMove(int from, int to) + { + if (--from < 0 || --to < 0 || from == to) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + var valid = await ValidateAsync(); + if (!valid) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var track = mp.MoveTrack(from, to); + if (track is null) + { + await Response().Error(strs.invalid_input).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithTitle(track.Title.TrimTo(65)) + .WithAuthor(GetText(strs.track_moved), MUSIC_ICON_URL) + .AddField(GetText(strs.from_position), $"#{from + 1}", true) + .AddField(GetText(strs.to_position), $"#{to + 1}", true) + .WithOkColor(); + + if (Uri.IsWellFormedUriString(track.Url, UriKind.Absolute)) + embed.WithUrl(track.Url); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Playlist([Leftover] string playlistQuery) + { + if (string.IsNullOrWhiteSpace(playlistQuery)) + return; + + var succ = await QueuePreconditionInternalAsync(); + if (!succ) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + + var queuedCount = await _service.EnqueueYoutubePlaylistAsync(mp, playlistQuery, ctx.User.ToString()); + if (queuedCount == 0) + { + await Response().Error(strs.no_search_results).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task NowPlaying() + { + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var currentTrack = mp.GetCurrentTrack(out _); + if (currentTrack is null) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(strs.now_playing), MUSIC_ICON_URL) + .WithDescription(currentTrack.PrettyName()) + .WithThumbnailUrl(currentTrack.Thumbnail) + .WithFooter( + $"{mp.PrettyVolume()} | {mp.PrettyTotalTime()} | {currentTrack.Platform} | {currentTrack.Queuer}"); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task PlaylistShuffle() + { + var valid = await ValidateAsync(); + if (!valid) + return; + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + mp.ShuffleQueue(); + await Response().Confirm(strs.queue_shuffled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task SetMusicChannel() + { + await _service.SetMusicChannelAsync(ctx.Guild.Id, ctx.Channel.Id); + + await Response().Confirm(strs.set_music_channel).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task UnsetMusicChannel() + { + await _service.SetMusicChannelAsync(ctx.Guild.Id, null); + + await Response().Confirm(strs.unset_music_channel).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoDisconnect() + { + var newState = await _service.ToggleAutoDisconnectAsync(ctx.Guild.Id); + + if (newState) + await Response().Confirm(strs.autodc_enable).SendAsync(); + else + await Response().Confirm(strs.autodc_disable).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task MusicQuality() + { + var quality = await _service.GetMusicQualityAsync(ctx.Guild.Id); + await Response().Confirm(strs.current_music_quality(Format.Bold(quality.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task MusicQuality(QualityPreset preset) + { + await _service.SetMusicQualityAsync(ctx.Guild.Id, preset); + await Response().Confirm(strs.music_quality_set(Format.Bold(preset.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueAutoPlay() + { + var newValue = await _service.ToggleQueueAutoPlayAsync(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.music_autoplay_on).SendAsync(); + else + await Response().Confirm(strs.music_autoplay_off).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QueueFairplay() + { + var newValue = await _service.FairplayAsync(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.music_fairplay).SendAsync(); + else + await Response().Error(strs.no_player).SendAsync(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/PlaylistCommands.cs b/src/EllieBot/Modules/Music/PlaylistCommands.cs new file mode 100644 index 0000000..259bb9e --- /dev/null +++ b/src/EllieBot/Modules/Music/PlaylistCommands.cs @@ -0,0 +1,246 @@ +#nullable disable +using LinqToDB; +using EllieBot.Db; +using EllieBot.Modules.Music.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +public sealed partial class Music +{ + [Group] + public sealed partial class PlaylistCommands : EllieModule + { + private static readonly SemaphoreSlim _playlistLock = new(1, 1); + private readonly DbService _db; + private readonly IBotCredentials _creds; + + public PlaylistCommands(DbService db, IBotCredentials creds) + { + _db = db; + _creds = creds; + } + + private async Task EnsureBotInVoiceChannelAsync(ulong voiceChannelId, IGuildUser botUser = null) + { + botUser ??= await ctx.Guild.GetCurrentUserAsync(); + await _voiceChannelLock.WaitAsync(); + try + { + if (botUser.VoiceChannel?.Id is null || !_service.TryGetMusicPlayer(ctx.Guild.Id, out _)) + await _service.JoinVoiceChannelAsync(ctx.Guild.Id, voiceChannelId); + } + finally + { + _voiceChannelLock.Release(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Playlists([Leftover] int num = 1) + { + if (num <= 0) + return; + + List playlists; + + await using (var uow = _db.GetDbContext()) + { + playlists = uow.Set().GetPlaylistsOnPage(num); + } + + var embed = _sender.CreateEmbed() + .WithAuthor(GetText(strs.playlists_page(num)), MUSIC_ICON_URL) + .WithDescription(string.Join("\n", + playlists.Select(r => GetText(strs.playlists(r.Id, r.Name, r.Author, r.Songs.Count))))) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task DeletePlaylist([Leftover] int id) + { + var success = false; + try + { + await using var uow = _db.GetDbContext(); + var pl = uow.Set().FirstOrDefault(x => x.Id == id); + + if (pl is not null) + { + if (_creds.IsOwner(ctx.User) || pl.AuthorId == ctx.User.Id) + { + uow.Set().Remove(pl); + await uow.SaveChangesAsync(); + success = true; + } + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error deleting playlist"); + } + + if (!success) + await Response().Error(strs.playlist_delete_fail).SendAsync(); + else + await Response().Confirm(strs.playlist_deleted).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task PlaylistShow(int id, int page = 1) + { + if (page-- < 1) + return; + + MusicPlaylist mpl; + await using (var uow = _db.GetDbContext()) + { + mpl = uow.Set().GetWithSongs(id); + } + + await Response() + .Paginated() + .Items(mpl.Songs) + .PageSize(20) + .CurrentPage(page) + .Page((items, _) => + { + var i = 0; + var str = string.Join("\n", + items + .Select(x => $"`{++i}.` [{x.Title.TrimTo(45)}]({x.Query}) `{x.Provider}`")); + return _sender.CreateEmbed().WithTitle($"\"{mpl.Name}\" by {mpl.Author}") + .WithOkColor() + .WithDescription(str); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Save([Leftover] string name) + { + if (!_service.TryGetMusicPlayer(ctx.Guild.Id, out var mp)) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + var songs = mp.GetQueuedTracks() + .Select(s => new PlaylistSong + { + Provider = s.Platform.ToString(), + ProviderType = (MusicType)s.Platform, + Title = s.Title, + Query = s.Platform == MusicPlatform.Local ? s.GetStreamUrl().Result!.Trim('"') : s.Url + }) + .ToList(); + + MusicPlaylist playlist; + await using (var uow = _db.GetDbContext()) + { + playlist = new() + { + Name = name, + Author = ctx.User.Username, + AuthorId = ctx.User.Id, + Songs = songs.ToList() + }; + uow.Set().Add(playlist); + await uow.SaveChangesAsync(); + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.playlist_saved)) + .AddField(GetText(strs.name), name) + .AddField(GetText(strs.id), playlist.Id.ToString())) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Load([Leftover] int id) + { + // expensive action, 1 at a time + await _playlistLock.WaitAsync(); + try + { + var user = (IGuildUser)ctx.User; + var voiceChannelId = user.VoiceChannel?.Id; + + if (voiceChannelId is null) + { + await Response().Error(strs.must_be_in_voice).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var botUser = await ctx.Guild.GetCurrentUserAsync(); + await EnsureBotInVoiceChannelAsync(voiceChannelId!.Value, botUser); + + if (botUser.VoiceChannel?.Id != voiceChannelId) + { + await Response().Error(strs.not_with_bot_in_voice).SendAsync(); + return; + } + + var mp = await _service.GetOrCreateMusicPlayerAsync((ITextChannel)ctx.Channel); + if (mp is null) + { + await Response().Error(strs.no_player).SendAsync(); + return; + } + + MusicPlaylist mpl; + await using (var uow = _db.GetDbContext()) + { + mpl = uow.Set().GetWithSongs(id); + } + + if (mpl is null) + { + await Response().Error(strs.playlist_id_not_found).SendAsync(); + return; + } + + IUserMessage msg = null; + try + { + msg = await Response() + .Pending(strs.attempting_to_queue(Format.Bold(mpl.Songs.Count.ToString()))) + .SendAsync(); + } + catch (Exception) + { + } + + await mp.EnqueueManyAsync(mpl.Songs.Select(x => (x.Query, (MusicPlatform)x.ProviderType)), + ctx.User.ToString()); + + if (msg is not null) + await msg.ModifyAsync(m => m.Content = GetText(strs.playlist_queue_complete)); + } + finally + { + _playlistLock.Release(); + } + } + + [Cmd] + [OwnerOnly] + public async Task DeletePlaylists() + { + await using var uow = _db.GetDbContext(); + await uow.Set().DeleteAsync(); + await uow.SaveChangesAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs new file mode 100644 index 0000000..ff47354 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/AyuVoiceStateService.cs @@ -0,0 +1,217 @@ +#nullable disable +using EllieBot.Voice; +using System.Reflection; + +namespace EllieBot.Modules.Music.Services; + +public sealed class AyuVoiceStateService : IEService +{ + // public delegate Task VoiceProxyUpdatedDelegate(ulong guildId, IVoiceProxy proxy); + // public event VoiceProxyUpdatedDelegate OnVoiceProxyUpdate = delegate { return Task.CompletedTask; }; + + private readonly ConcurrentDictionary _voiceProxies = new(); + private readonly ConcurrentDictionary _voiceGatewayLocks = new(); + + private readonly DiscordSocketClient _client; + private readonly MethodInfo _sendVoiceStateUpdateMethodInfo; + private readonly object _dnetApiClient; + private readonly ulong _currentUserId; + + public AyuVoiceStateService(DiscordSocketClient client) + { + _client = client; + _currentUserId = _client.CurrentUser.Id; + + var prop = _client.GetType() + .GetProperties(BindingFlags.NonPublic | BindingFlags.Instance) + .First(x => x.Name == "ApiClient" && x.PropertyType.Name == "DiscordSocketApiClient"); + _dnetApiClient = prop.GetValue(_client, null); + _sendVoiceStateUpdateMethodInfo = _dnetApiClient.GetType() + .GetMethod("SendVoiceStateUpdateAsync", + [ + typeof(ulong), typeof(ulong?), typeof(bool), + typeof(bool), typeof(RequestOptions) + ]); + + _client.LeftGuild += ClientOnLeftGuild; + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + if (_voiceProxies.TryRemove(guild.Id, out var proxy)) + { + proxy.StopGateway(); + proxy.SetGateway(null); + } + + return Task.CompletedTask; + } + + private Task InvokeSendVoiceStateUpdateAsync( + ulong guildId, + ulong? channelId = null, + bool isDeafened = false, + bool isMuted = false) + // return _voiceStateUpdate(guildId, channelId, isDeafened, isMuted); + => (Task)_sendVoiceStateUpdateMethodInfo.Invoke(_dnetApiClient, + [guildId, channelId, isMuted, isDeafened, null]); + + private Task SendLeaveVoiceChannelInternalAsync(ulong guildId) + => InvokeSendVoiceStateUpdateAsync(guildId); + + private Task SendJoinVoiceChannelInternalAsync(ulong guildId, ulong channelId) + => InvokeSendVoiceStateUpdateAsync(guildId, channelId); + + private SemaphoreSlim GetVoiceGatewayLock(ulong guildId) + => _voiceGatewayLocks.GetOrAdd(guildId, new SemaphoreSlim(1, 1)); + + private async Task LeaveVoiceChannelInternalAsync(ulong guildId) + { + var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState) + { + if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId && newState.VoiceChannel?.Id is null) + complete.TrySetResult(true); + + return Task.CompletedTask; + } + + try + { + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + + if (_voiceProxies.TryGetValue(guildId, out var proxy)) + { + _ = proxy.StopGateway(); + proxy.SetGateway(null); + } + + await SendLeaveVoiceChannelInternalAsync(guildId); + await Task.WhenAny(Task.Delay(1500), complete.Task); + } + finally + { + _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated; + } + } + + public async Task LeaveVoiceChannel(ulong guildId) + { + var gwLock = GetVoiceGatewayLock(guildId); + await gwLock.WaitAsync(); + try + { + await LeaveVoiceChannelInternalAsync(guildId); + } + finally + { + gwLock.Release(); + } + } + + private async Task InternalConnectToVcAsync(ulong guildId, ulong channelId) + { + var voiceStateUpdatedSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var voiceServerUpdatedSource = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState oldState, SocketVoiceState newState) + { + if (user is SocketGuildUser guildUser && guildUser.Guild.Id == guildId) + { + if (newState.VoiceChannel?.Id == channelId) + voiceStateUpdatedSource.TrySetResult(newState.VoiceSessionId); + + voiceStateUpdatedSource.TrySetResult(null); + } + + return Task.CompletedTask; + } + + Task OnVoiceServerUpdated(SocketVoiceServer data) + { + if (data.Guild.Id == guildId) + voiceServerUpdatedSource.TrySetResult(data); + + return Task.CompletedTask; + } + + try + { + _client.VoiceServerUpdated += OnVoiceServerUpdated; + _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated; + + await SendJoinVoiceChannelInternalAsync(guildId, channelId); + + // create a delay task, how much to wait for gateway response + using var cts = new CancellationTokenSource(); + var delayTask = Task.Delay(2500, cts.Token); + + // either delay or successful voiceStateUpdate + var maybeUpdateTask = Task.WhenAny(delayTask, voiceStateUpdatedSource.Task); + // either delay or successful voiceServerUpdate + var maybeServerTask = Task.WhenAny(delayTask, voiceServerUpdatedSource.Task); + + // wait for both to end (max 1s) and check if either of them is a delay task + var results = await Task.WhenAll(maybeUpdateTask, maybeServerTask); + if (results[0] == delayTask || results[1] == delayTask) + // if either is delay, return null - connection unsuccessful + return null; + else + cts.Cancel(); + + // if both are succesful, that means we can safely get + // the values from completion sources + + var session = await voiceStateUpdatedSource.Task; + + // session can be null. Means we disconnected, or connected to the wrong channel (?!) + if (session is null) + return null; + + var voiceServerData = await voiceServerUpdatedSource.Task; + + VoiceGateway CreateVoiceGatewayLocal() + { + return new(guildId, _currentUserId, session, voiceServerData.Token, voiceServerData.Endpoint); + } + + var current = _voiceProxies.AddOrUpdate(guildId, + _ => new VoiceProxy(CreateVoiceGatewayLocal()), + (gid, currentProxy) => + { + _ = currentProxy.StopGateway(); + currentProxy.SetGateway(CreateVoiceGatewayLocal()); + return currentProxy; + }); + + _ = current.StartGateway(); // don't await, this blocks until gateway is closed + return current; + } + finally + { + _client.VoiceServerUpdated -= OnVoiceServerUpdated; + _client.UserVoiceStateUpdated -= OnUserVoiceStateUpdated; + } + } + + public async Task JoinVoiceChannel(ulong guildId, ulong channelId, bool forceReconnect = true) + { + var gwLock = GetVoiceGatewayLock(guildId); + await gwLock.WaitAsync(); + try + { + await LeaveVoiceChannelInternalAsync(guildId); + return await InternalConnectToVcAsync(guildId, channelId); + } + finally + { + gwLock.Release(); + } + } + + public bool TryGetProxy(ulong guildId, out IVoiceProxy proxy) + => _voiceProxies.TryGetValue(guildId, out proxy); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/IMusicService.cs b/src/EllieBot/Modules/Music/Services/IMusicService.cs new file mode 100644 index 0000000..43bad99 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/IMusicService.cs @@ -0,0 +1,36 @@ +using EllieBot.Db.Models; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Music.Services; + +public interface IMusicService +{ + /// + /// Leave voice channel in the specified guild if it's connected to one + /// + /// Id of the guild + public Task LeaveVoiceChannelAsync(ulong guildId); + + /// + /// Joins the voice channel with the specified id + /// + /// Id of the guild where the voice channel is + /// Id of the voice channel + public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId); + + Task GetOrCreateMusicPlayerAsync(ITextChannel contextChannel); + bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer); + Task EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string playlistId, string queuer); + Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer); + Task SendToOutputAsync(ulong guildId, EmbedBuilder embed); + Task PlayAsync(ulong guildId, ulong voiceChannelId); + Task> SearchVideosAsync(string query); + Task SetMusicChannelAsync(ulong guildId, ulong? channelId); + Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType); + Task SetVolumeAsync(ulong guildId, int value); + Task ToggleAutoDisconnectAsync(ulong guildId); + Task GetMusicQualityAsync(ulong guildId); + Task SetMusicQualityAsync(ulong guildId, QualityPreset preset); + Task ToggleQueueAutoPlayAsync(ulong guildId); + Task FairplayAsync(ulong guildId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/MusicService.cs b/src/EllieBot/Modules/Music/Services/MusicService.cs new file mode 100644 index 0000000..b2b3da3 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/MusicService.cs @@ -0,0 +1,437 @@ +using EllieBot.Db; +using EllieBot.Db.Models; +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Modules.Music.Services; + +public sealed class MusicService : IMusicService, IPlaceholderProvider +{ + private readonly AyuVoiceStateService _voiceStateService; + private readonly ITrackResolveProvider _trackResolveProvider; + private readonly DbService _db; + private readonly IYoutubeResolver _ytResolver; + private readonly ILocalTrackResolver _localResolver; + private readonly DiscordSocketClient _client; + private readonly IBotStrings _strings; + private readonly IGoogleApiService _googleApiService; + private readonly YtLoader _ytLoader; + private readonly IMessageSenderService _sender; + + private readonly ConcurrentDictionary _players; + private readonly ConcurrentDictionary _outputChannels; + private readonly ConcurrentDictionary _settings; + + public MusicService( + AyuVoiceStateService voiceStateService, + ITrackResolveProvider trackResolveProvider, + DbService db, + IYoutubeResolver ytResolver, + ILocalTrackResolver localResolver, + DiscordSocketClient client, + IBotStrings strings, + IGoogleApiService googleApiService, + YtLoader ytLoader, + IMessageSenderService sender) + { + _voiceStateService = voiceStateService; + _trackResolveProvider = trackResolveProvider; + _db = db; + _ytResolver = ytResolver; + _localResolver = localResolver; + _client = client; + _strings = strings; + _googleApiService = googleApiService; + _ytLoader = ytLoader; + _sender = sender; + + _players = new(); + _outputChannels = new ConcurrentDictionary(); + _settings = new(); + + _client.LeftGuild += ClientOnLeftGuild; + } + + private void DisposeMusicPlayer(IMusicPlayer musicPlayer) + { + musicPlayer.Kill(); + _ = Task.Delay(10_000).ContinueWith(_ => musicPlayer.Dispose()); + } + + private void RemoveMusicPlayer(ulong guildId) + { + _outputChannels.TryRemove(guildId, out _); + if (_players.TryRemove(guildId, out var mp)) + DisposeMusicPlayer(mp); + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + RemoveMusicPlayer(guild.Id); + return Task.CompletedTask; + } + + public async Task LeaveVoiceChannelAsync(ulong guildId) + { + RemoveMusicPlayer(guildId); + await _voiceStateService.LeaveVoiceChannel(guildId); + } + + public Task JoinVoiceChannelAsync(ulong guildId, ulong voiceChannelId) + => _voiceStateService.JoinVoiceChannel(guildId, voiceChannelId); + + public async Task GetOrCreateMusicPlayerAsync(ITextChannel contextChannel) + { + var newPLayer = await CreateMusicPlayerInternalAsync(contextChannel.GuildId, contextChannel); + if (newPLayer is null) + return null; + + return _players.GetOrAdd(contextChannel.GuildId, newPLayer); + } + + public bool TryGetMusicPlayer(ulong guildId, [MaybeNullWhen(false)] out IMusicPlayer musicPlayer) + => _players.TryGetValue(guildId, out musicPlayer); + + public async Task EnqueueYoutubePlaylistAsync(IMusicPlayer mp, string query, string queuer) + { + var count = 0; + await foreach (var track in _ytResolver.ResolveTracksFromPlaylistAsync(query)) + { + if (mp.IsKilled) + break; + + mp.EnqueueTrack(track, queuer); + ++count; + } + + return count; + } + + public async Task EnqueueDirectoryAsync(IMusicPlayer mp, string dirPath, string queuer) + { + await foreach (var track in _localResolver.ResolveDirectoryAsync(dirPath)) + { + if (mp.IsKilled) + break; + + mp.EnqueueTrack(track, queuer); + } + } + + private async Task CreateMusicPlayerInternalAsync(ulong guildId, ITextChannel defaultChannel) + { + var queue = new MusicQueue(); + var resolver = _trackResolveProvider; + + if (!_voiceStateService.TryGetProxy(guildId, out var proxy)) + return null; + + var settings = await GetSettingsInternalAsync(guildId); + + ITextChannel? overrideChannel = null; + if (settings.MusicChannelId is { } channelId) + { + overrideChannel = _client.GetGuild(guildId)?.GetTextChannel(channelId); + + if (overrideChannel is null) + Log.Warning("Saved music output channel doesn't exist, falling back to current channel"); + } + + _outputChannels[guildId] = (defaultChannel, overrideChannel); + + var mp = new MusicPlayer(queue, + resolver, + proxy, + _googleApiService, + settings.QualityPreset, + settings.AutoPlay); + + mp.SetRepeat(settings.PlayerRepeat); + + if (settings.Volume is >= 0 and <= 100) + mp.SetVolume(settings.Volume); + else + Log.Error("Saved Volume is outside of valid range >= 0 && <=100 ({Volume})", settings.Volume); + + mp.OnCompleted += OnTrackCompleted(guildId); + mp.OnStarted += OnTrackStarted(guildId); + mp.OnQueueStopped += OnQueueStopped(guildId); + + return mp; + } + + public async Task SendToOutputAsync(ulong guildId, EmbedBuilder embed) + { + if (_outputChannels.TryGetValue(guildId, out var chan)) + { + var msg = await _sender.Response(chan.Override ?? chan.Default) + .Embed(embed) + .SendAsync(); + return msg; + } + + return null; + } + + private Func OnTrackCompleted(ulong guildId) + { + IUserMessage? lastFinishedMessage = null; + return async (mp, trackInfo) => + { + _ = lastFinishedMessage?.DeleteAsync(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(guildId, strs.finished_track), Music.MUSIC_ICON_URL) + .WithDescription(trackInfo.PrettyName()) + .WithFooter(trackInfo.PrettyTotalTime()); + + lastFinishedMessage = await SendToOutputAsync(guildId, embed); + }; + } + + private Func OnTrackStarted(ulong guildId) + { + IUserMessage? lastPlayingMessage = null; + return async (mp, trackInfo, index) => + { + _ = lastPlayingMessage?.DeleteAsync(); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(GetText(guildId, strs.playing_track(index + 1)), Music.MUSIC_ICON_URL) + .WithDescription(trackInfo.PrettyName()) + .WithFooter($"{mp.PrettyVolume()} | {trackInfo.PrettyInfo()}"); + + lastPlayingMessage = await SendToOutputAsync(guildId, embed); + }; + } + + private Func OnQueueStopped(ulong guildId) + => _ => + { + if (_settings.TryGetValue(guildId, out var settings)) + { + if (settings.AutoDisconnect) + return LeaveVoiceChannelAsync(guildId); + } + + return Task.CompletedTask; + }; + + // this has to be done because dragging bot to another vc isn't supported yet + public async Task PlayAsync(ulong guildId, ulong voiceChannelId) + { + if (!TryGetMusicPlayer(guildId, out var mp)) + return false; + + if (mp.IsStopped) + { + if (!_voiceStateService.TryGetProxy(guildId, out var proxy) + || proxy.State == VoiceProxy.VoiceProxyState.Stopped) + await JoinVoiceChannelAsync(guildId, voiceChannelId); + } + + mp.Next(); + return true; + } + + private async Task> SearchYtLoaderVideosAsync(string query) + { + var result = await _ytLoader.LoadResultsAsync(query); + return result.Select(x => (x.Title, x.Url, x.Thumb)).ToList(); + } + + private async Task> SearchGoogleApiVideosAsync(string query) + { + var result = await _googleApiService.GetVideoInfosByKeywordAsync(query, 5); + return result.Select(x => (x.Name, x.Url, x.Thumbnail)).ToList(); + } + + public async Task> SearchVideosAsync(string query) + { + try + { + IList<(string, string, string)> videos = await SearchYtLoaderVideosAsync(query); + if (videos.Count > 0) + return videos; + } + catch (Exception ex) + { + Log.Warning("Failed geting videos with YtLoader: {ErrorMessage}", ex.Message); + } + + try + { + return await SearchGoogleApiVideosAsync(query); + } + catch (Exception ex) + { + Log.Warning("Failed getting video results with Google Api. " + + "Probably google api key missing: {ErrorMessage}", + ex.Message); + } + + return Array.Empty<(string, string, string)>(); + } + + private string GetText(ulong guildId, LocStr str) + => _strings.GetText(str, guildId); + + public IEnumerable<(string Name, Func Func)> GetPlaceholders() + { + // random track that's playing + yield return ("%music.playing%", () => + { + var randomPlayingTrack = _players.Select(x => x.Value.GetCurrentTrack(out _)) + .Where(x => x is not null) + .Shuffle() + .FirstOrDefault(); + + if (randomPlayingTrack is null) + return "-"; + + return randomPlayingTrack.Title; + }); + + // number of servers currently listening to music + yield return ("%music.servers%", () => + { + var count = _players.Select(x => x.Value.GetCurrentTrack(out _)).Count(x => x is not null); + + return count.ToString(); + }); + + yield return ("%music.queued%", () => + { + var count = _players.Sum(x => x.Value.GetQueuedTracks().Count); + + return count.ToString(); + }); + } + + #region Settings + + private async Task GetSettingsInternalAsync(ulong guildId) + { + if (_settings.TryGetValue(guildId, out var settings)) + return settings; + + await using var uow = _db.GetDbContext(); + var toReturn = _settings[guildId] = await uow.Set().ForGuildAsync(guildId); + await uow.SaveChangesAsync(); + + return toReturn; + } + + private async Task ModifySettingsInternalAsync( + ulong guildId, + Action action, + TState state) + { + await using var uow = _db.GetDbContext(); + var ms = await uow.Set().ForGuildAsync(guildId); + action(ms, state); + await uow.SaveChangesAsync(); + _settings[guildId] = ms; + } + + public async Task SetMusicChannelAsync(ulong guildId, ulong? channelId) + { + if (channelId is null) + { + await UnsetMusicChannelAsync(guildId); + return true; + } + + var channel = _client.GetGuild(guildId)?.GetTextChannel(channelId.Value); + if (channel is null) + return false; + + await ModifySettingsInternalAsync(guildId, + (settings, chId) => { settings.MusicChannelId = chId; }, + channelId); + + _outputChannels.AddOrUpdate(guildId, (channel, channel), (_, old) => (old.Default, channel)); + + return true; + } + + public async Task UnsetMusicChannelAsync(ulong guildId) + { + await ModifySettingsInternalAsync(guildId, + (settings, _) => { settings.MusicChannelId = null; }, + (ulong?)null); + + if (_outputChannels.TryGetValue(guildId, out var old)) + _outputChannels[guildId] = (old.Default, null); + } + + public async Task SetRepeatAsync(ulong guildId, PlayerRepeatType repeatType) + { + await ModifySettingsInternalAsync(guildId, + (settings, type) => { settings.PlayerRepeat = type; }, + repeatType); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.SetRepeat(repeatType); + } + + public async Task SetVolumeAsync(ulong guildId, int value) + { + if (value is < 0 or > 100) + throw new ArgumentOutOfRangeException(nameof(value)); + + await ModifySettingsInternalAsync(guildId, + (settings, newValue) => { settings.Volume = newValue; }, + value); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.SetVolume(value); + } + + public async Task ToggleAutoDisconnectAsync(ulong guildId) + { + var newState = false; + await ModifySettingsInternalAsync(guildId, + (settings, _) => { newState = settings.AutoDisconnect = !settings.AutoDisconnect; }, + default(object)); + + return newState; + } + + public async Task GetMusicQualityAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var settings = await uow.Set().ForGuildAsync(guildId); + return settings.QualityPreset; + } + + public Task SetMusicQualityAsync(ulong guildId, QualityPreset preset) + => ModifySettingsInternalAsync(guildId, + (settings, _) => { settings.QualityPreset = preset; }, + preset); + + public async Task ToggleQueueAutoPlayAsync(ulong guildId) + { + var newValue = false; + await ModifySettingsInternalAsync(guildId, + (settings, _) => newValue = settings.AutoPlay = !settings.AutoPlay, + false); + + if (TryGetMusicPlayer(guildId, out var mp)) + mp.AutoPlay = newValue; + + return newValue; + } + + public Task FairplayAsync(ulong guildId) + { + if (TryGetMusicPlayer(guildId, out var mp)) + { + mp.SetFairplay(); + return Task.FromResult(true); + } + + return Task.FromResult(false); + } + + #endregion +} diff --git a/src/EllieBot/Modules/Music/Services/extractor/Misc.cs b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs new file mode 100644 index 0000000..68c1fca --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/Misc.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Modules.Music.Services; + +public sealed partial class YtLoader +{ + public class InitRange + { + public string Start { get; set; } + public string End { get; set; } + } + + public class IndexRange + { + public string Start { get; set; } + public string End { get; set; } + } + + public class ColorInfo + { + public string Primaries { get; set; } + public string TransferCharacteristics { get; set; } + public string MatrixCoefficients { get; set; } + } + + public class YtAdaptiveFormat + { + public int Itag { get; set; } + public string MimeType { get; set; } + public int Bitrate { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public InitRange InitRange { get; set; } + public IndexRange IndexRange { get; set; } + public string LastModified { get; set; } + public string ContentLength { get; set; } + public string Quality { get; set; } + public int Fps { get; set; } + public string QualityLabel { get; set; } + public string ProjectionType { get; set; } + public int AverageBitrate { get; set; } + public ColorInfo ColorInfo { get; set; } + public string ApproxDurationMs { get; set; } + public string SignatureCipher { get; set; } + } + + public abstract class TrackInfo + { + public abstract string Url { get; } + public abstract string Title { get; } + public abstract string Thumb { get; } + public abstract TimeSpan Duration { get; } + } + + public sealed class YtTrackInfo : TrackInfo + { + private const string BASE_YOUTUBE_URL = "https://youtube.com/watch?v="; + public override string Url { get; } + public override string Title { get; } + public override string Thumb { get; } + public override TimeSpan Duration { get; } + + private readonly string _videoId; + + public YtTrackInfo(string title, string videoId, string thumb, TimeSpan duration) + { + Title = title; + Thumb = thumb; + Url = BASE_YOUTUBE_URL + videoId; + Duration = duration; + + _videoId = videoId; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs new file mode 100644 index 0000000..a91dd11 --- /dev/null +++ b/src/EllieBot/Modules/Music/Services/extractor/YtLoader.cs @@ -0,0 +1,130 @@ +#nullable disable +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace EllieBot.Modules.Music.Services; + +public sealed partial class YtLoader : IEService +{ + private static readonly byte[] _ytResultInitialData = Encoding.UTF8.GetBytes("var ytInitialData = "); + private static readonly byte[] _ytResultJsonEnd = Encoding.UTF8.GetBytes(";<"); + + private static readonly string[] _durationFormats = + [ + @"m\:ss", @"mm\:ss", @"h\:mm\:ss", @"hh\:mm\:ss", @"hhh\:mm\:ss" + ]; + + private readonly IHttpClientFactory _httpFactory; + + public YtLoader(IHttpClientFactory httpFactory) + => _httpFactory = httpFactory; + + // public async Task LoadTrackByIdAsync(string videoId) + // { + // using var http = new HttpClient(); + // http.DefaultRequestHeaders.Add("X-YouTube-Client-Name", "1"); + // http.DefaultRequestHeaders.Add("X-YouTube-Client-Version", "2.20210520.09.00"); + // http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;"); + // + // var responseString = await http.GetStringAsync($"https://youtube.com?" + + // $"pbj=1" + + // $"&hl=en" + + // $"&v=" + videoId); + // + // var jsonDoc = JsonDocument.Parse(responseString).RootElement; + // var elem = jsonDoc.EnumerateArray() + // .FirstOrDefault(x => x.TryGetProperty("page", out var elem) && elem.GetString() == "watch"); + // + // var formatsJsonArray = elem.GetProperty("streamingdata") + // .GetProperty("formats") + // .GetRawText(); + // + // var formats = JsonSerializer.Deserialize>(formatsJsonArray); + // var result = formats + // .Where(x => x.MimeType.StartsWith("audio/")) + // .OrderByDescending(x => x.Bitrate) + // .FirstOrDefault(); + // + // if (result is null) + // return null; + // + // return new YtTrackInfo("1", "2", TimeSpan.Zero); + // } + + public async Task> LoadResultsAsync(string query) + { + query = Uri.EscapeDataString(query); + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Add("Cookie", "CONSENT=YES+cb.20210530-19-p0.en+FX+071;"); + + byte[] response; + try + { + response = await http.GetByteArrayAsync($"https://youtube.com/results?hl=en&search_query={query}"); + } + catch (HttpRequestException ex) + { + Log.Warning("Unable to retrieve data with YtLoader: {ErrorMessage}", ex.Message); + return null; + } + + // there is a lot of useless html above the script tag, however if html gets significantly reduced + // this will result in the json being cut off + + var mem = GetScriptResponseSpan(response); + var root = JsonDocument.Parse(mem).RootElement; + + using var tracksJsonItems = root + .GetProperty("contents") + .GetProperty("twoColumnSearchResultsRenderer") + .GetProperty("primaryContents") + .GetProperty("sectionListRenderer") + .GetProperty("contents")[0] + .GetProperty("itemSectionRenderer") + .GetProperty("contents") + .EnumerateArray(); + + var tracks = new List(); + foreach (var track in tracksJsonItems) + { + if (!track.TryGetProperty("videoRenderer", out var elem)) + continue; + + var videoId = elem.GetProperty("videoId").GetString(); + var thumb = elem.GetProperty("thumbnail").GetProperty("thumbnails")[0].GetProperty("url").GetString(); + var title = elem.GetProperty("title").GetProperty("runs")[0].GetProperty("text").GetString(); + var durationString = elem.GetProperty("lengthText").GetProperty("simpleText").GetString(); + + if (!TimeSpan.TryParseExact(durationString, + _durationFormats, + CultureInfo.InvariantCulture, + out var duration)) + { + Log.Warning("Cannot parse duration: {DurationString}", durationString); + continue; + } + + tracks.Add(new YtTrackInfo(title, videoId, thumb, duration)); + if (tracks.Count >= 5) + break; + } + + return tracks; + } + + private Memory GetScriptResponseSpan(byte[] response) + { + var responseSpan = response.AsSpan()[140_000..]; + var startIndex = responseSpan.IndexOf(_ytResultInitialData); + if (startIndex == -1) + return null; // FUTURE try selecting html + startIndex += _ytResultInitialData.Length; + + var endIndex = + 140_000 + startIndex + responseSpan[(startIndex + 20_000)..].IndexOf(_ytResultJsonEnd) + 20_000; + startIndex += 140_000; + return response.AsMemory(startIndex, endIndex - startIndex); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs new file mode 100644 index 0000000..020e074 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ICachableTrackData.cs @@ -0,0 +1,12 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface ICachableTrackData +{ + string Id { get; set; } + string Url { get; set; } + string Thumbnail { get; set; } + public TimeSpan Duration { get; } + MusicPlatform Platform { get; set; } + string Title { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs new file mode 100644 index 0000000..f4ea2bf --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ILocalTrackResolver.cs @@ -0,0 +1,7 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface ILocalTrackResolver : IPlatformQueryResolver +{ + IAsyncEnumerable ResolveDirectoryAsync(string dirPath); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs new file mode 100644 index 0000000..a593a57 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicPlayer.cs @@ -0,0 +1,41 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Music; + +public interface IMusicPlayer : IDisposable +{ + float Volume { get; } + bool IsPaused { get; } + bool IsStopped { get; } + bool IsKilled { get; } + int CurrentIndex { get; } + public PlayerRepeatType Repeat { get; } + bool AutoPlay { get; set; } + + void Stop(); + void Clear(); + IReadOnlyCollection GetQueuedTracks(); + IQueuedTrackInfo? GetCurrentTrack(out int index); + void Next(); + bool MoveTo(int index); + void SetVolume(int newVolume); + + void Kill(); + bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo); + + + Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync( + string query, + string queuer, + bool asNext, + MusicPlatform? forcePlatform = null); + + Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer); + bool TogglePause(); + IQueuedTrackInfo? MoveTrack(int from, int to); + void EnqueueTrack(ITrackInfo track, string queuer); + void EnqueueTracks(IEnumerable tracks, string queuer); + void SetRepeat(PlayerRepeatType type); + void ShuffleQueue(); + void SetFairplay(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IMusicQueue.cs b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs new file mode 100644 index 0000000..5d4d24b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IMusicQueue.cs @@ -0,0 +1,23 @@ +namespace EllieBot.Modules.Music; + +public interface IMusicQueue +{ + int Index { get; } + int Count { get; } + IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int index); + IQueuedTrackInfo EnqueueNext(ITrackInfo song, string queuer, out int index); + + void EnqueueMany(IEnumerable tracks, string queuer); + + public IReadOnlyCollection List(); + IQueuedTrackInfo? GetCurrent(out int index); + void Advance(); + void Clear(); + bool SetIndex(int index); + bool TryRemoveAt(int index, out IQueuedTrackInfo? trackInfo, out bool isCurrent); + void RemoveCurrent(); + IQueuedTrackInfo? MoveTrack(int from, int to); + void Shuffle(Random rng); + bool IsLast(); + void ReorderFairly(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs new file mode 100644 index 0000000..fa282ed --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IPlatformQueryResolver.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface IPlatformQueryResolver +{ + Task ResolveByQueryAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs new file mode 100644 index 0000000..5093cd9 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IQueuedTrackInfo.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IQueuedTrackInfo : ITrackInfo +{ + public ITrackInfo TrackInfo { get; } + + public string Queuer { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IRadioResolver.cs b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs new file mode 100644 index 0000000..86a6ba5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IRadioResolver.cs @@ -0,0 +1,6 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public interface IRadioResolver : IPlatformQueryResolver +{ +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackCacher.cs b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs new file mode 100644 index 0000000..d55cd65 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackCacher.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackCacher +{ + Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> streamUrlFactory); + + Task CacheTrackDataAsync(ICachableTrackData data); + Task GetCachedDataByIdAsync(string id, MusicPlatform platform); + Task GetCachedDataByQueryAsync(string query, MusicPlatform platform); + Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data); + + Task CacheStreamUrlAsync( + string id, + MusicPlatform platform, + string url, + TimeSpan expiry); + + Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform); + Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids); + Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId); + Task GetPlaylistIdByQueryAsync(string query, MusicPlatform platform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackInfo.cs b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs new file mode 100644 index 0000000..347e8fa --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackInfo.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackInfo +{ + public string Id => string.Empty; + public string Title { get; } + public string Url { get; } + public string Thumbnail { get; } + public TimeSpan Duration { get; } + public MusicPlatform Platform { get; } + public ValueTask GetStreamUrl(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs new file mode 100644 index 0000000..ae3d1e6 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/ITrackResolveProvider.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Music; + +public interface ITrackResolveProvider +{ + Task QuerySongAsync(string query, MusicPlatform? forcePlatform); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs new file mode 100644 index 0000000..d88e51c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IVoiceProxy.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Voice; + +namespace EllieBot.Modules.Music; + +public interface IVoiceProxy +{ + VoiceProxy.VoiceProxyState State { get; } + public bool SendPcmFrame(VoiceClient vc, Span data, int length); + public void SetGateway(VoiceGateway gateway); + Task StartSpeakingAsync(); + Task StopSpeakingAsync(); + public Task StartGateway(); + Task StopGateway(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs new file mode 100644 index 0000000..433012d --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/IYoutubeResolver.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Music; + +public interface IYoutubeResolver : IPlatformQueryResolver +{ + public Regex YtVideoIdRegex { get; } + public Task ResolveByIdAsync(string id); + IAsyncEnumerable ResolveTracksFromPlaylistAsync(string query); + Task ResolveByQueryAsync(string query, bool tryExtractingId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs new file mode 100644 index 0000000..4e663ad --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/CachableTrackData.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Music; + +public sealed class CachableTrackData : ICachableTrackData +{ + public string Title { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + public string Thumbnail { get; set; } = string.Empty; + public double TotalDurationMs { get; set; } + + [JsonIgnore] + public TimeSpan Duration + => TimeSpan.FromMilliseconds(TotalDurationMs); + + public MusicPlatform Platform { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs new file mode 100644 index 0000000..9c8a9a3 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MultimediaTimer.cs @@ -0,0 +1,95 @@ +#nullable disable +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Music.Common; + +public sealed class MultimediaTimer : IDisposable +{ + private LpTimeProcDelegate lpTimeProc; + private readonly uint _eventId; + private readonly Action _callback; + private readonly object _state; + + public MultimediaTimer(Action callback, object state, int period) + { + if (period <= 0) + throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than 0"); + + _callback = callback; + _state = state; + + lpTimeProc = CallbackInternal; + _eventId = timeSetEvent((uint)period, 1, lpTimeProc, 0, TimerMode.Periodic); + } + + /// + /// The timeSetEvent function starts a specified timer event. The multimedia timer runs in its own thread. + /// After the event is activated, it calls the specified callback function or sets or pulses the specified + /// event object. + /// + /// + /// Event delay, in milliseconds. If this value is not in the range of the minimum and + /// maximum event delays supported by the timer, the function returns an error. + /// + /// + /// Resolution of the timer event, in milliseconds. The resolution increases with + /// smaller values; a resolution of 0 indicates periodic events should occur with the greatest possible accuracy. + /// To reduce system overhead, however, you should use the maximum value appropriate for your application. + /// + /// + /// Pointer to a callback function that is called once upon expiration of a single event or periodically upon + /// expiration of periodic events. If fuEvent specifies the TIME_CALLBACK_EVENT_SET or TIME_CALLBACK_EVENT_PULSE + /// flag, then the lpTimeProc parameter is interpreted as a handle to an event object. The event will be set or + /// pulsed upon completion of a single event or periodically upon completion of periodic events. + /// For any other value of fuEvent, the lpTimeProc parameter is a pointer to a callback function of type + /// LPTIMECALLBACK. + /// + /// User-supplied callback data. + /// + /// Timer event type. This parameter may include one of the following values. + [DllImport("Winmm.dll")] + private static extern uint timeSetEvent( + uint uDelay, + uint uResolution, + LpTimeProcDelegate lpTimeProc, + int dwUser, + TimerMode fuEvent); + + /// + /// The timeKillEvent function cancels a specified timer event. + /// + /// + /// Identifier of the timer event to cancel. + /// This identifier was returned by the timeSetEvent function when the timer event was set up. + /// + /// Returns TIMERR_NOERROR if successful or MMSYSERR_INVALPARAM if the specified timer event does not exist. + [DllImport("Winmm.dll")] + private static extern int timeKillEvent(uint uTimerId); + + private void CallbackInternal( + uint uTimerId, + uint uMsg, + int dwUser, + int dw1, + int dw2) + => _callback(_state); + + public void Dispose() + { + lpTimeProc = default; + timeKillEvent(_eventId); + } + + private delegate void LpTimeProcDelegate( + uint uTimerId, + uint uMsg, + int dwUser, + int dw1, + int dw2); + + private enum TimerMode + { + OneShot, + Periodic + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs new file mode 100644 index 0000000..cab883b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicExtensions.cs @@ -0,0 +1,57 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public static class MusicExtensions +{ + public static string PrettyTotalTime(this IMusicPlayer mp) + { + long sum = 0; + foreach (var track in mp.GetQueuedTracks()) + { + if (track.Duration == TimeSpan.MaxValue) + return "∞"; + + sum += track.Duration.Ticks; + } + + var total = new TimeSpan(sum); + + return total.ToString(@"hh\:mm\:ss"); + } + + public static string PrettyVolume(this IMusicPlayer mp) + => $"🔉 {(int)(mp.Volume * 100)}%"; + + public static string PrettyName(this ITrackInfo trackInfo) + => $"**[{trackInfo.Title.TrimTo(60).Replace("[", "\\[").Replace("]", "\\]")}]({trackInfo.Url.TrimTo(50, true)})**"; + + public static string PrettyInfo(this IQueuedTrackInfo trackInfo) + => $"{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {trackInfo.Queuer}"; + + public static string PrettyFullName(this IQueuedTrackInfo trackInfo) + => $@"{trackInfo.PrettyName()} + `{trackInfo.PrettyTotalTime()} | {trackInfo.Platform} | {Format.Sanitize(trackInfo.Queuer.TrimTo(15))}`"; + + public static string PrettyTotalTime(this ITrackInfo trackInfo) + { + if (trackInfo.Duration == TimeSpan.Zero) + return "(?)"; + if (trackInfo.Duration == TimeSpan.MaxValue) + return "∞"; + if (trackInfo.Duration.TotalHours >= 1) + return trackInfo.Duration.ToString("""hh\:mm\:ss"""); + + return trackInfo.Duration.ToString("""mm\:ss"""); + } + + public static ICachableTrackData ToCachedData(this ITrackInfo trackInfo, string id) + => new CachableTrackData + { + TotalDurationMs = trackInfo.Duration.TotalMilliseconds, + Id = id, + Thumbnail = trackInfo.Thumbnail, + Url = trackInfo.Url, + Platform = trackInfo.Platform, + Title = trackInfo.Title + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs new file mode 100644 index 0000000..0d6c149 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlatform.cs @@ -0,0 +1,9 @@ +#nullable disable +namespace EllieBot.Modules.Music; + +public enum MusicPlatform +{ + Radio, + Youtube, + Local, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs new file mode 100644 index 0000000..6819d4e --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicPlayer.cs @@ -0,0 +1,531 @@ +using EllieBot.Voice; +using EllieBot.Db.Models; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace EllieBot.Modules.Music; + +public sealed class MusicPlayer : IMusicPlayer +{ + public event Func? OnCompleted; + public event Func? OnStarted; + public event Func? OnQueueStopped; + public bool IsKilled { get; private set; } + public bool IsStopped { get; private set; } + public bool IsPaused { get; private set; } + public PlayerRepeatType Repeat { get; private set; } + + public int CurrentIndex + => _queue.Index; + + public float Volume { get; private set; } = 1.0f; + + private readonly AdjustVolumeDelegate _adjustVolume; + private readonly VoiceClient _vc; + + private readonly IMusicQueue _queue; + private readonly ITrackResolveProvider _trackResolveProvider; + private readonly IVoiceProxy _proxy; + private readonly IGoogleApiService _googleApiService; + private readonly ISongBuffer _songBuffer; + + private bool skipped; + private int? forceIndex; + private readonly Thread _thread; + private readonly Random _rng; + + public bool AutoPlay { get; set; } + + public MusicPlayer( + IMusicQueue queue, + ITrackResolveProvider trackResolveProvider, + IVoiceProxy proxy, + IGoogleApiService googleApiService, + QualityPreset qualityPreset, + bool autoPlay) + { + _queue = queue; + _trackResolveProvider = trackResolveProvider; + _proxy = proxy; + _googleApiService = googleApiService; + AutoPlay = autoPlay; + _rng = new EllieRandom(); + + _vc = GetVoiceClient(qualityPreset); + if (_vc.BitDepth == 16) + _adjustVolume = AdjustVolumeInt16; + else + _adjustVolume = AdjustVolumeFloat32; + + _songBuffer = new PoopyBufferImmortalized(_vc.InputLength); + + _thread = new(async () => { await PlayLoop(); }); + _thread.Start(); + } + + private static VoiceClient GetVoiceClient(QualityPreset qualityPreset) + => qualityPreset switch + { + QualityPreset.Highest => new(), + QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40), + QualityPreset.Medium => new(SampleRate._48k, + Bitrate._96k, + Channels.Two, + FrameDelay.Delay40, + BitDepthEnum.UInt16), + QualityPreset.Low => new(SampleRate._48k, + Bitrate._64k, + Channels.Two, + FrameDelay.Delay40, + BitDepthEnum.UInt16), + _ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null) + }; + + private async Task PlayLoop() + { + var sw = new Stopwatch(); + + while (!IsKilled) + { + // wait until a song is available in the queue + // or until the queue is resumed + var track = _queue.GetCurrent(out var index); + + if (track is null || IsStopped) + { + await Task.Delay(500); + continue; + } + + if (skipped) + { + skipped = false; + _queue.Advance(); + continue; + } + + using var cancellationTokenSource = new CancellationTokenSource(); + var token = cancellationTokenSource.Token; + try + { + // light up green in vc + _ = _proxy.StartSpeakingAsync(); + + _ = OnStarted?.Invoke(this, track, index); + + // make sure song buffer is ready to be (re)used + _songBuffer.Reset(); + + var streamUrl = await track.GetStreamUrl(); + // start up the data source + using var source = FfmpegTrackDataSource.CreateAsync( + _vc.BitDepth, + streamUrl, + track.Platform == MusicPlatform.Local); + + // start moving data from the source into the buffer + // this method will return once the sufficient prebuffering is done + await _songBuffer.BufferAsync(source, token); + + // // Implemenation with multimedia timer. Works but a hassle because no support for switching + // // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option + // // for selfhosters. + // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + // { + // var cancelSource = new CancellationTokenSource(); + // var cancelToken = cancelSource.Token; + // using var timer = new MultimediaTimer(_ => + // { + // if (IsStopped || IsKilled) + // { + // cancelSource.Cancel(); + // return; + // } + // + // if (_skipped) + // { + // _skipped = false; + // cancelSource.Cancel(); + // return; + // } + // + // if (IsPaused) + // return; + // + // try + // { + // // this should tolerate certain number of errors + // var result = CopyChunkToOutput(_songBuffer, _vc); + // if (!result) + // cancelSource.Cancel(); + // + // } + // catch (Exception ex) + // { + // Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); + // cancelSource.Cancel(); + // } + // + // }, null, 20); + // + // while(true) + // await Task.Delay(1000, cancelToken); + // } + + // start sending data + var ticksPerMs = 1000f / Stopwatch.Frequency; + sw.Start(); + Thread.Sleep(2); + + var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3; + + var errorCount = 0; + while (!IsStopped && !IsKilled) + { + // doing the skip this way instead of in the condition + // ensures that a song will for sure be skipped + if (skipped) + { + skipped = false; + break; + } + + if (IsPaused) + { + await Task.Delay(200); + continue; + } + + sw.Restart(); + var ticks = sw.ElapsedTicks; + try + { + var result = CopyChunkToOutput(_songBuffer, _vc); + + // if song is finished + if (result is null) + break; + + if (result is true) + { + if (errorCount > 0) + { + _ = _proxy.StartSpeakingAsync(); + errorCount = 0; + } + + // FUTURE windows multimedia api + + // wait for slightly less than the latency + Thread.Sleep(delay); + + // and then spin out the rest + while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f) + Thread.SpinWait(100); + } + else + { + // result is false is either when the gateway is being swapped + // or if the bot is reconnecting, or just disconnected for whatever reason + + // tolerate up to 15x200ms of failures (3 seconds) + if (++errorCount <= 15) + { + await Task.Delay(200); + continue; + } + + Log.Warning("Can't send data to voice channel"); + + IsStopped = true; + // if errors are happening for more than 3 seconds + // Stop the player + break; + } + } + catch (Exception ex) + { + Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); + } + } + } + catch (Win32Exception) + { + IsStopped = true; + Log.Error("Please install ffmpeg and make sure it's added to your " + + "PATH environment variable before trying again"); + } + catch (OperationCanceledException) + { + Log.Information("Song skipped"); + } + catch (Exception ex) + { + Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message); + } + finally + { + cancellationTokenSource.Cancel(); + // turn off green in vc + + _ = OnCompleted?.Invoke(this, track); + + if (AutoPlay && track.Platform == MusicPlatform.Youtube) + { + try + { + var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5); + var related = relatedSongs.Shuffle().FirstOrDefault(); + if (related is not null) + { + var relatedTrack = + await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube); + if (relatedTrack is not null) + EnqueueTrack(relatedTrack, "Autoplay"); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Failed queueing a related song via autoplay"); + } + } + + + HandleQueuePostTrack(); + skipped = false; + + _ = _proxy.StopSpeakingAsync(); + + await Task.Delay(100); + } + } + } + + private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc) + { + var data = sb.Read(vc.InputLength, out var length); + + // if nothing is read from the buffer, song is finished + if (data.Length == 0) + return null; + + _adjustVolume(data, Volume); + return _proxy.SendPcmFrame(vc, data, length); + } + + private void HandleQueuePostTrack() + { + if (forceIndex is { } index) + { + _queue.SetIndex(index); + forceIndex = null; + return; + } + + var (repeat, isStopped) = (Repeat, IsStopped); + + if (repeat == PlayerRepeatType.Track || isStopped) + return; + + // if queue is being repeated, advance no matter what + if (repeat == PlayerRepeatType.None) + { + // if this is the last song, + // stop the queue + if (_queue.IsLast()) + { + IsStopped = true; + OnQueueStopped?.Invoke(this); + return; + } + + _queue.Advance(); + return; + } + + _queue.Advance(); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AdjustVolumeInt16(Span audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(audioSamples); + + for (var i = 0; i < samples.Length; i++) + { + ref var sample = ref samples[i]; + sample = (short)(sample * volume); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AdjustVolumeFloat32(Span audioSamples, float volume) + { + if (Math.Abs(volume - 1f) < 0.0001f) + return; + + var samples = MemoryMarshal.Cast(audioSamples); + + for (var i = 0; i < samples.Length; i++) + { + ref var sample = ref samples[i]; + sample *= volume; + } + } + + public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync( + string query, + string queuer, + bool asNext, + MusicPlatform? forcePlatform = null) + { + var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform); + if (song is null) + return default; + + int index; + + if (asNext) + return (_queue.EnqueueNext(song, queuer, out index), index); + + return (_queue.Enqueue(song, queuer, out index), index); + } + + public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer) + { + var errorCount = 0; + foreach (var chunk in queries.Chunk(5)) + { + if (IsKilled) + break; + + await chunk.Select(async data => + { + var (query, platform) = data; + try + { + await TryEnqueueTrackAsync(query, queuer, false, platform); + errorCount = 0; + } + catch (Exception ex) + { + Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query); + ++errorCount; + } + }) + .WhenAll(); + + await Task.Delay(1000); + + // > 10 errors in a row = kill + if (errorCount > 10) + break; + } + } + + public void EnqueueTrack(ITrackInfo track, string queuer) + => _queue.Enqueue(track, queuer, out _); + + public void EnqueueTracks(IEnumerable tracks, string queuer) + => _queue.EnqueueMany(tracks, queuer); + + public void SetRepeat(PlayerRepeatType type) + => Repeat = type; + + public void ShuffleQueue() + => _queue.Shuffle(_rng); + + public void Stop() + => IsStopped = true; + + public void Clear() + { + _queue.Clear(); + skipped = true; + } + + public IReadOnlyCollection GetQueuedTracks() + => _queue.List(); + + public IQueuedTrackInfo? GetCurrentTrack(out int index) + => _queue.GetCurrent(out index); + + public void Next() + { + skipped = true; + IsStopped = false; + IsPaused = false; + } + + public bool MoveTo(int index) + { + if (_queue.SetIndex(index)) + { + forceIndex = index; + skipped = true; + IsStopped = false; + IsPaused = false; + return true; + } + + return false; + } + + public void SetVolume(int newVolume) + { + var normalizedVolume = newVolume / 100f; + if (normalizedVolume is < 0f or > 1f) + throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100"); + + Volume = normalizedVolume; + } + + public void Kill() + { + IsKilled = true; + IsStopped = true; + IsPaused = false; + skipped = true; + } + + public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo) + { + if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent)) + return false; + + if (isCurrent) + skipped = true; + + return true; + } + + public bool TogglePause() + => IsPaused = !IsPaused; + + public IQueuedTrackInfo? MoveTrack(int from, int to) + => _queue.MoveTrack(from, to); + + public void Dispose() + { + IsKilled = true; + OnCompleted = null; + OnStarted = null; + OnQueueStopped = null; + _queue.Clear(); + _songBuffer.Dispose(); + _vc.Dispose(); + } + + private delegate void AdjustVolumeDelegate(Span data, float volume); + + public void SetFairplay() + { + _queue.ReorderFairly(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs new file mode 100644 index 0000000..1b1ce9c --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/MusicQueue.cs @@ -0,0 +1,345 @@ +namespace EllieBot.Modules.Music; + +public sealed partial class MusicQueue +{ + private sealed class QueuedTrackInfo : IQueuedTrackInfo + { + public ITrackInfo TrackInfo { get; } + public string Queuer { get; } + + public string Title + => TrackInfo.Title; + + public string Url + => TrackInfo.Url; + + public string Thumbnail + => TrackInfo.Thumbnail; + + public TimeSpan Duration + => TrackInfo.Duration; + + public MusicPlatform Platform + => TrackInfo.Platform; + + + public QueuedTrackInfo(ITrackInfo trackInfo, string queuer) + { + TrackInfo = trackInfo; + Queuer = queuer; + } + + public ValueTask GetStreamUrl() + => TrackInfo.GetStreamUrl(); + } +} + +public sealed partial class MusicQueue : IMusicQueue +{ + public int Index + { + get + { + // just make sure the internal logic runs first + // to make sure that some potential intermediate value is not returned + lock (_locker) + { + return index; + } + } + } + + public int Count + { + get + { + lock (_locker) + { + return tracks.Count; + } + } + } + + private LinkedList tracks; + + private int index; + + private readonly object _locker = new(); + + public MusicQueue() + { + index = 0; + tracks = new(); + } + + public IQueuedTrackInfo Enqueue(ITrackInfo trackInfo, string queuer, out int enqueuedAt) + { + lock (_locker) + { + var added = new QueuedTrackInfo(trackInfo, queuer); + enqueuedAt = tracks.Count; + tracks.AddLast(added); + + return added; + } + } + + + public IQueuedTrackInfo EnqueueNext(ITrackInfo trackInfo, string queuer, out int trackIndex) + { + lock (_locker) + { + if (tracks.Count == 0) + return Enqueue(trackInfo, queuer, out trackIndex); + + var currentNode = tracks.First!; + int i; + for (i = 1; i <= index; i++) + currentNode = currentNode.Next!; // can't be null because index is always in range of the count + + var added = new QueuedTrackInfo(trackInfo, queuer); + trackIndex = i; + + tracks.AddAfter(currentNode, added); + + return added; + } + } + + public void EnqueueMany(IEnumerable toEnqueue, string queuer) + { + lock (_locker) + { + foreach (var track in toEnqueue) + { + var added = new QueuedTrackInfo(track, queuer); + tracks.AddLast(added); + } + } + } + + public IReadOnlyCollection List() + { + lock (_locker) + { + return tracks.ToList(); + } + } + + public IQueuedTrackInfo? GetCurrent(out int currentIndex) + { + lock (_locker) + { + currentIndex = index; + return tracks.ElementAtOrDefault(index); + } + } + + public void Advance() + { + lock (_locker) + { + if (++index >= tracks.Count) + index = 0; + } + } + + public void Clear() + { + lock (_locker) + { + tracks.Clear(); + } + } + + public bool SetIndex(int newIndex) + { + lock (_locker) + { + if (newIndex < 0 || newIndex >= tracks.Count) + return false; + + index = newIndex; + return true; + } + } + + private void RemoveAtInternal(int remoteAtIndex, out IQueuedTrackInfo trackInfo) + { + var removedNode = tracks.First!; + int i; + for (i = 0; i < remoteAtIndex; i++) + removedNode = removedNode.Next!; + + trackInfo = removedNode.Value; + tracks.Remove(removedNode); + + if (i <= index) + --index; + + if (index < 0) + index = Count; + + // if it was the last song in the queue + // // wrap back to start + // if (_index == Count) + // _index = 0; + // else if (i <= _index) + // if (_index == 0) + // _index = Count; + // else --_index; + } + + public void RemoveCurrent() + { + lock (_locker) + { + if (index < tracks.Count) + RemoveAtInternal(index, out _); + } + } + + public IQueuedTrackInfo? MoveTrack(int from, int to) + { + ArgumentOutOfRangeException.ThrowIfNegative(from); + ArgumentOutOfRangeException.ThrowIfNegative(to); + ArgumentOutOfRangeException.ThrowIfEqual(to, from); + + lock (_locker) + { + if (from >= Count || to >= Count) + return null; + + // update current track index + if (from == index) + { + // if the song being moved is the current track + // it means that it will for sure end up on the destination + index = to; + } + else + { + // moving a track from below the current track means + // means it will drop down + if (from < index) + index--; + + // moving a track to below the current track + // means it will rise up + if (to <= index) + index++; + + + // if both from and to are below _index - net change is + 1 - 1 = 0 + // if from is below and to is above - net change is -1 (as the track is taken and put above) + // if from is above and to is below - net change is 1 (as the track is inserted under) + // if from is above and to is above - net change is 0 + } + + // get the node which needs to be moved + var fromNode = tracks.First!; + for (var i = 0; i < from; i++) + fromNode = fromNode.Next!; + + // remove it from the queue + tracks.Remove(fromNode); + + // if it needs to be added as a first node, + // add it directly and return + if (to == 0) + { + tracks.AddFirst(fromNode); + return fromNode.Value; + } + + // else find the node at the index before the specified target + var addAfterNode = tracks.First!; + for (var i = 1; i < to; i++) + addAfterNode = addAfterNode.Next!; + + // and add after it + tracks.AddAfter(addAfterNode, fromNode); + return fromNode.Value; + } + } + + public void Shuffle(Random rng) + { + lock (_locker) + { + var list = tracks.ToArray(); + rng.Shuffle(list); + tracks = new(list); + } + } + + public bool IsLast() + { + lock (_locker) + { + return index == tracks.Count // if there are no tracks + || index == tracks.Count - 1; + } + } + + public void ReorderFairly() + { + lock (_locker) + { + var groups = new Dictionary(); + var queuers = new List>(); + + foreach (var track in tracks.Skip(index).Concat(tracks.Take(index))) + { + if (!groups.TryGetValue(track.Queuer, out var qIndex)) + { + queuers.Add(new Queue()); + qIndex = queuers.Count - 1; + groups.Add(track.Queuer, qIndex); + } + + queuers[qIndex].Enqueue(track); + } + + tracks = new LinkedList(); + index = 0; + + while (true) + { + for (var i = 0; i < queuers.Count; i++) + { + var queue = queuers[i]; + tracks.AddLast(queue.Dequeue()); + + if (queue.Count == 0) + { + queuers.RemoveAt(i); + i--; + } + } + + if (queuers.Count == 0) + break; + } + } + } + + public bool TryRemoveAt(int remoteAt, out IQueuedTrackInfo? trackInfo, out bool isCurrent) + { + lock (_locker) + { + isCurrent = false; + trackInfo = null; + + if (remoteAt < 0 || remoteAt >= tracks.Count) + return false; + + if (remoteAt == index) + isCurrent = true; + + RemoveAtInternal(remoteAt, out trackInfo); + + return true; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs new file mode 100644 index 0000000..b002779 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/RemoteTrackInfo.cs @@ -0,0 +1,16 @@ +namespace EllieBot.Modules.Music; + +public sealed record RemoteTrackInfo( + string Id, + string Title, + string Url, + string Thumbnail, + TimeSpan Duration, + MusicPlatform Platform, + Func> _streamFactory) : ITrackInfo +{ + private readonly Func> _streamFactory = _streamFactory; + + public async ValueTask GetStreamUrl() + => await _streamFactory(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs new file mode 100644 index 0000000..9ae1c30 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/SimpleTrackInfo.cs @@ -0,0 +1,30 @@ +namespace EllieBot.Modules.Music; + +public sealed class SimpleTrackInfo : ITrackInfo +{ + public string Title { get; } + public string Url { get; } + public string Thumbnail { get; } + public TimeSpan Duration { get; } + public MusicPlatform Platform { get; } + public string? StreamUrl { get; } + + public SimpleTrackInfo( + string title, + string url, + string thumbnail, + TimeSpan duration, + MusicPlatform platform, + string streamUrl) + { + Title = title; + Url = url; + Thumbnail = thumbnail; + Duration = duration; + Platform = platform; + StreamUrl = streamUrl; + } + + public ValueTask GetStreamUrl() + => new(StreamUrl); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs new file mode 100644 index 0000000..2dcffcc --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/TrackCacher.cs @@ -0,0 +1,105 @@ +namespace EllieBot.Modules.Music; + +public sealed class TrackCacher : ITrackCacher +{ + private readonly IBotCache _cache; + + public TrackCacher(IBotCache cache) + => _cache = cache; + + + private TypedKey GetStreamLinkKey(MusicPlatform platform, string id) + => new($"music:stream:{platform}:{id}"); + + public async Task GetOrCreateStreamLink( + string id, + MusicPlatform platform, + Func> streamUrlFactory) + { + var key = GetStreamLinkKey(platform, id); + + var streamUrl = await _cache.GetOrDefaultAsync(key); + await _cache.RemoveAsync(key); + + if (streamUrl == default) + { + (streamUrl, _) = await streamUrlFactory(); + } + + // make a new one for later use + _ = Task.Run(async () => + { + (streamUrl, var expiry) = await streamUrlFactory(); + await CacheStreamUrlAsync(id, platform, streamUrl, expiry); + }); + + return streamUrl; + } + + public async Task CacheStreamUrlAsync( + string id, + MusicPlatform platform, + string url, + TimeSpan expiry) + => await _cache.AddAsync(GetStreamLinkKey(platform, id), url, expiry); + + // track data by id + private TypedKey GetTrackDataKey(MusicPlatform platform, string id) + => new($"music:track:{platform}:{id}"); + public async Task CacheTrackDataAsync(ICachableTrackData data) + => await _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)); + + private CachableTrackData ToCachableTrackData(ICachableTrackData data) + => new CachableTrackData() + { + Id = data.Id, + Platform = data.Platform, + Thumbnail = data.Thumbnail, + Title = data.Title, + Url = data.Url, + }; + + public async Task GetCachedDataByIdAsync(string id, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataKey(platform, id)); + + + // track data by query + private TypedKey GetTrackDataQueryKey(MusicPlatform platform, string query) + => new($"music:track:{platform}:q:{query}"); + + public async Task CacheTrackDataByQueryAsync(string query, ICachableTrackData data) + => await Task.WhenAll( + _cache.AddAsync(GetTrackDataQueryKey(data.Platform, query), ToCachableTrackData(data)).AsTask(), + _cache.AddAsync(GetTrackDataKey(data.Platform, data.Id), ToCachableTrackData(data)).AsTask()); + + public async Task GetCachedDataByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetTrackDataQueryKey(platform, query)); + + + // playlist track ids by playlist id + private TypedKey> GetPlaylistTracksCacheKey(string playlist, MusicPlatform platform) + => new($"music:playlist_tracks:{platform}:{playlist}"); + + public async Task CachePlaylistTrackIdsAsync(string playlistId, MusicPlatform platform, IEnumerable ids) + => await _cache.AddAsync(GetPlaylistTracksCacheKey(playlistId, platform), ids.ToList()); + + public async Task> GetPlaylistTrackIdsAsync(string playlistId, MusicPlatform platform) + { + var result = await _cache.GetAsync(GetPlaylistTracksCacheKey(playlistId, platform)); + if (result.TryGetValue(out var val)) + return val; + + return Array.Empty(); + } + + + // playlist id by query + private TypedKey GetPlaylistCacheKey(string query, MusicPlatform platform) + => new($"music:playlist_id:{platform}:{query}"); + + public async Task CachePlaylistIdByQueryAsync(string query, MusicPlatform platform, string playlistId) + => await _cache.AddAsync(GetPlaylistCacheKey(query, platform), playlistId); + + public async Task GetPlaylistIdByQueryAsync(string query, MusicPlatform platform) + => await _cache.GetOrDefaultAsync(GetPlaylistCacheKey(query, platform)); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs new file mode 100644 index 0000000..08bb8b8 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Impl/VoiceProxy.cs @@ -0,0 +1,102 @@ +#nullable disable +using EllieBot.Voice; +using EllieBot.Voice.Models; + +namespace EllieBot.Modules.Music; + +public sealed class VoiceProxy : IVoiceProxy +{ + public enum VoiceProxyState + { + Created, + Started, + Stopped + } + + private const int MAX_ERROR_COUNT = 20; + private const int DELAY_ON_ERROR_MILISECONDS = 200; + + public VoiceProxyState State + => gateway switch + { + { Started: true, Stopped: false } => VoiceProxyState.Started, + { Stopped: false } => VoiceProxyState.Created, + _ => VoiceProxyState.Stopped + }; + + + private VoiceGateway gateway; + + public VoiceProxy(VoiceGateway initial) + => gateway = initial; + + public bool SendPcmFrame(VoiceClient vc, Span data, int length) + { + try + { + var gw = gateway; + if (gw is null || gw.Stopped || !gw.Started) + return false; + + vc.SendPcmFrame(gw, data, 0, length); + return true; + } + catch (Exception) + { + return false; + } + } + + public async Task RunGatewayAction(Func action) + { + var errorCount = 0; + do + { + if (State == VoiceProxyState.Stopped) + break; + + try + { + var gw = gateway; + if (gw is null || !gw.ConnectingFinished.Task.IsCompleted) + { + ++errorCount; + await Task.Delay(DELAY_ON_ERROR_MILISECONDS); + Log.Debug("Gateway is not ready"); + continue; + } + + await action(gw); + errorCount = 0; + } + catch (Exception ex) + { + ++errorCount; + await Task.Delay(DELAY_ON_ERROR_MILISECONDS); + Log.Debug(ex, "Error performing proxy gateway action"); + } + } while (errorCount is > 0 and <= MAX_ERROR_COUNT); + + return State != VoiceProxyState.Stopped && errorCount <= MAX_ERROR_COUNT; + } + + public void SetGateway(VoiceGateway newGateway) + => gateway = newGateway; + + public Task StartSpeakingAsync() + => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.Microphone)); + + public Task StopSpeakingAsync() + => RunGatewayAction(gw => gw.SendSpeakingAsync(VoiceSpeaking.State.None)); + + public async Task StartGateway() + => await gateway.Start(); + + public Task StopGateway() + { + if (gateway is { } gw) + return gw.StopAsync(); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs new file mode 100644 index 0000000..d728ce7 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/LocalTrackResolver.cs @@ -0,0 +1,122 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Text; + +namespace EllieBot.Modules.Music.Resolvers; + +public sealed class LocalTrackResolver : ILocalTrackResolver +{ + private static readonly HashSet _musicExtensions = new[] + { + ".MP4", ".MP3", ".FLAC", ".OGG", ".WAV", ".WMA", ".WMV", ".AAC", ".MKV", ".WEBM", ".M4A", ".AA", ".AAX", + ".ALAC", ".AIFF", ".MOV", ".FLV", ".OGG", ".M4V" + }.ToHashSet(); + + public async Task ResolveByQueryAsync(string query) + { + if (!File.Exists(query)) + return null; + + var trackDuration = await Ffprobe.GetTrackDurationAsync(query); + return new SimpleTrackInfo(Path.GetFileNameWithoutExtension(query), + $"https://google.com?q={Uri.EscapeDataString(Path.GetFileNameWithoutExtension(query))}", + "https://cdn.discordapp.com/attachments/155726317222887425/261850914783100928/1482522077_music.png", + trackDuration, + MusicPlatform.Local, + $"\"{Path.GetFullPath(query)}\""); + } + + public async IAsyncEnumerable ResolveDirectoryAsync(string dirPath) + { + DirectoryInfo dir; + try + { + dir = new(dirPath); + } + catch (Exception ex) + { + Log.Error(ex, "Specified directory {DirectoryPath} could not be opened", dirPath); + yield break; + } + + var files = dir.EnumerateFiles() + .Where(x => + { + if (!x.Attributes.HasFlag(FileAttributes.Hidden | FileAttributes.System) + && _musicExtensions.Contains(x.Extension.ToUpperInvariant())) + return true; + return false; + }) + .ToList(); + + var firstFile = files.FirstOrDefault()?.FullName; + if (firstFile is null) + yield break; + + var firstData = await ResolveByQueryAsync(firstFile); + if (firstData is not null) + yield return firstData; + + var fileChunks = files.Skip(1).Chunk(10); + foreach (var chunk in fileChunks) + { + var part = await chunk.Select(x => ResolveByQueryAsync(x.FullName)).WhenAll(); + + // nullable reference types being annoying + foreach (var p in part) + { + if (p is null) + continue; + + yield return p; + } + } + } +} + +public static class Ffprobe +{ + public static async Task GetTrackDurationAsync(string query) + { + query = query.Replace("\"", ""); + + try + { + using var p = Process.Start(new ProcessStartInfo + { + FileName = "ffprobe", + Arguments = + $"-v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 -- \"{query}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + CreateNoWindow = true + }); + + if (p is null) + return TimeSpan.Zero; + + var data = await p.StandardOutput.ReadToEndAsync(); + if (double.TryParse(data, out var seconds)) + return TimeSpan.FromSeconds(seconds); + + var errorData = await p.StandardError.ReadToEndAsync(); + if (!string.IsNullOrWhiteSpace(errorData)) + Log.Warning("Ffprobe warning for file {FileName}: {ErrorMessage}", query, errorData); + + return TimeSpan.Zero; + } + catch (Win32Exception) + { + Log.Warning("Ffprobe was likely not installed. Local song durations will show as (?)"); + } + catch (Exception ex) + { + Log.Error(ex, "Unknown exception running ffprobe; {ErrorMessage}", ex.Message); + } + + return TimeSpan.Zero; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs new file mode 100644 index 0000000..c3733a4 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/RadioResolveStrategy.cs @@ -0,0 +1,106 @@ +#nullable disable +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Music.Resolvers; + +public class RadioResolver : IRadioResolver +{ + private readonly Regex _plsRegex = new(@"File1=(?.*?)\n", RegexOptions.Compiled); + private readonly Regex _m3URegex = new(@"(?^[^#].*)", RegexOptions.Compiled | RegexOptions.Multiline); + private readonly Regex _asxRegex = new(@".*?)""", RegexOptions.Compiled); + private readonly Regex _xspfRegex = new(@"(?.*?)", RegexOptions.Compiled); + + public async Task ResolveByQueryAsync(string query) + { + if (IsRadioLink(query)) + query = await HandleStreamContainers(query); + + return new SimpleTrackInfo(query.TrimTo(50), + query, + "https://cdn.discordapp.com/attachments/155726317222887425/261850925063340032/1482522097_radio.png", + TimeSpan.MaxValue, + MusicPlatform.Radio, + query); + } + + public static bool IsRadioLink(string query) + => (query.StartsWith("http", StringComparison.InvariantCulture) + || query.StartsWith("ww", StringComparison.InvariantCulture)) + && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf")); + + private async Task HandleStreamContainers(string query) + { + string file = null; + try + { + using var http = new HttpClient(); + file = await http.GetStringAsync(query); + } + catch + { + return query; + } + + if (query.Contains(".pls")) + { + try + { + var m = _plsRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .pls:\n{PlsFile}", file); + return null; + } + } + + if (query.Contains(".m3u")) + { + try + { + var m = _m3URegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .m3u:\n{M3uFile}", file); + return null; + } + } + + if (query.Contains(".asx")) + { + try + { + var m = _asxRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .asx:\n{AsxFile}", file); + return null; + } + } + + if (query.Contains(".xspf")) + { + try + { + var m = _xspfRegex.Match(file); + var res = m.Groups["url"]?.ToString(); + return res?.Trim(); + } + catch + { + Log.Warning("Failed reading .xspf:\n{XspfFile}", file); + return null; + } + } + + return query; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs new file mode 100644 index 0000000..642edf1 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/TrackResolveProvider.cs @@ -0,0 +1,49 @@ +namespace EllieBot.Modules.Music; + +public sealed class TrackResolveProvider : ITrackResolveProvider +{ + private readonly IYoutubeResolver _ytResolver; + private readonly ILocalTrackResolver _localResolver; + private readonly IRadioResolver _radioResolver; + + public TrackResolveProvider( + IYoutubeResolver ytResolver, + ILocalTrackResolver localResolver, + IRadioResolver radioResolver) + { + _ytResolver = ytResolver; + _localResolver = localResolver; + _radioResolver = radioResolver; + } + + public Task QuerySongAsync(string query, MusicPlatform? forcePlatform) + { + switch (forcePlatform) + { + case MusicPlatform.Radio: + return _radioResolver.ResolveByQueryAsync(query); + case MusicPlatform.Youtube: + return _ytResolver.ResolveByQueryAsync(query); + case MusicPlatform.Local: + return _localResolver.ResolveByQueryAsync(query); + case null: + var match = _ytResolver.YtVideoIdRegex.Match(query); + if (match.Success) + return _ytResolver.ResolveByIdAsync(match.Groups["id"].Value); + else if (Uri.TryCreate(query, UriKind.Absolute, out var uri) && uri.IsFile) + return _localResolver.ResolveByQueryAsync(uri.AbsolutePath); + else if (IsRadioLink(query)) + return _radioResolver.ResolveByQueryAsync(query); + else + return _ytResolver.ResolveByQueryAsync(query, false); + default: + Log.Error("Unsupported platform: {MusicPlatform}", forcePlatform); + return Task.FromResult(null); + } + } + + public static bool IsRadioLink(string query) + => (query.StartsWith("http", StringComparison.InvariantCulture) + || query.StartsWith("ww", StringComparison.InvariantCulture)) + && (query.Contains(".pls") || query.Contains(".m3u") || query.Contains(".asx") || query.Contains(".xspf")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs new file mode 100644 index 0000000..70479d0 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/Resolvers/YtdlYoutubeResolver.cs @@ -0,0 +1,315 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using EllieBot.Modules.Searches; + +namespace EllieBot.Modules.Music; + +public sealed class YtdlYoutubeResolver : IYoutubeResolver +{ + private static readonly string[] _durationFormats = + [ + "ss", "m\\:ss", "mm\\:ss", "h\\:mm\\:ss", "hh\\:mm\\:ss", "hhh\\:mm\\:ss" + ]; + + private static readonly Regex _expiryRegex = new(@"(?:[\?\&]expire\=(?\d+))"); + + + private static readonly Regex _simplePlaylistRegex = new(@"&list=(?[\w\-]{12,})", RegexOptions.Compiled); + + public Regex YtVideoIdRegex { get; } = + new(@"(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\?(?:\S*?&?v\=))|youtu\.be\/)(?[a-zA-Z0-9_-]{6,11})", + RegexOptions.Compiled); + + private readonly ITrackCacher _trackCacher; + + private readonly YtdlOperation _ytdlPlaylistOperation; + private readonly YtdlOperation _ytdlIdOperation; + private readonly YtdlOperation _ytdlSearchOperation; + + private readonly IGoogleApiService _google; + + public YtdlYoutubeResolver(ITrackCacher trackCacher, IGoogleApiService google, SearchesConfigService scs) + { + _trackCacher = trackCacher; + _google = google; + + + _ytdlPlaylistOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "-i " + + "--yes-playlist " + + "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + + _ytdlIdOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "-- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + + _ytdlSearchOperation = new("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "-f bestaudio " + + "-e " + + "--get-url " + + "--get-id " + + "--get-thumbnail " + + "--get-duration " + + "--no-check-certificate " + + "--default-search " + + "\"ytsearch:\" -- \"{0}\"", scs.Data.YtProvider != YoutubeSearcher.Ytdl); + } + + private YtTrackData ResolveYtdlData(string ytdlOutputString) + { + if (string.IsNullOrWhiteSpace(ytdlOutputString)) + return default; + + var dataArray = ytdlOutputString.Trim().Split('\n'); + + if (dataArray.Length < 5) + { + Log.Information("Not enough data received: {YtdlData}", ytdlOutputString); + return default; + } + + if (!TimeSpan.TryParseExact(dataArray[4], _durationFormats, CultureInfo.InvariantCulture, out var time)) + time = TimeSpan.Zero; + + var thumbnail = Uri.IsWellFormedUriString(dataArray[3], UriKind.Absolute) ? dataArray[3].Trim() : string.Empty; + + return new(dataArray[0], dataArray[1], thumbnail, dataArray[2], time); + } + + private ITrackInfo DataToInfo(in YtTrackData trackData) + => new RemoteTrackInfo( + trackData.Id, + trackData.Title, + $"https://youtube.com/watch?v={trackData.Id}", + trackData.Thumbnail, + trackData.Duration, + MusicPlatform.Youtube, + CreateCacherFactory(trackData.Id)); + + private Func> CreateCacherFactory(string id) + => () => _trackCacher.GetOrCreateStreamLink(id, + MusicPlatform.Youtube, + async () => await ExtractNewStreamUrlAsync(id)); + + private static TimeSpan GetExpiry(string streamUrl) + { + var match = _expiryRegex.Match(streamUrl); + if (match.Success && double.TryParse(match.Groups["timestamp"].ToString(), out var timestamp)) + { + var realExpiry = timestamp.ToUnixTimestamp() - DateTime.UtcNow; + if (realExpiry > TimeSpan.FromMinutes(60)) + return realExpiry.Subtract(TimeSpan.FromMinutes(30)); + + return realExpiry; + } + + return TimeSpan.FromHours(1); + } + + private async Task<(string StreamUrl, TimeSpan Expiry)> ExtractNewStreamUrlAsync(string id) + { + var data = await _ytdlIdOperation.GetDataAsync(id); + var trackInfo = ResolveYtdlData(data); + if (string.IsNullOrWhiteSpace(trackInfo.StreamUrl)) + return default; + + return (trackInfo.StreamUrl!, GetExpiry(trackInfo.StreamUrl!)); + } + + public async Task ResolveByIdAsync(string id) + { + id = id.Trim(); + + var cachedData = await _trackCacher.GetCachedDataByIdAsync(id, MusicPlatform.Youtube); + if (cachedData is null) + { + Log.Information("Resolving youtube track by Id: {YoutubeId}", id); + + var data = await _ytdlIdOperation.GetDataAsync(id); + + var trackInfo = ResolveYtdlData(data); + if (string.IsNullOrWhiteSpace(trackInfo.Title)) + return default; + + var toReturn = DataToInfo(in trackInfo); + + await Task.WhenAll(_trackCacher.CacheTrackDataAsync(toReturn.ToCachedData(id)), + CacheStreamUrlAsync(trackInfo)); + + return toReturn; + } + + return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration)); + } + + private Task CacheStreamUrlAsync(YtTrackData trackInfo) + => _trackCacher.CacheStreamUrlAsync(trackInfo.Id, + MusicPlatform.Youtube, + trackInfo.StreamUrl!, + GetExpiry(trackInfo.StreamUrl!)); + + public async IAsyncEnumerable ResolveTracksByPlaylistIdAsync(string playlistId) + { + Log.Information("Resolving youtube tracks from playlist: {PlaylistId}", playlistId); + var count = 0; + + var ids = await _trackCacher.GetPlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube); + if (ids.Count > 0) + { + foreach (var id in ids) + { + var trackInfo = await ResolveByIdAsync(id); + if (trackInfo is null) + continue; + + yield return trackInfo; + } + + yield break; + } + + var data = string.Empty; + var trackIds = new List(); + await foreach (var line in _ytdlPlaylistOperation.EnumerateDataAsync(playlistId)) + { + data += line; + + if (++count == 5) + { + var trackData = ResolveYtdlData(data); + data = string.Empty; + count = 0; + if (string.IsNullOrWhiteSpace(trackData.Id)) + continue; + + var info = DataToInfo(in trackData); + await Task.WhenAll(_trackCacher.CacheTrackDataAsync(info.ToCachedData(trackData.Id)), + CacheStreamUrlAsync(trackData)); + + trackIds.Add(trackData.Id); + yield return info; + } + else + data += Environment.NewLine; + } + + await _trackCacher.CachePlaylistTrackIdsAsync(playlistId, MusicPlatform.Youtube, trackIds); + } + + public async IAsyncEnumerable ResolveTracksFromPlaylistAsync(string query) + { + string? playlistId; + // try to match playlist id inside the query, if a playlist url has been queried + var match = _simplePlaylistRegex.Match(query); + if (match.Success) + { + // if it's a success, just return from that playlist using the id + playlistId = match.Groups["id"].ToString(); + await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId)) + yield return track; + + yield break; + } + + // if a query is a search term, try the cache + playlistId = await _trackCacher.GetPlaylistIdByQueryAsync(query, MusicPlatform.Youtube); + if (playlistId is null) + { + // if it's not in the cache + // find playlist id by keyword using google api + try + { + var playlistIds = await _google.GetPlaylistIdsByKeywordsAsync(query); + playlistId = playlistIds.FirstOrDefault(); + } + catch (Exception ex) + { + Log.Warning(ex, "Error Getting playlist id via GoogleApi"); + } + + // if query is not a playlist url + // and query result is not in the cache + // and api returns no values + // it means invalid input has been used, + // or google api key is not provided + if (playlistId is null) + yield break; + } + + // cache the query -> playlist id for fast future lookup + await _trackCacher.CachePlaylistIdByQueryAsync(query, MusicPlatform.Youtube, playlistId); + await foreach (var track in ResolveTracksByPlaylistIdAsync(playlistId)) + yield return track; + } + + public Task ResolveByQueryAsync(string query) + => ResolveByQueryAsync(query, true); + + public async Task ResolveByQueryAsync(string query, bool tryResolving) + { + if (tryResolving) + { + var match = YtVideoIdRegex.Match(query); + if (match.Success) + return await ResolveByIdAsync(match.Groups["id"].Value); + } + + Log.Information("Resolving youtube song by search term: {YoutubeQuery}", query); + + var cachedData = await _trackCacher.GetCachedDataByQueryAsync(query, MusicPlatform.Youtube); + if (cachedData is null || string.IsNullOrWhiteSpace(cachedData.Title)) + { + var stringData = await _ytdlSearchOperation.GetDataAsync(query); + var trackData = ResolveYtdlData(stringData); + + var trackInfo = DataToInfo(trackData); + await Task.WhenAll(_trackCacher.CacheTrackDataByQueryAsync(query, trackInfo.ToCachedData(trackData.Id)), + CacheStreamUrlAsync(trackData)); + return trackInfo; + } + + return DataToInfo(new(cachedData.Title, cachedData.Id, cachedData.Thumbnail, null, cachedData.Duration)); + } + + private readonly struct YtTrackData + { + public readonly string Title; + public readonly string Id; + public readonly string Thumbnail; + public readonly string? StreamUrl; + public readonly TimeSpan Duration; + + public YtTrackData( + string title, + string id, + string thumbnail, + string? streamUrl, + TimeSpan duration) + { + Title = title.Trim(); + Id = id.Trim(); + Thumbnail = thumbnail; + StreamUrl = streamUrl; + Duration = duration; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs new file mode 100644 index 0000000..d8f81b5 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlayerSettingsExtensions.cs @@ -0,0 +1,27 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlayerSettingsExtensions +{ + public static async Task ForGuildAsync(this DbSet settings, ulong guildId) + { + var toReturn = await settings.AsQueryable().FirstOrDefaultAsync(x => x.GuildId == guildId); + + if (toReturn is null) + { + var newSettings = new MusicPlayerSettings + { + GuildId = guildId, + PlayerRepeat = PlayerRepeatType.Queue + }; + + await settings.AddAsync(newSettings); + return newSettings; + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs new file mode 100644 index 0000000..16a755b --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylist.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MusicPlaylist : DbEntity +{ + public string Name { get; set; } + public string Author { get; set; } + public ulong AuthorId { get; set; } + public List Songs { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs new file mode 100644 index 0000000..0e3e603 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicPlaylistExtensions.cs @@ -0,0 +1,18 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Db; + +public static class MusicPlaylistExtensions +{ + public static List GetPlaylistsOnPage(this DbSet playlists, int num) + { + ArgumentOutOfRangeException.ThrowIfLessThan(num, 1); + + return playlists.AsQueryable().Skip((num - 1) * 20).Take(20).Include(pl => pl.Songs).ToList(); + } + + public static MusicPlaylist GetWithSongs(this DbSet playlists, int id) + => playlists.Include(mpl => mpl.Songs).FirstOrDefault(mpl => mpl.Id == id); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs new file mode 100644 index 0000000..40f8397 --- /dev/null +++ b/src/EllieBot/Modules/Music/_common/db/MusicSettings.cs @@ -0,0 +1,61 @@ +#nullable disable +namespace EllieBot.Db.Models; + +public class MusicPlayerSettings +{ + /// + /// Auto generated Id + /// + public int Id { get; set; } + + /// + /// Id of the guild + /// + public ulong GuildId { get; set; } + + /// + /// Queue repeat type + /// + public PlayerRepeatType PlayerRepeat { get; set; } = PlayerRepeatType.Queue; + + /// + /// Channel id the bot will always try to send track related messages to + /// + public ulong? MusicChannelId { get; set; } + + /// + /// Default volume player will be created with + /// + public int Volume { get; set; } = 100; + + /// + /// Whether the bot should auto disconnect from the voice channel once the queue is done + /// This only has effect if + /// + public bool AutoDisconnect { get; set; } + + /// + /// Selected quality preset for the music player + /// + public QualityPreset QualityPreset { get; set; } + + /// + /// Whether the bot will automatically queue related songs + /// + public bool AutoPlay { get; set; } +} + +public enum QualityPreset +{ + Highest, + High, + Medium, + Low +} + +public enum PlayerRepeatType +{ + None, + Track, + Queue +} \ No newline at end of file -- 2.43.0 From 7c965e5d10b224d884a30b82f9718f0d67fc86bd Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:54:32 +1200 Subject: [PATCH 027/340] Added Patronage module --- .../Patronage/Config/PatronageConfig.cs | 35 + .../Patronage/CurrencyRewardService.cs | 195 ++++ .../Modules/Patronage/InsufficientTier.cs | 11 + .../Patronage/Patreon/PatreonClient.cs | 150 ++++ .../Patronage/Patreon/PatreonCredentials.cs | 10 + .../Modules/Patronage/Patreon/PatreonData.cs | 134 +++ .../Patronage/Patreon/PatreonMemberData.cs | 33 + .../Patronage/Patreon/PatreonRefreshData.cs | 22 + .../Patreon/PatreonSubscriptionHandler.cs | 79 ++ .../Modules/Patronage/PatronageCommands.cs | 156 ++++ .../Modules/Patronage/PatronageService.cs | 843 ++++++++++++++++++ 11 files changed, 1668 insertions(+) create mode 100644 src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs create mode 100644 src/EllieBot/Modules/Patronage/CurrencyRewardService.cs create mode 100644 src/EllieBot/Modules/Patronage/InsufficientTier.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs create mode 100644 src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs create mode 100644 src/EllieBot/Modules/Patronage/PatronageCommands.cs create mode 100644 src/EllieBot/Modules/Patronage/PatronageService.cs diff --git a/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs new file mode 100644 index 0000000..56f166f --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Config/PatronageConfig.cs @@ -0,0 +1,35 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Patronage; + +public class PatronageConfig : ConfigServiceBase +{ + public override string Name + => "patron"; + + private static readonly TypedKey _changeKey; + + private const string FILE_PATH = "data/patron.yml"; + + public PatronageConfig(IConfigSeria serializer, IPubSub pubSub) : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("enabled", + x => x.IsEnabled, + bool.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + ModifyConfig(c => + { + if (c.Version == 1) + { + c.Version = 2; + c.IsEnabled = false; + } + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs new file mode 100644 index 0000000..336fe9c --- /dev/null +++ b/src/EllieBot/Modules/Patronage/CurrencyRewardService.cs @@ -0,0 +1,195 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Modules.Gambling.Services; +using EllieBot.Modules.Patronage; +using EllieBot.Services.Currency; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class CurrencyRewardService : IEService, IDisposable +{ + private readonly ICurrencyService _cs; + private readonly IPatronageService _ps; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly GamblingConfigService _config; + private readonly DiscordSocketClient _client; + + public CurrencyRewardService( + ICurrencyService cs, + IPatronageService ps, + DbService db, + IMessageSenderService sender, + GamblingConfigService config, + DiscordSocketClient client) + { + _cs = cs; + _ps = ps; + _db = db; + _sender = sender; + _config = config; + _client = client; + + _ps.OnNewPatronPayment += OnNewPayment; + _ps.OnPatronRefunded += OnPatronRefund; + _ps.OnPatronUpdated += OnPatronUpdate; + } + + public void Dispose() + { + _ps.OnNewPatronPayment -= OnNewPayment; + _ps.OnPatronRefunded -= OnPatronRefund; + _ps.OnPatronUpdated -= OnPatronUpdate; + } + + private async Task OnPatronUpdate(Patron oldPatron, Patron newPatron) + { + // if pledge was increased + if (oldPatron.Amount < newPatron.Amount) + { + var conf = _config.Data; + var newAmount = (long)(newPatron.Amount * conf.PatreonCurrencyPerCent); + + RewardedUser old; + await using (var ctx = _db.GetDbContext()) + { + old = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .FirstOrDefaultAsync(); + + if (old is null) + { + await OnNewPayment(newPatron); + return; + } + + // no action as the amount is the same or lower + if (old.AmountRewardedThisMonth >= newAmount) + return; + + var count = await ctx.GetTable() + .Where(x => x.PlatformUserId == newPatron.UniquePlatformUserId) + .UpdateAsync(_ => new() + { + PlatformUserId = newPatron.UniquePlatformUserId, + UserId = newPatron.UserId, + // amount before bonuses + AmountRewardedThisMonth = newAmount, + LastReward = newPatron.PaidAt + }); + + // shouldn't ever happen + if (count == 0) + return; + } + + var oldAmount = old.AmountRewardedThisMonth; + + var realNewAmount = GetRealCurrencyReward( + (int)(newAmount / conf.PatreonCurrencyPerCent), + newAmount, + out var percentBonus); + + var realOldAmount = GetRealCurrencyReward( + (int)(oldAmount / conf.PatreonCurrencyPerCent), + oldAmount, + out _); + + var diff = realNewAmount - realOldAmount; + if (diff <= 0) + return; // no action if new is lower + + // if the user pledges 5$ or more, they will get X % more flowers where X is amount in dollars, + // up to 100% + + await _cs.AddAsync(newPatron.UserId, diff, new TxData("patron", "update")); + + _ = SendMessageToUser(newPatron.UserId, + $"You've received an additional **{diff}**{_config.Data.Currency.Sign} as a currency reward (+{percentBonus}%)!"); + } + } + + private long GetRealCurrencyReward(int pledgeCents, long modifiedAmount, out int percentBonus) + { + // needs at least 5$ to be eligible for a bonus + if (pledgeCents < 500) + { + percentBonus = 0; + return modifiedAmount; + } + + var dollarValue = pledgeCents / 100; + percentBonus = dollarValue switch + { + >= 100 => 100, + >= 50 => 50, + >= 20 => 20, + >= 10 => 10, + >= 5 => 5, + _ => 0 + }; + return (long)(modifiedAmount * (1 + (percentBonus / 100.0f))); + } + + // on a new payment, always give the full amount. + private async Task OnNewPayment(Patron patron) + { + var amount = (long)(patron.Amount * _config.Data.PatreonCurrencyPerCent); + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + PlatformUserId = patron.UniquePlatformUserId, + UserId = patron.UserId, + AmountRewardedThisMonth = amount, + LastReward = patron.PaidAt, + }, + old => new() + { + AmountRewardedThisMonth = amount, + UserId = patron.UserId, + LastReward = patron.PaidAt + }, + () => new() + { + PlatformUserId = patron.UniquePlatformUserId + }); + + var realAmount = GetRealCurrencyReward(patron.Amount, amount, out var percentBonus); + await _cs.AddAsync(patron.UserId, realAmount, new("patron", "new")); + _ = SendMessageToUser(patron.UserId, + $"You've received **{realAmount}**{_config.Data.Currency.Sign} as a currency reward (**+{percentBonus}%**)!"); + } + + private async Task SendMessageToUser(ulong userId, string message) + { + try + { + var user = (IUser)_client.GetUser(userId) ?? await _client.Rest.GetUserAsync(userId); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(message); + + await _sender.Response(user).Embed(eb).SendAsync(); + } + catch + { + Log.Warning("Unable to send a \"Currency Reward\" message to the patron {UserId}", userId); + } + } + + private async Task OnPatronRefund(Patron patron) + { + await using var ctx = _db.GetDbContext(); + _ = await ctx.GetTable() + .UpdateAsync(old => new() + { + AmountRewardedThisMonth = old.AmountRewardedThisMonth * 2 + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/InsufficientTier.cs b/src/EllieBot/Modules/Patronage/InsufficientTier.cs new file mode 100644 index 0000000..26a0675 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/InsufficientTier.cs @@ -0,0 +1,11 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Patronage; + +public readonly struct InsufficientTier +{ + public FeatureType FeatureType { get; init; } + public string Feature { get; init; } + public PatronTier RequiredTier { get; init; } + public PatronTier UserTier { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs new file mode 100644 index 0000000..ad65448 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonClient.cs @@ -0,0 +1,150 @@ +#nullable disable +using OneOf; +using OneOf.Types; +using System.Net.Http.Json; +using System.Text.Json; + +namespace EllieBot.Modules.Patronage; + +public class PatreonClient : IDisposable +{ + private readonly string _clientId; + private readonly string _clientSecret; + private string refreshToken; + + + private string accessToken = string.Empty; + private readonly HttpClient _http; + + private DateTime refreshAt = DateTime.UtcNow; + + public PatreonClient(string clientId, string clientSecret, string refreshToken) + { + _clientId = clientId; + _clientSecret = clientSecret; + this.refreshToken = refreshToken; + + _http = new(); + } + + public void Dispose() + => _http.Dispose(); + + public PatreonCredentials GetCredentials() + => new PatreonCredentials() + { + AccessToken = accessToken, + ClientId = _clientId, + ClientSecret = _clientSecret, + RefreshToken = refreshToken, + }; + + public async Task>> RefreshTokenAsync(bool force) + { + if (!force && IsTokenValid()) + return new Success(); + + var res = await _http.PostAsync("https://www.patreon.com/api/oauth2/token" + + "?grant_type=refresh_token" + + $"&refresh_token={refreshToken}" + + $"&client_id={_clientId}" + + $"&client_secret={_clientSecret}", + null); + + if (!res.IsSuccessStatusCode) + return new Error($"Request did not return a sucess status code. Status code: {res.StatusCode}"); + + try + { + var data = await res.Content.ReadFromJsonAsync(); + + if (data is null) + return new Error($"Invalid data retrieved from Patreon."); + + refreshToken = data.RefreshToken; + accessToken = data.AccessToken; + + refreshAt = DateTime.UtcNow.AddSeconds(data.ExpiresIn - 5.Minutes().TotalSeconds); + return new Success(); + } + catch (Exception ex) + { + return new Error($"Error during deserialization: {ex.Message}"); + } + } + + private async ValueTask EnsureTokenValidAsync() + { + if (!IsTokenValid()) + { + var res = await RefreshTokenAsync(true); + return res.Match( + static _ => true, + static err => + { + Log.Warning("Error getting token: {ErrorMessage}", err.Value); + return false; + }); + } + + return true; + } + + private bool IsTokenValid() + => refreshAt > DateTime.UtcNow && !string.IsNullOrWhiteSpace(accessToken); + + public async Task>, Error>> GetMembersAsync(string campaignId) + { + if (!await EnsureTokenValidAsync()) + return new Error("Unable to get patreon token"); + + return OneOf>, Error>.FromT0( + GetMembersInternalAsync(campaignId)); + } + + private async IAsyncEnumerable> GetMembersInternalAsync(string campaignId) + { + _http.DefaultRequestHeaders.Clear(); + _http.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", + $"Bearer {accessToken}"); + + var page = + $"https://www.patreon.com/api/oauth2/v2/campaigns/{campaignId}/members" + + $"?fields%5Bmember%5D=full_name,currently_entitled_amount_cents,last_charge_date,last_charge_status" + + $"&fields%5Buser%5D=social_connections" + + $"&include=user" + + $"&sort=-last_charge_date"; + PatreonMembersResponse data; + + do + { + var res = await _http.GetStreamAsync(page); + data = await JsonSerializer.DeserializeAsync(res); + + if (data is null) + break; + + var userData = data.Data + .Join(data.Included, + static m => m.Relationships.User.Data.Id, + static u => u.Id, + static (m, u) => new PatreonMemberData() + { + PatreonUserId = m.Relationships.User.Data.Id, + UserId = ulong.TryParse( + u.Attributes?.SocialConnections?.Discord?.UserId ?? string.Empty, + out var userId) + ? userId + : 0, + EntitledToCents = m.Attributes.CurrentlyEntitledAmountCents, + LastChargeDate = m.Attributes.LastChargeDate, + LastChargeStatus = m.Attributes.LastChargeStatus + }) + .Where(x => x.UserId == 140788173885276160) + .ToArray(); + + yield return userData; + + } while (!string.IsNullOrWhiteSpace(page = data.Links?.Next)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs new file mode 100644 index 0000000..5eb6f1f --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonCredentials.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public readonly struct PatreonCredentials +{ + public string ClientId { get; init; } + public string ClientSecret { get; init; } + public string AccessToken { get; init; } + public string RefreshToken { get; init; } +} diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs new file mode 100644 index 0000000..f5d120e --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonData.cs @@ -0,0 +1,134 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class Attributes +{ + [JsonPropertyName("full_name")] + public string FullName { get; set; } + + [JsonPropertyName("is_follower")] + public bool IsFollower { get; set; } + + [JsonPropertyName("last_charge_date")] + public DateTime? LastChargeDate { get; set; } + + [JsonPropertyName("last_charge_status")] + public string LastChargeStatus { get; set; } + + [JsonPropertyName("lifetime_support_cents")] + public int LifetimeSupportCents { get; set; } + + [JsonPropertyName("currently_entitled_amount_cents")] + public int CurrentlyEntitledAmountCents { get; set; } + + [JsonPropertyName("patron_status")] + public string PatronStatus { get; set; } +} + +public sealed class Data +{ + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Address +{ + [JsonPropertyName("data")] + public Data Data { get; set; } +} + +// public sealed class CurrentlyEntitledTiers +// { +// [JsonPropertyName("data")] +// public List Data { get; set; } +// } + +// public sealed class Relationships +// { +// [JsonPropertyName("address")] +// public Address Address { get; set; } +// +// // [JsonPropertyName("currently_entitled_tiers")] +// // public CurrentlyEntitledTiers CurrentlyEntitledTiers { get; set; } +// } + +public sealed class PatreonMembersResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } + + [JsonPropertyName("included")] + public List Included { get; set; } + + [JsonPropertyName("links")] + public PatreonLinks Links { get; set; } +} + +public sealed class PatreonLinks +{ + [JsonPropertyName("next")] + public string Next { get; set; } +} + +public sealed class PatreonUser +{ + [JsonPropertyName("attributes")] + public PatreonUserAttributes Attributes { get; set; } + + [JsonPropertyName("id")] + public string Id { get; set; } + // public string Type { get; set; } +} + +public sealed class PatreonUserAttributes +{ + [JsonPropertyName("social_connections")] + public PatreonSocials SocialConnections { get; set; } +} + +public sealed class PatreonSocials +{ + [JsonPropertyName("discord")] + public DiscordSocial Discord { get; set; } +} + +public sealed class DiscordSocial +{ + [JsonPropertyName("user_id")] + public string UserId { get; set; } +} + +public sealed class PatreonMember +{ + [JsonPropertyName("attributes")] + public Attributes Attributes { get; set; } + + [JsonPropertyName("relationships")] + public Relationships Relationships { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } +} + +public sealed class Relationships +{ + [JsonPropertyName("user")] + public PatreonRelationshipUser User { get; set; } +} + +public sealed class PatreonRelationshipUser +{ + [JsonPropertyName("data")] + public PatreonUserData Data { get; set; } +} + +public sealed class PatreonUserData +{ + [JsonPropertyName("id")] + public string Id { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs new file mode 100644 index 0000000..58656b9 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonMemberData.cs @@ -0,0 +1,33 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonMemberData : ISubscriberData +{ + public string PatreonUserId { get; init; } + public ulong UserId { get; init; } + public DateTime? LastChargeDate { get; init; } + public string LastChargeStatus { get; init; } + public int EntitledToCents { get; init; } + + public string UniquePlatformUserId + => PatreonUserId; + ulong ISubscriberData.UserId + => UserId; + public int Cents + => EntitledToCents; + public DateTime? LastCharge + => LastChargeDate; + public SubscriptionChargeStatus ChargeStatus + => LastChargeStatus switch + { + "Paid" => SubscriptionChargeStatus.Paid, + "Fraud" or "Refunded" => SubscriptionChargeStatus.Refunded, + "Declined" or "Pending" => SubscriptionChargeStatus.Unpaid, + _ => SubscriptionChargeStatus.Other, + }; +} + +public sealed class PatreonPledgeData +{ + +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs new file mode 100644 index 0000000..2b6d154 --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonRefreshData.cs @@ -0,0 +1,22 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Patronage; + +public sealed class PatreonRefreshData +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + [JsonPropertyName("expires_in")] + public long ExpiresIn { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs new file mode 100644 index 0000000..1fd170e --- /dev/null +++ b/src/EllieBot/Modules/Patronage/Patreon/PatreonSubscriptionHandler.cs @@ -0,0 +1,79 @@ +#nullable disable +namespace EllieBot.Modules.Patronage; + +/// +/// Service tasked with handling pledges on patreon +/// +public sealed class PatreonSubscriptionHandler : ISubscriptionHandler, IEService +{ + private readonly IBotCredsProvider _credsProvider; + private readonly PatreonClient _patreonClient; + + public PatreonSubscriptionHandler(IBotCredsProvider credsProvider) + { + _credsProvider = credsProvider; + var botCreds = credsProvider.GetCreds(); + _patreonClient = new PatreonClient(botCreds.Patreon.ClientId, botCreds.Patreon.ClientSecret, botCreds.Patreon.RefreshToken); + } + + public async IAsyncEnumerable> GetPatronsAsync() + { + var botCreds = _credsProvider.GetCreds(); + + if (string.IsNullOrWhiteSpace(botCreds.Patreon.CampaignId) + || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientId) + || string.IsNullOrWhiteSpace(botCreds.Patreon.ClientSecret) + || string.IsNullOrWhiteSpace(botCreds.Patreon.RefreshToken)) + yield break; + + var result = await _patreonClient.RefreshTokenAsync(false); + if (!result.TryPickT0(out _, out var error)) + { + Log.Warning("Unable to refresh patreon token: {ErrorMessage}", error.Value); + yield break; + } + + var patreonCreds = _patreonClient.GetCredentials(); + + _credsProvider.ModifyCredsFile(c => + { + c.Patreon.AccessToken = patreonCreds.AccessToken; + c.Patreon.RefreshToken = patreonCreds.RefreshToken; + }); + + IAsyncEnumerable> data; + try + { + var maybeUserData = await _patreonClient.GetMembersAsync(botCreds.Patreon.CampaignId); + data = maybeUserData.Match( + static userData => userData, + static err => + { + Log.Warning("Error while getting patreon members: {ErrorMessage}", err.Value); + return AsyncEnumerable.Empty>(); + }); + } + catch (Exception ex) + { + Log.Warning(ex, + "Unexpected error while refreshing patreon members: {ErroMessage}", + ex.Message); + + yield break; + } + + var now = DateTime.UtcNow; + var firstOfThisMonth = new DateOnly(now.Year, now.Month, 1); + await foreach (var batch in data) + { + // send only active patrons + var toReturn = batch.Where(x => x.Cents > 0 + && x.LastCharge is { } lc + && lc.ToUniversalTime().ToDateOnly() >= firstOfThisMonth) + .ToArray(); + + if (toReturn.Length > 0) + yield return toReturn; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageCommands.cs b/src/EllieBot/Modules/Patronage/PatronageCommands.cs new file mode 100644 index 0000000..8d0a38a --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageCommands.cs @@ -0,0 +1,156 @@ +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Help; + +public partial class Help +{ + [OnlyPublicBot] + public partial class Patronage : EllieModule + { + private readonly PatronageService _service; + private readonly PatronageConfig _pConf; + + public Patronage(PatronageService service, PatronageConfig pConf) + { + _service = service; + _pConf = pConf; + } + + [Cmd] + [Priority(2)] + public Task Patron() + => InternalPatron(ctx.User); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public Task Patron(IUser user) + => InternalPatron(user); + + [Cmd] + [Priority(0)] + [OwnerOnly] + public async Task PatronMessage(PatronTier tierAndHigher, string message) + { + _ = ctx.Channel.TriggerTypingAsync(); + var result = await _service.SendMessageToPatronsAsync(tierAndHigher, message); + + await Response() + .Confirm(strs.patron_msg_sent( + Format.Code(tierAndHigher.ToString()), + Format.Bold(result.Success.ToString()), + Format.Bold(result.Failed.ToString()))) + .SendAsync(); + } + + // [OwnerOnly] + // public async Task PatronGift(IUser user, int amount) + // { + // // i can't figure out a good way to gift more than one month at the moment. + // + // if (amount < 1) + // return; + // + // var patron = _service.GiftPatronAsync(user, amount); + // + // var eb = _sender.CreateEmbed(); + // + // await Response().Embed(eb.WithDescription($"Added **{days}** days of Patron benefits to {user.Mention}!") + // .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + // .AddField("Amount", $"**{patron.Amount / 100.0f:N1}$**", true) + // .AddField("Until", TimestampTag.FromDateTime(patron.ValidThru.AddDays(1)))).SendAsync(); + // + // + // } + + private async Task InternalPatron(IUser user) + { + if (!_pConf.Data.IsEnabled) + { + await Response().Error(strs.patron_not_enabled).SendAsync(); + return; + } + + var patron = await _service.GetPatronAsync(user.Id); + var quotaStats = await _service.GetUserQuotaStatistic(user.Id); + + var eb = _sender.CreateEmbed() + .WithAuthor(user) + .WithTitle(GetText(strs.patron_info)) + .WithOkColor(); + + if (quotaStats.Commands.Count == 0 + && quotaStats.Groups.Count == 0 + && quotaStats.Modules.Count == 0) + { + eb.WithDescription(GetText(strs.no_quota_found)); + } + else + { + eb.AddField(GetText(strs.tier), Format.Bold(patron.Tier.ToFullName()), true) + .AddField(GetText(strs.pledge), $"**{patron.Amount / 100.0f:N1}$**", true); + + if (patron.Tier != PatronTier.None) + eb.AddField(GetText(strs.expires), + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true); + + eb.AddField(GetText(strs.quotas), "⁣", false); + + if (quotaStats.Commands.Count > 0) + { + var text = GetQuotaList(quotaStats.Commands); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.commands), text, true); + } + + if (quotaStats.Groups.Count > 0) + { + var text = GetQuotaList(quotaStats.Groups); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.groups), text, true); + } + + if (quotaStats.Modules.Count > 0) + { + var text = GetQuotaList(quotaStats.Modules); + if (!string.IsNullOrWhiteSpace(text)) + eb.AddField(GetText(strs.modules), text, true); + } + } + + + try + { + await Response().User(ctx.User).Embed(eb).SendAsync(); + _ = ctx.OkAsync(); + } + catch + { + await Response().Error(strs.cant_dm).SendAsync(); + } + } + + private string GetQuotaList(IReadOnlyDictionary featureQuotaStats) + { + var text = string.Empty; + foreach (var (key, q) in featureQuotaStats) + { + text += $"\n⁣\t`{key}`\n"; + if (q.Hourly != default) + text += $"⁣ ⁣ {GetEmoji(q.Hourly)} {q.Hourly.Cur}/{q.Hourly.Max} per hour\n"; + if (q.Daily != default) + text += $"⁣ ⁣ {GetEmoji(q.Daily)} {q.Daily.Cur}/{q.Daily.Max} per day\n"; + if (q.Monthly != default) + text += $"⁣ ⁣ {GetEmoji(q.Monthly)} {q.Monthly.Cur}/{q.Monthly.Max} per month\n"; + } + + return text; + } + + private string GetEmoji((uint Cur, uint Max) limit) + => limit.Cur < limit.Max + ? "✅" + : "⚠️"; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Patronage/PatronageService.cs b/src/EllieBot/Modules/Patronage/PatronageService.cs new file mode 100644 index 0000000..3d5f62c --- /dev/null +++ b/src/EllieBot/Modules/Patronage/PatronageService.cs @@ -0,0 +1,843 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using OneOf; +using OneOf.Types; +using CommandInfo = Discord.Commands.CommandInfo; + +namespace EllieBot.Modules.Patronage; + +/// +public sealed class PatronageService + : IPatronageService, + IReadyExecutor, + IExecPreCommand, + IEService +{ + public event Func OnNewPatronPayment = static delegate { return Task.CompletedTask; }; + public event Func OnPatronUpdated = static delegate { return Task.CompletedTask; }; + public event Func OnPatronRefunded = static delegate { return Task.CompletedTask; }; + + // this has to run right before the command + public int Priority + => int.MinValue; + + private static readonly PatronTier[] _tiers = Enum.GetValues(); + + private readonly PatronageConfig _pConf; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly ISubscriptionHandler _subsHandler; + + private static readonly TypedKey _quotaKey + = new($"quota:last_hourly_reset"); + + private readonly IBotCache _cache; + private readonly IBotCredsProvider _creds; + private readonly IMessageSenderService _sender; + + public PatronageService( + PatronageConfig pConf, + DbService db, + DiscordSocketClient client, + ISubscriptionHandler subsHandler, + IBotCache cache, + IBotCredsProvider creds, + IMessageSenderService sender) + { + _pConf = pConf; + _db = db; + _client = client; + _subsHandler = subsHandler; + _sender = sender; + _cache = cache; + _creds = creds; + } + + public Task OnReadyAsync() + { + if (_client.ShardId != 0) + return Task.CompletedTask; + + return Task.WhenAll(ResetLoopAsync(), LoadSubscribersLoopAsync()); + } + + private async Task LoadSubscribersLoopAsync() + { + var timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); + while (await timer.WaitForNextTickAsync()) + { + try + { + if (!_pConf.Data.IsEnabled) + continue; + + await foreach (var batch in _subsHandler.GetPatronsAsync()) + { + await ProcesssPatronsAsync(batch); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error processing patrons"); + } + } + } + + public async Task ResetLoopAsync() + { + await Task.Delay(1.Minutes()); + while (true) + { + try + { + if (!_pConf.Data.IsEnabled) + { + await Task.Delay(1.Minutes()); + continue; + } + + var now = DateTime.UtcNow; + var lastRun = DateTime.MinValue; + + var result = await _cache.GetAsync(_quotaKey); + if (result.TryGetValue(out var lastVal) && lastVal != default) + { + lastRun = DateTime.FromBinary(lastVal); + } + + var nowDate = now.ToDateOnly(); + var lastDate = lastRun.ToDateOnly(); + + await using var ctx = _db.GetDbContext(); + + if ((lastDate.Day == 1 || (lastDate.Month != nowDate.Month)) && nowDate.Day > 1) + { + // assumes bot won't be offline for a year + await ctx.GetTable() + .TruncateAsync(); + } + else if (nowDate.DayNumber != lastDate.DayNumber) + { + // day is different, means hour is different. + // reset both hourly and daily quota counts. + await ctx.GetTable() + .UpdateAsync((old) => new() + { + HourlyCount = 0, + DailyCount = 0, + }); + } + else if (now.Hour != lastRun.Hour) // if it's not, just reset hourly quotas + { + await ctx.GetTable() + .UpdateAsync((old) => new() + { + HourlyCount = 0 + }); + } + + // assumes that the code above runs in less than an hour + await _cache.AddAsync(_quotaKey, now.ToBinary()); + } + catch (Exception ex) + { + Log.Error(ex, "Error in quota reset loop. Message: {ErrorMessage}", ex.Message); + } + + await Task.Delay(TimeSpan.FromHours(1).Add(TimeSpan.FromMinutes(1))); + } + } + + private async Task ProcesssPatronsAsync(IReadOnlyCollection subscribersEnum) + { + // process only users who have discord accounts connected + var subscribers = subscribersEnum.Where(x => x.UserId != 0).ToArray(); + + if (subscribers.Length == 0) + return; + + var todayDate = DateTime.UtcNow.Date; + await using var ctx = _db.GetDbContext(); + + // handle paid users + foreach (var subscriber in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Paid)) + { + if (subscriber.LastCharge is null) + continue; + + var lastChargeUtc = subscriber.LastCharge.Value.ToUniversalTime(); + var dateInOneMonth = lastChargeUtc.Date.AddMonths(1); + try + { + var dbPatron = await ctx.GetTable() + .FirstOrDefaultAsync(x + => x.UniquePlatformUserId == subscriber.UniquePlatformUserId); + + if (dbPatron is null) + { + // if the user is not in the database alrady + dbPatron = await ctx.GetTable() + .InsertWithOutputAsync(() => new() + { + UniquePlatformUserId = subscriber.UniquePlatformUserId, + UserId = subscriber.UserId, + AmountCents = subscriber.Cents, + LastCharge = lastChargeUtc, + ValidThru = dateInOneMonth, + }); + + // await tran.CommitAsync(); + + var newPatron = PatronUserToPatron(dbPatron); + _ = SendWelcomeMessage(newPatron); + await OnNewPatronPayment(newPatron); + } + else + { + if (dbPatron.LastCharge.Month < lastChargeUtc.Month + || dbPatron.LastCharge.Year < lastChargeUtc.Year) + { + // user is charged again for this month + // if his sub would end in teh future, extend it by one month. + // if it's not, just add 1 month to the last charge date + var count = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) + .UpdateAsync(old => new() + { + UserId = subscriber.UserId, + AmountCents = subscriber.Cents, + LastCharge = lastChargeUtc, + ValidThru = old.ValidThru >= todayDate + // ? Sql.DateAdd(Sql.DateParts.Month, 1, old.ValidThru).Value + ? old.ValidThru.AddMonths(1) + : dateInOneMonth, + }); + + // this should never happen + if (count == 0) + { + // await tran.RollbackAsync(); + continue; + } + + // await tran.CommitAsync(); + + await OnNewPatronPayment(PatronUserToPatron(dbPatron)); + } + else if (dbPatron.AmountCents != subscriber.Cents // if user changed the amount + || dbPatron.UserId != subscriber.UserId) // if user updated user id) + { + var cents = subscriber.Cents; + // the user updated the pledge or changed the connected discord account + await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == subscriber.UniquePlatformUserId) + .UpdateAsync(old => new() + { + UserId = subscriber.UserId, + AmountCents = cents, + LastCharge = lastChargeUtc, + ValidThru = old.ValidThru, + }); + + var newPatron = dbPatron.Clone(); + newPatron.AmountCents = cents; + newPatron.UserId = subscriber.UserId; + + // idk what's going on but UpdateWithOutputAsync doesn't work properly here + // nor does firstordefault after update. I'm not seeing something obvious + await OnPatronUpdated( + PatronUserToPatron(dbPatron), + PatronUserToPatron(newPatron)); + } + } + } + catch (Exception ex) + { + Log.Error(ex, + "Unexpected error occured while processing rewards for patron {UserId}", + subscriber.UserId); + } + } + + var expiredDate = DateTime.MinValue; + foreach (var patron in subscribers.Where(x => x.ChargeStatus == SubscriptionChargeStatus.Refunded)) + { + // if the subscription is refunded, Disable user's valid thru + var changedCount = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId + && x.ValidThru != expiredDate) + .UpdateAsync(old => new() + { + ValidThru = expiredDate + }); + + if (changedCount == 0) + continue; + + var updated = await ctx.GetTable() + .Where(x => x.UniquePlatformUserId == patron.UniquePlatformUserId) + .FirstAsync(); + + await OnPatronRefunded(PatronUserToPatron(updated)); + } + } + + public async Task ExecPreCommandAsync( + ICommandContext ctx, + string moduleName, + CommandInfo command) + { + var ownerId = ctx.Guild?.OwnerId ?? 0; + + var result = await AttemptRunCommand( + ctx.User.Id, + ownerId: ownerId, + command.Aliases.First().ToLowerInvariant(), + command.Module.Parent == null ? string.Empty : command.Module.GetGroupName().ToLowerInvariant(), + moduleName.ToLowerInvariant() + ); + + return result.Match( + _ => false, + ins => + { + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Insufficient Patron Tier") + .AddField("For", $"{ins.FeatureType}: `{ins.Feature}`", true) + .AddField("Required Tier", + $"[{ins.RequiredTier.ToFullName()}](https://patreon.com/join/elliebot)", + true); + + if (ctx.Guild is null || ctx.Guild?.OwnerId == ctx.User.Id) + eb.WithDescription("You don't have the sufficent Patron Tier to run this command.") + .WithFooter("You can use '.patron' and '.donate' commands for more info"); + else + eb.WithDescription( + "Neither you nor the server owner have the sufficent Patron Tier to run this command.") + .WithFooter("You can use '.patron' and '.donate' commands for more info"); + + _ = ctx.WarningAsync(); + + if (ctx.Guild?.OwnerId == ctx.User.Id) + _ = _sender.Response(ctx) + .Context(ctx) + .Embed(eb) + .SendAsync(); + else + _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); + + return true; + }, + quota => + { + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle("Quota Limit Reached"); + + if (quota.IsOwnQuota || ctx.User.Id == ownerId) + { + eb.WithDescription($"You've reached your quota of `{quota.Quota} {quota.QuotaPeriod.ToFullName()}`") + .WithFooter("You may want to check your quota by using the '.patron' command."); + } + else + { + eb.WithDescription( + $"This server reached the quota of {quota.Quota} `{quota.QuotaPeriod.ToFullName()}`") + .WithFooter("You may contact the server owner about this issue.\n" + + "Alternatively, you can become patron yourself by using the '.donate' command.\n" + + "If you're already a patron, it means you've reached your quota.\n" + + "You can use '.patron' command to check your quota status."); + } + + eb.AddField("For", $"{quota.FeatureType}: `{quota.Feature}`", true) + .AddField("Resets At", quota.ResetsAt.ToShortAndRelativeTimestampTag(), true); + + _ = ctx.WarningAsync(); + + // send the message in the server in case it's the owner + if (ctx.Guild?.OwnerId == ctx.User.Id) + _ = _sender.Response(ctx) + .Embed(eb) + .SendAsync(); + else + _ = _sender.Response(ctx).User(ctx.User).Embed(eb).SendAsync(); + + return true; + }); + } + + private async ValueTask> AttemptRunCommand( + ulong userId, + ulong ownerId, + string commandName, + string groupName, + string moduleName) + { + // try to run as a user + var res = await AttemptRunCommand(userId, commandName, groupName, moduleName, true); + + // if it fails, try to run as an owner + // but only if the command is ran in a server + // and if the owner is not the user + if (!res.IsT0 && ownerId != 0 && ownerId != userId) + res = await AttemptRunCommand(ownerId, commandName, groupName, moduleName, false); + + return res; + } + + /// + /// Returns either the current usage counter if limit wasn't reached, or QuotaLimit if it is. + /// + public async ValueTask> TryIncrementQuotaCounterAsync( + ulong userId, + bool isSelf, + FeatureType featureType, + string featureName, + uint? maybeHourly, + uint? maybeDaily, + uint? maybeMonthly) + { + await using var ctx = _db.GetDbContext(); + + var now = DateTime.UtcNow; + await using var tran = await ctx.Database.BeginTransactionAsync(); + + var userQuotaData = await ctx.GetTable() + .FirstOrDefaultAsyncLinqToDB(x => x.UserId == userId + && x.Feature == featureName) + ?? new PatronQuota(); + + // if hourly exists, if daily exists, etc... + if (maybeHourly is uint hourly && userQuotaData.HourlyCount >= hourly) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerHour, + Quota = hourly, + // quite a neat trick. https://stackoverflow.com/a/5733560 + ResetsAt = now.Date.AddHours(now.Hour + 1), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + if (maybeDaily is uint daily + && userQuotaData.DailyCount >= daily) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerDay, + Quota = daily, + ResetsAt = now.Date.AddDays(1), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + if (maybeMonthly is uint monthly && userQuotaData.MonthlyCount >= monthly) + { + return new QuotaLimit() + { + QuotaPeriod = QuotaPer.PerMonth, + Quota = monthly, + ResetsAt = now.Date.SecondOfNextMonth(), + Feature = featureName, + FeatureType = featureType, + IsOwnQuota = isSelf + }; + } + + await ctx.GetTable() + .InsertOrUpdateAsync(() => new() + { + UserId = userId, + FeatureType = featureType, + Feature = featureName, + DailyCount = 1, + MonthlyCount = 1, + HourlyCount = 1, + }, + (old) => new() + { + HourlyCount = old.HourlyCount + 1, + DailyCount = old.DailyCount + 1, + MonthlyCount = old.MonthlyCount + 1, + }, + () => new() + { + UserId = userId, + FeatureType = featureType, + Feature = featureName, + }); + + await tran.CommitAsync(); + + return (userQuotaData.HourlyCount + 1, userQuotaData.DailyCount + 1, userQuotaData.MonthlyCount + 1); + } + + /// + /// Attempts to add 1 to user's quota for the command, group and module. + /// Input MUST BE lowercase + /// + /// Id of the user who is attempting to run the command + /// Name of the command the user is trying to run + /// Name of the command's group + /// Name of the command's top level module + /// Whether this is check is for the user himself. False if it's someone else's id (owner) + /// Either a succcess (user can run the command) or one of the error values. + private async ValueTask> AttemptRunCommand( + ulong userId, + string commandName, + string groupName, + string moduleName, + bool isSelf) + { + var confData = _pConf.Data; + + if (!confData.IsEnabled) + return default; + + if (_creds.GetCreds().IsOwner(userId)) + return default; + + // get user tier + var patron = await GetPatronAsync(userId); + FeatureType quotaForFeatureType; + + if (confData.Quotas.Commands.TryGetValue(commandName, out var quotaData)) + { + quotaForFeatureType = FeatureType.Command; + } + else if (confData.Quotas.Groups.TryGetValue(groupName, out quotaData)) + { + quotaForFeatureType = FeatureType.Group; + } + else if (confData.Quotas.Modules.TryGetValue(moduleName, out quotaData)) + { + quotaForFeatureType = FeatureType.Module; + } + else + { + return default; + } + + var featureName = quotaForFeatureType switch + { + FeatureType.Command => commandName, + FeatureType.Group => groupName, + FeatureType.Module => moduleName, + _ => throw new ArgumentOutOfRangeException(nameof(quotaForFeatureType)) + }; + + if (!TryGetTierDataOrLower(quotaData, patron.Tier, out var data)) + { + return new InsufficientTier() + { + Feature = featureName, + FeatureType = quotaForFeatureType, + RequiredTier = quotaData.Count == 0 + ? PatronTier.ComingSoon + : quotaData.Keys.First(), + UserTier = patron.Tier, + }; + } + + // no quota limits for this tier + if (data is null) + return default; + + var quotaCheckResult = await TryIncrementQuotaCounterAsync(userId, + isSelf, + quotaForFeatureType, + featureName, + data.TryGetValue(QuotaPer.PerHour, out var hourly) ? hourly : null, + data.TryGetValue(QuotaPer.PerDay, out var daily) ? daily : null, + data.TryGetValue(QuotaPer.PerMonth, out var monthly) ? monthly : null + ); + + return quotaCheckResult.Match>( + _ => new Success(), + x => x); + } + + private bool TryGetTierDataOrLower( + IReadOnlyDictionary data, + PatronTier tier, + out T? o) + { + // check for quotas on this tier + if (data.TryGetValue(tier, out o)) + return true; + + // if there are none, get the quota first tier below this one + // which has quotas specified + for (var i = _tiers.Length - 1; i >= 0; i--) + { + var lowerTier = _tiers[i]; + if (lowerTier < tier && data.TryGetValue(lowerTier, out o)) + return true; + } + + // if there are none, that means the feature is intended + // to be patron-only but the quotas haven't been specified yet + // so it will be marked as "Coming Soon" + o = default; + return false; + } + + public async Task GetPatronAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + + // this can potentially return multiple users if the user + // is subscribed on multiple platforms + // or if there are multiple users on the same platform who connected the same discord account?! + var users = await ctx.GetTable() + .Where(x => x.UserId == userId) + .ToListAsync(); + + // first find all active subscriptions + // and return the one with the highest amount + var maxActive = users.Where(x => !x.ValidThru.IsBeforeToday()).MaxBy(x => x.AmountCents); + if (maxActive is not null) + return PatronUserToPatron(maxActive); + + // if there are no active subs, return the one with the highest amount + + var max = users.MaxBy(x => x.AmountCents); + if (max is null) + return default; // no patron with that name + + return PatronUserToPatron(max); + } + + public async Task GetUserQuotaStatistic(ulong userId) + { + var pConfData = _pConf.Data; + + if (!pConfData.IsEnabled) + return new(); + + var patron = await GetPatronAsync(userId); + + await using var ctx = _db.GetDbContext(); + var allPatronQuotas = await ctx.GetTable() + .Where(x => x.UserId == userId) + .ToListAsync(); + + var allQuotasDict = allPatronQuotas + .GroupBy(static x => x.FeatureType) + .ToDictionary(static x => x.Key, static x => x.ToDictionary(static y => y.Feature)); + + allQuotasDict.TryGetValue(FeatureType.Command, out var data); + var userCommandQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Commands); + + allQuotasDict.TryGetValue(FeatureType.Group, out data); + var userGroupQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Groups); + + allQuotasDict.TryGetValue(FeatureType.Module, out data); + var userModuleQuotaStats = GetFeatureQuotaStats(patron.Tier, data, pConfData.Quotas.Modules); + + return new UserQuotaStats() + { + Tier = patron.Tier, + Commands = userCommandQuotaStats, + Groups = userGroupQuotaStats, + Modules = userModuleQuotaStats, + }; + } + + private IReadOnlyDictionary GetFeatureQuotaStats( + PatronTier patronTier, + IReadOnlyDictionary? allQuotasDict, + Dictionary?>> commands) + { + var userCommandQuotaStats = new Dictionary(); + foreach (var (key, quotaData) in commands) + { + if (TryGetTierDataOrLower(quotaData, patronTier, out var data)) + { + // if data is null that means the quota for the user's tier is unlimited + // no point in returning it? + + if (data is null) + continue; + + var (daily, hourly, monthly) = default((uint, uint, uint)); + // try to get users stats for this feature + // if it fails just leave them at 0 + if (allQuotasDict?.TryGetValue(key, out var quota) ?? false) + (daily, hourly, monthly) = (quota.DailyCount, quota.HourlyCount, quota.MonthlyCount); + + userCommandQuotaStats[key] = new FeatureQuotaStats() + { + Hourly = data.TryGetValue(QuotaPer.PerHour, out var hourD) + ? (hourly, hourD) + : default, + Daily = data.TryGetValue(QuotaPer.PerDay, out var maxD) + ? (daily, maxD) + : default, + Monthly = data.TryGetValue(QuotaPer.PerMonth, out var maxM) + ? (monthly, maxM) + : default, + }; + } + } + + return userCommandQuotaStats; + } + + public async Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue) + { + var conf = _pConf.Data; + + // if patron system is disabled, the quota is just default + if (!conf.IsEnabled) + return new() + { + Name = key.PrettyName, + Quota = defaultValue, + IsPatronLimit = false + }; + + + if (!conf.Quotas.Features.TryGetValue(key.Key, out var data)) + return new() + { + Name = key.PrettyName, + Quota = defaultValue, + IsPatronLimit = false, + }; + + var patron = await GetPatronAsync(userId); + if (!TryGetTierDataOrLower(data, patron.Tier, out var limit)) + return new() + { + Name = key.PrettyName, + Quota = 0, + IsPatronLimit = true, + }; + + return new() + { + Name = key.PrettyName, + Quota = limit, + IsPatronLimit = true + }; + } + + // public async Task GiftPatronAsync(IUser user, int amount) + // { + // if (amount < 1) + // throw new ArgumentOutOfRangeException(nameof(amount)); + // + // + // } + + private Patron PatronUserToPatron(PatronUser user) + => new Patron() + { + UniquePlatformUserId = user.UniquePlatformUserId, + UserId = user.UserId, + Amount = user.AmountCents, + Tier = CalculateTier(user), + PaidAt = user.LastCharge, + ValidThru = user.ValidThru, + }; + + private PatronTier CalculateTier(PatronUser user) + { + if (user.ValidThru.IsBeforeToday()) + return PatronTier.None; + + return user.AmountCents switch + { + >= 10_000 => PatronTier.C, + >= 5000 => PatronTier.L, + >= 2000 => PatronTier.XX, + >= 1000 => PatronTier.X, + >= 500 => PatronTier.V, + >= 100 => PatronTier.I, + _ => PatronTier.None + }; + } + + private async Task SendWelcomeMessage(Patron patron) + { + try + { + var user = (IUser)_client.GetUser(patron.UserId) ?? await _client.Rest.GetUserAsync(patron.UserId); + if (user is null) + return; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("❤️ Thank you for supporting EllieBot! ❤️") + .WithDescription( + "Your donation has been processed and you will receive the rewards shortly.\n" + + "You can visit to see rewards for your tier. 🎉") + .AddField("Tier", Format.Bold(patron.Tier.ToString()), true) + .AddField("Pledge", $"**{patron.Amount / 100.0f:N1}$**", true) + .AddField("Expires", + patron.ValidThru.AddDays(1).ToShortAndRelativeTimestampTag(), + true) + .AddField("Instructions", + """ + *- Within the next **1-2 minutes** you will have all of the benefits of the Tier you've subscribed to.* + *- You can check your benefits on * + *- You can use the `.patron` command in this chat to check your current quota usage for the Patron-only commands* + *- **ALL** of the servers that you **own** will enjoy your Patron benefits.* + *- You can use any of the commands available in your tier on any server (assuming you have sufficient permissions to run those commands)* + *- Any user in any of your servers can use Patron-only commands, but they will spend **your quota**, which is why it's recommended to use Ellie's command cooldown system (.h .cmdcd) or permission system to limit the command usage for your server members.* + *- Permission guide can be found here if you're not familiar with it: * + """, + inline: false) + .WithFooter($"platform id: {patron.UniquePlatformUserId}"); + + await _sender.Response(user).Embed(eb).SendAsync(); + } + catch + { + Log.Warning("Unable to send a \"Welcome\" message to the patron {UserId}", patron.UserId); + } + } + + public async Task<(int Success, int Failed)> SendMessageToPatronsAsync(PatronTier tierAndHigher, string message) + { + await using var ctx = _db.GetDbContext(); + + var patrons = await ctx.GetTable() + .Where(x => x.ValidThru > DateTime.UtcNow) + .ToArrayAsync(); + + var text = SmartText.CreateFrom(message); + + var succ = 0; + var fail = 0; + foreach (var patron in patrons) + { + try + { + var user = await _client.GetUserAsync(patron.UserId); + await _sender.Response(user).Text(text).SendAsync(); + ++succ; + } + catch + { + ++fail; + } + + await Task.Delay(1000); + } + + return (succ, fail); + } + + public PatronConfigData GetConfig() + => _pConf.Data; +} \ No newline at end of file -- 2.43.0 From 78c78353468ff3ccfbd45f73652f4730865e7a8b Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:54:52 +1200 Subject: [PATCH 028/340] Added Permissions module --- .../Blacklist/BlacklistCommands.cs | 154 +++++ .../CleverbotResponseCmdCdTypeReader.cs | 15 + .../CommandCooldown/CmdCdService.cs | 142 +++++ .../CommandCooldown/CmdCdsCommands.cs | 107 ++++ .../Permissions/Filter/FilterCommands.cs | 327 +++++++++++ .../Permissions/Filter/FilterService.cs | 250 ++++++++ .../Filter/ServerFilterSettings.cs | 10 + .../GlobalPermissionCommands.cs | 77 +++ .../GlobalPermissionService.cs | 92 +++ .../Modules/Permissions/PermissionCache.cs | 11 + .../Permissions/PermissionExtensions.cs | 132 +++++ .../Modules/Permissions/Permissions.cs | 544 ++++++++++++++++++ .../Permissions/PermissionsCollection.cs | 74 +++ .../Modules/Permissions/PermissionsService.cs | 187 ++++++ .../Permissions/ResetPermissionsCommands.cs | 37 ++ 15 files changed, 2159 insertions(+) create mode 100644 src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs create mode 100644 src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/FilterService.cs create mode 100644 src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs create mode 100644 src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs create mode 100644 src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionCache.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionExtensions.cs create mode 100644 src/EllieBot/Modules/Permissions/Permissions.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionsCollection.cs create mode 100644 src/EllieBot/Modules/Permissions/PermissionsService.cs create mode 100644 src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs diff --git a/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs new file mode 100644 index 0000000..9db46fb --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Blacklist/BlacklistCommands.cs @@ -0,0 +1,154 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class BlacklistCommands : EllieModule + { + private readonly DiscordSocketClient _client; + + public BlacklistCommands(DiscordSocketClient client) + => _client = client; + + private async Task ListBlacklistInternal(string title, BlacklistType type, int page = 0) + { + ArgumentOutOfRangeException.ThrowIfNegative(page); + + var list = _service.GetBlacklist(); + var allItems = await list.Where(x => x.Type == type) + .Select(i => + { + try + { + return Task.FromResult(i.Type switch + { + BlacklistType.Channel => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetChannel(i.ItemId)?.ToString() + ?? ""), + BlacklistType.User => Format.Code(i.ItemId.ToString()) + + " " + + ((_client.GetUser(i.ItemId)) + ?.ToString() + ?? ""), + BlacklistType.Server => Format.Code(i.ItemId.ToString()) + + " " + + (_client.GetGuild(i.ItemId)?.ToString() ?? ""), + _ => Format.Code(i.ItemId.ToString()) + }); + } + catch + { + Log.Warning("Can't get {BlacklistType} [{BlacklistItemId}]", + i.Type, + i.ItemId); + + return Task.FromResult(Format.Code(i.ItemId.ToString())); + } + }) + .WhenAll(); + + await Response() + .Paginated() + .Items(allItems) + .PageSize(10) + .CurrentPage(page) + .Page((pageItems, _) => + { + if (pageItems.Count == 0) + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(title) + .WithDescription(GetText(strs.empty_page)); + + return _sender.CreateEmbed() + .WithTitle(title) + .WithDescription(allItems.Join('\n')) + .WithOkColor(); + }) + .SendAsync(); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_users), BlacklistType.User, page); + } + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_channels), BlacklistType.Channel, page); + } + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(int page = 1) + { + if (--page < 0) + return Task.CompletedTask; + + return ListBlacklistInternal(GetText(strs.blacklisted_servers), BlacklistType.Server, page); + } + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task UserBlacklist(AddRemove action, IUser usr) + => Blacklist(action, usr.Id, BlacklistType.User); + + [Cmd] + [OwnerOnly] + public Task ChannelBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Channel); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, ulong id) + => Blacklist(action, id, BlacklistType.Server); + + [Cmd] + [OwnerOnly] + public Task ServerBlacklist(AddRemove action, IGuild guild) + => Blacklist(action, guild.Id, BlacklistType.Server); + + private async Task Blacklist(AddRemove action, ulong id, BlacklistType type) + { + if (action == AddRemove.Add) + await _service.Blacklist(type, id); + else + await _service.UnBlacklist(type, id); + + if (action == AddRemove.Add) + { + await Response() + .Confirm(strs.blacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.unblacklisted(Format.Code(type.ToString()), + Format.Code(id.ToString()))) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs new file mode 100644 index 0000000..618ec59 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CleverbotResponseCmdCdTypeReader.cs @@ -0,0 +1,15 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using static EllieBot.Common.TypeReaders.TypeReaderResult; + +namespace EllieBot.Modules.Permissions; + +public class CleverbotResponseCmdCdTypeReader : EllieTypeReader +{ + public override ValueTask> ReadAsync( + ICommandContext ctx, + string input) + => input.ToLowerInvariant() == CleverBotResponseStr.CLEVERBOT_RESPONSE + ? new(FromSuccess(new CleverBotResponseStr())) + : new(FromError(CommandError.ParseFailed, "Not a valid cleverbot")); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs new file mode 100644 index 0000000..f4ea9e8 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdService.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _settings = new(); + + private readonly ConcurrentDictionary<(ulong, string), ConcurrentDictionary> _activeCooldowns = + new(); + + public int Priority => 0; + + public CmdCdService(IBot bot, DbService db) + { + _db = db; + _settings = bot + .AllGuildConfigs + .ToDictionary(x => x.GuildId, x => x.CommandCooldowns + .DistinctBy(x => x.CommandName.ToLowerInvariant()) + .ToDictionary(c => c.CommandName, c => c.Seconds) + .ToConcurrent()) + .ToConcurrent(); + } + + public Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + => TryBlock(context.Guild, context.User, command.Name.ToLowerInvariant()); + + public Task TryBlock(IGuild? guild, IUser user, string commandName) + { + if (guild is null) + return Task.FromResult(false); + + if (!_settings.TryGetValue(guild.Id, out var cooldownSettings)) + return Task.FromResult(false); + + if (!cooldownSettings.TryGetValue(commandName, out var cdSeconds)) + return Task.FromResult(false); + + var cooldowns = _activeCooldowns.GetOrAdd( + (guild.Id, commandName), + static _ => new()); + + // if user is not already on cooldown, add + if (cooldowns.TryAdd(user.Id, DateTime.UtcNow)) + { + return Task.FromResult(false); + } + + // if there is an entry, maybe it expired. Try to check if it expired and don't fail if it did + // - just update + if (cooldowns.TryGetValue(user.Id, out var oldValue)) + { + var diff = DateTime.UtcNow - oldValue; + if (diff.TotalSeconds > cdSeconds) + { + if (cooldowns.TryUpdate(user.Id, DateTime.UtcNow, oldValue)) + return Task.FromResult(false); + } + } + + return Task.FromResult(true); + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + + while (await timer.WaitForNextTickAsync()) + { + // once per hour delete expired entries + foreach (var ((guildId, commandName), dict) in _activeCooldowns) + { + // if this pair no longer has associated config, that means it has been removed. + // remove all cooldowns + if (!_settings.TryGetValue(guildId, out var inner) + || !inner.TryGetValue(commandName, out var cdSeconds)) + { + _activeCooldowns.Remove((guildId, commandName), out _); + continue; + } + + Cleanup(dict, cdSeconds); + } + } + } + + private void Cleanup(ConcurrentDictionary dict, int cdSeconds) + { + var now = DateTime.UtcNow; + foreach (var (key, _) in dict.Where(x => (now - x.Value).TotalSeconds > cdSeconds).ToArray()) + { + dict.TryRemove(key, out _); + } + } + + public void ClearCooldowns(ulong guildId, string cmdName) + { + if (_settings.TryGetValue(guildId, out var dict)) + dict.TryRemove(cmdName, out _); + + _activeCooldowns.TryRemove((guildId, cmdName), out _); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == cmdName); + ctx.SaveChanges(); + } + + public void AddCooldown(ulong guildId, string name, int secs) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(secs); + + var sett = _settings.GetOrAdd(guildId, static _ => new()); + sett[name] = secs; + + // force cleanup + if (_activeCooldowns.TryGetValue((guildId, name), out var dict)) + Cleanup(dict, secs); + + using var ctx = _db.GetDbContext(); + var gc = ctx.GuildConfigsForId(guildId, x => x.Include(x => x.CommandCooldowns)); + gc.CommandCooldowns.RemoveWhere(x => x.CommandName == name); + gc.CommandCooldowns.Add(new() + { + Seconds = secs, + CommandName = name + }); + ctx.SaveChanges(); + } + + public IReadOnlyCollection<(string CommandName, int Seconds)> GetCommandCooldowns(ulong guildId) + { + if (!_settings.TryGetValue(guildId, out var dict)) + return Array.Empty<(string, int)>(); + + return dict.Select(x => (x.Key, x.Value)).ToArray(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs new file mode 100644 index 0000000..e2c1427 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/CommandCooldown/CmdCdsCommands.cs @@ -0,0 +1,107 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.TypeReaders; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class CmdCdsCommands : EllieModule + { + private readonly DbService _db; + private readonly CmdCdService _service; + + public CmdCdsCommands(CmdCdService service, DbService db) + { + _service = service; + _db = db; + } + + private async Task CmdCooldownInternal(string cmdName, int secs) + { + var channel = (ITextChannel)ctx.Channel; + if (secs is < 0 or > 3600) + { + await Response().Error(strs.invalid_second_param_between(0, 3600)).SendAsync(); + return; + } + + var name = cmdName.ToLowerInvariant(); + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.CommandCooldowns)); + + var toDelete = config.CommandCooldowns.FirstOrDefault(cc => cc.CommandName == name); + if (toDelete is not null) + uow.Set().Remove(toDelete); + if (secs != 0) + { + var cc = new CommandCooldown + { + CommandName = name, + Seconds = secs + }; + config.CommandCooldowns.Add(cc); + _service.AddCooldown(channel.Guild.Id, name, secs); + } + + await uow.SaveChangesAsync(); + } + + if (secs == 0) + { + _service.ClearCooldowns(ctx.Guild.Id, cmdName); + await Response().Confirm(strs.cmdcd_cleared(Format.Bold(name))).SendAsync(); + } + else + await Response().Confirm(strs.cmdcd_add(Format.Bold(name), Format.Bold(secs.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task CmdCooldown(CleverBotResponseStr command, int secs) + => CmdCooldownInternal(CleverBotResponseStr.CLEVERBOT_RESPONSE, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task CmdCooldown(CommandOrExprInfo command, int secs) + => CmdCooldownInternal(command.Name, secs); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllCmdCooldowns(int page = 1) + { + if (--page < 0) + return; + + var localSet = _service.GetCommandCooldowns(ctx.Guild.Id); + + if (!localSet.Any()) + await Response().Confirm(strs.cmdcd_none).SendAsync(); + else + { + await Response() + .Paginated() + .Items(localSet) + .PageSize(15) + .CurrentPage(page) + .Page((items, _) => + { + var output = items.Select(x => + $"{Format.Code(x.CommandName)}: {x.Seconds}s"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithDescription(output.Join("\n")); + }) + .SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs new file mode 100644 index 0000000..cdd3cad --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterCommands.cs @@ -0,0 +1,327 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class FilterCommands : EllieModule + { + private readonly DbService _db; + + public FilterCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task FwClear() + { + _service.ClearFilteredWords(ctx.Guild.Id); + await Response().Confirm(strs.fw_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterList() + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle("Server filter settings"); + + var config = await _service.GetFilterSettings(ctx.Guild.Id); + + string GetEnabledEmoji(bool value) + => value ? "\\🟢" : "\\🔴"; + + async Task GetChannelListAsync(IReadOnlyCollection channels) + { + var toReturn = (await channels + .Select(async cid => + { + var ch = await ctx.Guild.GetChannelAsync(cid); + return ch is null + ? $"{cid} *missing*" + : $"<#{cid}>"; + }) + .WhenAll()) + .Join('\n'); + + if (string.IsNullOrWhiteSpace(toReturn)) + return GetText(strs.no_channel_found); + + return toReturn; + } + + embed.AddField($"{GetEnabledEmoji(config.FilterLinksEnabled)} Filter Links", + await GetChannelListAsync(config.FilterLinksChannels)); + + embed.AddField($"{GetEnabledEmoji(config.FilterInvitesEnabled)} Filter Invites", + await GetChannelListAsync(config.FilterInvitesChannels)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterInvites = !config.FilterInvites; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.InviteFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_on).SendAsync(); + } + else + { + _service.InviteFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.invite_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterInv() + { + var channel = (ITextChannel)ctx.Channel; + + FilterChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, + set => set.Include(gc => gc.FilterInvitesChannelIds)); + var match = new FilterChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterInvitesChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterInvitesChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.InviteFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.invite_filter_channel_on).SendAsync(); + } + else + { + _service.InviteFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.invite_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterLinks = !config.FilterLinks; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.LinkFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_on).SendAsync(); + } + else + { + _service.LinkFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.link_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterLin() + { + var channel = (ITextChannel)ctx.Channel; + + FilterLinksChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterLinksChannelIds)); + var match = new FilterLinksChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterLinksChannelIds.FirstOrDefault(fc => fc.Equals(match)); + + if (removed is null) + config.FilterLinksChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.LinkFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.link_filter_channel_on).SendAsync(); + } + else + { + _service.LinkFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.link_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + bool enabled; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set); + enabled = config.FilterWords = !config.FilterWords; + await uow.SaveChangesAsync(); + } + + if (enabled) + { + _service.WordFilteringServers.Add(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_on).SendAsync(); + } + else + { + _service.WordFilteringServers.TryRemove(channel.Guild.Id); + await Response().Confirm(strs.word_filter_server_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlFilterWords() + { + var channel = (ITextChannel)ctx.Channel; + + FilterWordsChannelId removed; + await using (var uow = _db.GetDbContext()) + { + var config = + uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilterWordsChannelIds)); + + var match = new FilterWordsChannelId + { + ChannelId = channel.Id + }; + removed = config.FilterWordsChannelIds.FirstOrDefault(fc => fc.Equals(match)); + if (removed is null) + config.FilterWordsChannelIds.Add(match); + else + uow.Remove(removed); + await uow.SaveChangesAsync(); + } + + if (removed is null) + { + _service.WordFilteringChannels.Add(channel.Id); + await Response().Confirm(strs.word_filter_channel_on).SendAsync(); + } + else + { + _service.WordFilteringChannels.TryRemove(channel.Id); + await Response().Confirm(strs.word_filter_channel_off).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task FilterWord([Leftover] string word) + { + var channel = (ITextChannel)ctx.Channel; + + word = word?.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(word)) + return; + + FilteredWord removed; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(channel.Guild.Id, set => set.Include(gc => gc.FilteredWords)); + + removed = config.FilteredWords.FirstOrDefault(fw => fw.Word.Trim().ToLowerInvariant() == word); + + if (removed is null) + { + config.FilteredWords.Add(new() + { + Word = word + }); + } + else + uow.Remove(removed); + + await uow.SaveChangesAsync(); + } + + var filteredWords = + _service.ServerFilteredWords.GetOrAdd(channel.Guild.Id, new ConcurrentHashSet()); + + if (removed is null) + { + filteredWords.Add(word); + await Response().Confirm(strs.filter_word_add(Format.Code(word))).SendAsync(); + } + else + { + filteredWords.TryRemove(word); + await Response().Confirm(strs.filter_word_remove(Format.Code(word))).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task LstFilterWords(int page = 1) + { + page--; + if (page < 0) + return; + + var channel = (ITextChannel)ctx.Channel; + + _service.ServerFilteredWords.TryGetValue(channel.Guild.Id, out var fwHash); + + var fws = fwHash.ToArray(); + + await Response() + .Paginated() + .Items(fws) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => _sender.CreateEmbed() + .WithTitle(GetText(strs.filter_word_list)) + .WithDescription(string.Join("\n", items)) + .WithOkColor()) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/FilterService.cs b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs new file mode 100644 index 0000000..9dfe01e --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/FilterService.cs @@ -0,0 +1,250 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public sealed class FilterService : IExecOnMessage +{ + public ConcurrentHashSet InviteFilteringChannels { get; } + public ConcurrentHashSet InviteFilteringServers { get; } + + //serverid, filteredwords + public ConcurrentDictionary> ServerFilteredWords { get; } + + public ConcurrentHashSet WordFilteringChannels { get; } + public ConcurrentHashSet WordFilteringServers { get; } + + public ConcurrentHashSet LinkFilteringChannels { get; } + public ConcurrentHashSet LinkFilteringServers { get; } + + public int Priority + => int.MaxValue - 1; + + private readonly DbService _db; + + public FilterService(DiscordSocketClient client, DbService db) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var configs = uow.Set() + .AsQueryable() + .Include(x => x.FilteredWords) + .Include(x => x.FilterLinksChannelIds) + .Include(x => x.FilterWordsChannelIds) + .Include(x => x.FilterInvitesChannelIds) + .Where(gc => ids.Contains(gc.GuildId)) + .ToList(); + + InviteFilteringServers = new(configs.Where(gc => gc.FilterInvites).Select(gc => gc.GuildId)); + InviteFilteringChannels = + new(configs.SelectMany(gc => gc.FilterInvitesChannelIds.Select(fci => fci.ChannelId))); + + LinkFilteringServers = new(configs.Where(gc => gc.FilterLinks).Select(gc => gc.GuildId)); + LinkFilteringChannels = + new(configs.SelectMany(gc => gc.FilterLinksChannelIds.Select(fci => fci.ChannelId))); + + var dict = configs.ToDictionary(gc => gc.GuildId, + gc => new ConcurrentHashSet(gc.FilteredWords.Select(fw => fw.Word).Distinct())); + + ServerFilteredWords = new(dict); + + var serverFiltering = configs.Where(gc => gc.FilterWords); + WordFilteringServers = new(serverFiltering.Select(gc => gc.GuildId)); + WordFilteringChannels = + new(configs.SelectMany(gc => gc.FilterWordsChannelIds.Select(fwci => fwci.ChannelId))); + } + + client.MessageUpdated += (oldData, newMsg, channel) => + { + _ = Task.Run(() => + { + var guild = (channel as ITextChannel)?.Guild; + + if (guild is null || newMsg is not IUserMessage usrMsg) + return Task.CompletedTask; + + return ExecOnMessageAsync(guild, usrMsg); + }); + return Task.CompletedTask; + }; + } + + public ConcurrentHashSet FilteredWordsForChannel(ulong channelId, ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringChannels.Contains(channelId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public void ClearFilteredWords(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set.Include(x => x.FilteredWords).Include(x => x.FilterWordsChannelIds)); + + WordFilteringServers.TryRemove(guildId); + ServerFilteredWords.TryRemove(guildId, out _); + + foreach (var c in gc.FilterWordsChannelIds) + WordFilteringChannels.TryRemove(c.ChannelId); + + gc.FilterWords = false; + gc.FilteredWords.Clear(); + gc.FilterWordsChannelIds.Clear(); + + uow.SaveChanges(); + } + + public ConcurrentHashSet FilteredWordsForServer(ulong guildId) + { + var words = new ConcurrentHashSet(); + if (WordFilteringServers.Contains(guildId)) + ServerFilteredWords.TryGetValue(guildId, out words); + return words; + } + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (msg.Author is not IGuildUser gu || gu.GuildPermissions.Administrator) + return false; + + var results = await Task.WhenAll(FilterInvites(guild, msg), FilterWords(guild, msg), FilterLinks(guild, msg)); + + return results.Any(x => x); + } + + private async Task FilterWords(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + var filteredChannelWords = + FilteredWordsForChannel(usrMsg.Channel.Id, guild.Id) ?? new ConcurrentHashSet(); + var filteredServerWords = FilteredWordsForServer(guild.Id) ?? new ConcurrentHashSet(); + var wordsInMessage = usrMsg.Content.ToLowerInvariant().Split(' '); + if (filteredChannelWords.Count != 0 || filteredServerWords.Count != 0) + { + foreach (var word in wordsInMessage) + { + if (filteredChannelWords.Contains(word) || filteredServerWords.Contains(word)) + { + Log.Information("User {UserName} [{UserId}] used a filtered word in {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter words in channel with id {Id}", + usrMsg.Channel.Id); + } + + return true; + } + } + } + + return false; + } + + private async Task FilterInvites(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((InviteFilteringChannels.Contains(usrMsg.Channel.Id) || InviteFilteringServers.Contains(guild.Id)) + && usrMsg.Content.IsDiscordInvite()) + { + Log.Information("User {UserName} [{UserId}] sent a filtered invite to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, + "I do not have permission to filter invites in channel with id {Id}", + usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + private async Task FilterLinks(IGuild guild, IUserMessage usrMsg) + { + if (guild is null) + return false; + if (usrMsg is null) + return false; + + // if user has manage messages perm, don't filter + if (usrMsg.Channel is ITextChannel ch && usrMsg.Author is IGuildUser gu && gu.GetPermissions(ch).ManageMessages) + return false; + + if ((LinkFilteringChannels.Contains(usrMsg.Channel.Id) || LinkFilteringServers.Contains(guild.Id)) + && usrMsg.Content.TryGetUrlPath(out _)) + { + Log.Information("User {UserName} [{UserId}] sent a filtered link to {ChannelId} channel", + usrMsg.Author.ToString(), + usrMsg.Author.Id, + usrMsg.Channel.Id); + + try + { + await usrMsg.DeleteAsync(); + return true; + } + catch (HttpException ex) + { + Log.Warning(ex, "I do not have permission to filter links in channel with id {Id}", usrMsg.Channel.Id); + return true; + } + } + + return false; + } + + public async Task GetFilterSettings(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, + set => set + .Include(x => x.FilterInvitesChannelIds) + .Include(x => x.FilterLinksChannelIds)); + + return new() + { + FilterInvitesChannels = gc.FilterInvitesChannelIds.Map(x => x.ChannelId), + FilterLinksChannels = gc.FilterLinksChannelIds.Map(x => x.ChannelId), + FilterInvitesEnabled = gc.FilterInvites, + FilterLinksEnabled = gc.FilterLinks, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs new file mode 100644 index 0000000..bf8454b --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Filter/ServerFilterSettings.cs @@ -0,0 +1,10 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Services; + +public readonly struct ServerFilterSettings +{ + public bool FilterInvitesEnabled { get; init; } + public bool FilterLinksEnabled { get; init; } + public IReadOnlyCollection FilterInvitesChannels { get; init; } + public IReadOnlyCollection FilterLinksChannels { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs new file mode 100644 index 0000000..d3abefd --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionCommands.cs @@ -0,0 +1,77 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class GlobalPermissionCommands : EllieModule + { + private readonly GlobalPermissionService _service; + private readonly DbService _db; + + public GlobalPermissionCommands(GlobalPermissionService service, DbService db) + { + _service = service; + _db = db; + } + + [Cmd] + [OwnerOnly] + public async Task GlobalPermList() + { + var blockedModule = _service.BlockedModules; + var blockedCommands = _service.BlockedCommands; + if (!blockedModule.Any() && !blockedCommands.Any()) + { + await Response().Error(strs.lgp_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (blockedModule.Any()) + embed.AddField(GetText(strs.blocked_modules), string.Join("\n", _service.BlockedModules)); + + if (blockedCommands.Any()) + embed.AddField(GetText(strs.blocked_commands), string.Join("\n", _service.BlockedCommands)); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalModule(ModuleOrExpr module) + { + var moduleName = module.Name.ToLowerInvariant(); + + var added = _service.ToggleModule(moduleName); + + if (added) + { + await Response().Confirm(strs.gmod_add(Format.Bold(module.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gmod_remove(Format.Bold(module.Name))).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task GlobalCommand(CommandOrExprInfo cmd) + { + var commandName = cmd.Name.ToLowerInvariant(); + var added = _service.ToggleCommand(commandName); + + if (added) + { + await Response().Confirm(strs.gcmd_add(Format.Bold(cmd.Name))).SendAsync(); + return; + } + + await Response().Confirm(strs.gcmd_remove(Format.Bold(cmd.Name))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs new file mode 100644 index 0000000..00dfd78 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/GlobalPermissions/GlobalPermissionService.cs @@ -0,0 +1,92 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Permissions.Services; + +public class GlobalPermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + public HashSet BlockedCommands + => _bss.Data.Blocked.Commands; + + public HashSet BlockedModules + => _bss.Data.Blocked.Modules; + + private readonly BotConfigService _bss; + + public GlobalPermissionService(BotConfigService bss) + => _bss = bss; + + + public Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var settings = _bss.Data; + var commandName = command.Name.ToLowerInvariant(); + + if (commandName != "resetglobalperms" + && (settings.Blocked.Commands.Contains(commandName) + || settings.Blocked.Modules.Contains(moduleName.ToLowerInvariant()))) + return Task.FromResult(true); + + return Task.FromResult(false); + } + + /// + /// Toggles module blacklist + /// + /// Lowercase module name + /// Whether the module is added + public bool ToggleModule(string moduleName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Modules.Add(moduleName)) + added = true; + else + { + bs.Blocked.Modules.Remove(moduleName); + added = false; + } + }); + + return added; + } + + /// + /// Toggles command blacklist + /// + /// Lowercase command name + /// Whether the command is added + public bool ToggleCommand(string commandName) + { + var added = false; + _bss.ModifyConfig(bs => + { + if (bs.Blocked.Commands.Add(commandName)) + added = true; + else + { + bs.Blocked.Commands.Remove(commandName); + added = false; + } + }); + + return added; + } + + /// + /// Resets all global permissions + /// + public Task Reset() + { + _bss.ModifyConfig(bs => + { + bs.Blocked.Commands.Clear(); + bs.Blocked.Modules.Clear(); + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionCache.cs b/src/EllieBot/Modules/Permissions/PermissionCache.cs new file mode 100644 index 0000000..47b5983 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionCache.cs @@ -0,0 +1,11 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionCache +{ + public string PermRole { get; set; } + public bool Verbose { get; set; } = true; + public PermissionsCollection Permissions { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionExtensions.cs b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs new file mode 100644 index 0000000..04eee4e --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionExtensions.cs @@ -0,0 +1,132 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Common; + +public static class PermissionExtensions +{ + public static bool CheckPermissions( + this IEnumerable permsEnumerable, + IUser user, + IMessageChannel message, + string commandName, + string moduleName, + out int permIndex) + { + var perms = permsEnumerable as List ?? permsEnumerable.ToList(); + + for (var i = perms.Count - 1; i >= 0; i--) + { + var perm = perms[i]; + + var result = perm.CheckPermission(user, message, commandName, moduleName); + + if (result is null) + continue; + permIndex = i; + return result.Value; + } + + permIndex = -1; //defaut behaviour + return true; + } + + //null = not applicable + //true = applicable, allowed + //false = applicable, not allowed + public static bool? CheckPermission( + this Permissionv2 perm, + IUser user, + IMessageChannel channel, + string commandName, + string moduleName) + { + if (!((perm.SecondaryTarget == SecondaryPermissionType.Command + && string.Equals(perm.SecondaryTargetName, commandName, StringComparison.InvariantCultureIgnoreCase)) + || (perm.SecondaryTarget == SecondaryPermissionType.Module + && string.Equals(perm.SecondaryTargetName, moduleName, StringComparison.InvariantCultureIgnoreCase)) + || perm.SecondaryTarget == SecondaryPermissionType.AllModules)) + return null; + + var guildUser = user as IGuildUser; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + if (perm.PrimaryTargetId == user.Id) + return perm.State; + break; + case PrimaryPermissionType.Channel: + if (perm.PrimaryTargetId == channel.Id) + return perm.State; + break; + case PrimaryPermissionType.Role: + if (guildUser is null) + break; + if (guildUser.RoleIds.Contains(perm.PrimaryTargetId)) + return perm.State; + break; + case PrimaryPermissionType.Server: + if (guildUser is null) + break; + return perm.State; + } + + return null; + } + + public static string GetCommand(this Permissionv2 perm, string prefix, SocketGuild guild = null) + { + var com = string.Empty; + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += "u"; + break; + case PrimaryPermissionType.Channel: + com += "c"; + break; + case PrimaryPermissionType.Role: + com += "r"; + break; + case PrimaryPermissionType.Server: + com += "s"; + break; + } + + switch (perm.SecondaryTarget) + { + case SecondaryPermissionType.Module: + com += "m"; + break; + case SecondaryPermissionType.Command: + com += "c"; + break; + case SecondaryPermissionType.AllModules: + com = "a" + com + "m"; + break; + } + + var secName = perm.SecondaryTarget == SecondaryPermissionType.Command && !perm.IsCustomCommand + ? prefix + perm.SecondaryTargetName + : perm.SecondaryTargetName; + com += " " + (perm.SecondaryTargetName != "*" ? secName + " " : "") + (perm.State ? "enable" : "disable") + " "; + + switch (perm.PrimaryTarget) + { + case PrimaryPermissionType.User: + com += guild?.GetUser(perm.PrimaryTargetId)?.ToString() ?? $"<@{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Channel: + com += $"<#{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Role: + com += guild?.GetRole(perm.PrimaryTargetId)?.ToString() ?? $"<@&{perm.PrimaryTargetId}>"; + break; + case PrimaryPermissionType.Server: + break; + } + + return prefix + com; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/Permissions.cs b/src/EllieBot/Modules/Permissions/Permissions.cs new file mode 100644 index 0000000..90de56b --- /dev/null +++ b/src/EllieBot/Modules/Permissions/Permissions.cs @@ -0,0 +1,544 @@ +#nullable disable +using EllieBot.Common.TypeReaders; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Modules.Permissions.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions : EllieModule +{ + public enum Reset { Reset } + + private readonly DbService _db; + + public Permissions(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Verbose(PermissionAction action = null) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + if (action is null) + action = new(!config.VerbosePermissions); // New behaviour, can toggle. + config.VerbosePermissions = action.Value; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + if (action.Value) + await Response().Confirm(strs.verbose_true).SendAsync(); + else + await Response().Confirm(strs.verbose_false).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public async Task PermRole([Leftover] IRole role = null) + { + if (role is not null && role == role.Guild.EveryoneRole) + return; + + if (role is null) + { + var cache = _service.GetCacheFor(ctx.Guild.Id); + if (!ulong.TryParse(cache.PermRole, out var roleId) + || (role = ((SocketGuild)ctx.Guild).GetRole(roleId)) is null) + await Response().Confirm(strs.permrole_not_set).SendAsync(); + else + await Response().Confirm(strs.permrole(Format.Bold(role.ToString()))).SendAsync(); + return; + } + + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = role.Id.ToString(); + uow.SaveChanges(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_changed(Format.Bold(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(1)] + public async Task PermRole(Reset _) + { + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + config.PermissionRole = null; + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response().Confirm(strs.permrole_reset).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ListPerms(int page = 1) + { + if (page < 1) + return; + + IList perms; + + if (_service.Cache.TryGetValue(ctx.Guild.Id, out var permCache)) + perms = permCache.Permissions.Source.ToList(); + else + perms = Permissionv2.GetDefaultPermlist; + + var startPos = 20 * (page - 1); + var toSend = Format.Bold(GetText(strs.page(page))) + + "\n\n" + + string.Join("\n", + perms.Reverse() + .Skip(startPos) + .Take(20) + .Select(p => + { + var str = + $"`{p.Index + 1}.` {Format.Bold(p.GetCommand(prefix, (SocketGuild)ctx.Guild))}"; + if (p.Index == 0) + str += $" [{GetText(strs.uneditable)}]"; + return str; + })); + + await Response().Confirm(toSend).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RemovePerm(int index) + { + index -= 1; + if (index < 0) + return; + try + { + Permissionv2 p; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + p = permsCol[index]; + permsCol.RemoveAt(index); + uow.Remove(p); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.removed(index + 1, + Format.Code(p.GetCommand(prefix, (SocketGuild)ctx.Guild)))) + .SendAsync(); + } + catch (IndexOutOfRangeException) + { + await Response().Error(strs.perm_out_of_range).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task MovePerm(int from, int to) + { + from -= 1; + to -= 1; + if (!(from == to || from < 0 || to < 0)) + { + try + { + Permissionv2 fromPerm; + await using (var uow = _db.GetDbContext()) + { + var config = uow.GcWithPermissionsFor(ctx.Guild.Id); + var permsCol = new PermissionsCollection(config.Permissions); + + var fromFound = from < permsCol.Count; + var toFound = to < permsCol.Count; + + if (!fromFound) + { + await Response().Error(strs.perm_not_found(++from)).SendAsync(); + return; + } + + if (!toFound) + { + await Response().Error(strs.perm_not_found(++to)).SendAsync(); + return; + } + + fromPerm = permsCol[from]; + + permsCol.RemoveAt(from); + permsCol.Insert(to, fromPerm); + await uow.SaveChangesAsync(); + _service.UpdateCache(config); + } + + await Response() + .Confirm(strs.moved_permission( + Format.Code(fromPerm.GetCommand(prefix, (SocketGuild)ctx.Guild)), + ++from, + ++to)) + .SendAsync(); + + return; + } + catch (Exception e) when (e is ArgumentOutOfRangeException or IndexOutOfRangeException) + { + } + } + + await Response().Confirm(strs.perm_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrCmd(CommandOrExprInfo command, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(command.Name), GetText(strs.of_command))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task SrvrMdl(ModuleOrExpr module, PermissionAction action) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.sx_enable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + else + await Response().Confirm(strs.sx_disable(Format.Code(module.Name), GetText(strs.of_module))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UsrMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IGuildUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.ux_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.ux_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(user.ToString()))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleMdl(ModuleOrExpr module, PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + + if (action.Value) + { + await Response() + .Confirm(strs.rx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.rx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(role.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlCmd(CommandOrExprInfo command, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Command, + SecondaryTargetName = command.Name.ToLowerInvariant(), + State = action.Value, + IsCustomCommand = command.IsCustom + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(command.Name), + GetText(strs.of_command), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChnlMdl(ModuleOrExpr module, PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.Module, + SecondaryTargetName = module.Name.ToLowerInvariant(), + State = action.Value + }); + + if (action.Value) + { + await Response() + .Confirm(strs.cx_enable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + else + { + await Response() + .Confirm(strs.cx_disable(Format.Code(module.Name), + GetText(strs.of_module), + Format.Code(chnl.Name))) + .SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllChnlMdls(PermissionAction action, [Leftover] ITextChannel chnl) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Channel, + PrimaryTargetId = chnl.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.acm_enable(Format.Code(chnl.Name))).SendAsync(); + else + await Response().Confirm(strs.acm_disable(Format.Code(chnl.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllRoleMdls(PermissionAction action, [Leftover] IRole role) + { + if (role == role.Guild.EveryoneRole) + return; + + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Role, + PrimaryTargetId = role.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.arm_enable(Format.Code(role.Name))).SendAsync(); + else + await Response().Confirm(strs.arm_disable(Format.Code(role.Name))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllUsrMdls(PermissionAction action, [Leftover] IUser user) + { + await _service.AddPermissions(ctx.Guild.Id, + new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = user.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }); + + if (action.Value) + await Response().Confirm(strs.aum_enable(Format.Code(user.ToString()))).SendAsync(); + else + await Response().Confirm(strs.aum_disable(Format.Code(user.ToString()))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AllSrvrMdls(PermissionAction action) + { + var newPerm = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.Server, + PrimaryTargetId = 0, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = action.Value + }; + + var allowUser = new Permissionv2 + { + PrimaryTarget = PrimaryPermissionType.User, + PrimaryTargetId = ctx.User.Id, + SecondaryTarget = SecondaryPermissionType.AllModules, + SecondaryTargetName = "*", + State = true + }; + + await _service.AddPermissions(ctx.Guild.Id, newPerm, allowUser); + + if (action.Value) + await Response().Confirm(strs.asm_enable).SendAsync(); + else + await Response().Confirm(strs.asm_disable).SendAsync(); + } +} diff --git a/src/EllieBot/Modules/Permissions/PermissionsCollection.cs b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs new file mode 100644 index 0000000..c2526ea --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsCollection.cs @@ -0,0 +1,74 @@ +#nullable disable +namespace EllieBot.Modules.Permissions.Common; + +public class PermissionsCollection : IndexedCollection + where T : class, IIndexed +{ + public override T this[int index] + { + get => Source[index]; + set + { + lock (_localLocker) + { + if (index == 0) // can't set first element. It's always allow all + throw new IndexOutOfRangeException(nameof(index)); + base[index] = value; + } + } + } + + private readonly object _localLocker = new(); + + public PermissionsCollection(IEnumerable source) + : base(source) + { + } + + public static implicit operator List(PermissionsCollection x) + => x.Source; + + public override void Clear() + { + lock (_localLocker) + { + var first = Source[0]; + base.Clear(); + Source[0] = first; + } + } + + public override bool Remove(T item) + { + bool removed; + lock (_localLocker) + { + if (Source.IndexOf(item) == 0) + throw new ArgumentException("You can't remove first permsission (allow all)"); + removed = base.Remove(item); + } + + return removed; + } + + public override void Insert(int index, T item) + { + lock (_localLocker) + { + if (index == 0) // can't insert on first place. Last item is always allow all. + throw new IndexOutOfRangeException(nameof(index)); + base.Insert(index, item); + } + } + + public override void RemoveAt(int index) + { + lock (_localLocker) + { + if (index == 0) // you can't remove first permission (allow all) + throw new IndexOutOfRangeException(nameof(index)); + + base.RemoveAt(index); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/PermissionsService.cs b/src/EllieBot/Modules/Permissions/PermissionsService.cs new file mode 100644 index 0000000..7bca184 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/PermissionsService.cs @@ -0,0 +1,187 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Modules.Permissions.Common; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Permissions.Services; + +public class PermissionService : IExecPreCommand, IEService +{ + public int Priority { get; } = 0; + + //guildid, root permission + public ConcurrentDictionary Cache { get; } = new(); + + private readonly DbService _db; + private readonly CommandHandler _cmd; + private readonly IBotStrings _strings; + private readonly IMessageSenderService _sender; + + public PermissionService( + DiscordSocketClient client, + DbService db, + CommandHandler cmd, + IBotStrings strings, + IMessageSenderService sender) + { + _db = db; + _cmd = cmd; + _strings = strings; + _sender = sender; + + using var uow = _db.GetDbContext(); + foreach (var x in uow.Set().PermissionsForAll(client.Guilds.ToArray().Select(x => x.Id).ToList())) + { + Cache.TryAdd(x.GuildId, + new() + { + Verbose = x.VerbosePermissions, + PermRole = x.PermissionRole, + Permissions = new(x.Permissions) + }); + } + } + + public PermissionCache GetCacheFor(ulong guildId) + { + if (!Cache.TryGetValue(guildId, out var pc)) + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.Permissions)); + UpdateCache(config); + } + + Cache.TryGetValue(guildId, out pc); + if (pc is null) + throw new("Cache is null."); + } + + return pc; + } + + public async Task AddPermissions(ulong guildId, params Permissionv2[] perms) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + //var orderedPerms = new PermissionsCollection(config.Permissions); + var max = config.Permissions.Max(x => x.Index); //have to set its index to be the highest + foreach (var perm in perms) + { + perm.Index = ++max; + config.Permissions.Add(perm); + } + + await uow.SaveChangesAsync(); + UpdateCache(config); + } + + public void UpdateCache(GuildConfig config) + => Cache.AddOrUpdate(config.GuildId, + new PermissionCache + { + Permissions = new(config.Permissions), + PermRole = config.PermissionRole, + Verbose = config.VerbosePermissions + }, + (_, old) => + { + old.Permissions = new(config.Permissions); + old.PermRole = config.PermissionRole; + old.Verbose = config.VerbosePermissions; + return old; + }); + + public async Task ExecPreCommandAsync(ICommandContext ctx, string moduleName, CommandInfo command) + { + var guild = ctx.Guild; + var msg = ctx.Message; + var user = ctx.User; + var channel = ctx.Channel; + var commandName = command.Name.ToLowerInvariant(); + + if (guild is null) + return false; + + var resetCommand = commandName == "resetperms"; + + var pc = GetCacheFor(guild.Id); + if (!resetCommand + && !pc.Permissions.CheckPermissions(msg.Author, msg.Channel, commandName, moduleName, out var index)) + { + if (pc.Verbose) + { + try + { + await _sender.Response(channel) + .Error(_strings.GetText(strs.perm_prevent(index + 1, + Format.Bold(pc.Permissions[index] + .GetCommand(_cmd.GetPrefix(guild), (SocketGuild)guild))), + guild.Id)) + .SendAsync(); + } + catch + { + } + } + + return true; + } + + + if (moduleName == nameof(Permissions)) + { + if (user is not IGuildUser guildUser) + return true; + + if (guildUser.GuildPermissions.Administrator) + return false; + + var permRole = pc.PermRole; + if (!ulong.TryParse(permRole, out var rid)) + rid = 0; + string returnMsg; + IRole role; + if (string.IsNullOrWhiteSpace(permRole) || (role = guild.GetRole(rid)) is null) + { + returnMsg = "You need Admin permissions in order to use permission commands."; + if (pc.Verbose) + { + try + { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + if (!guildUser.RoleIds.Contains(rid)) + { + returnMsg = $"You need the {Format.Bold(role.Name)} role in order to use permission commands."; + if (pc.Verbose) + { + try + { await _sender.Response(channel).Error(returnMsg).SendAsync(); } + catch { } + } + + return true; + } + + return false; + } + + return false; + } + + public async Task Reset(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var config = uow.GcWithPermissionsFor(guildId); + config.Permissions = Permissionv2.GetDefaultPermlist; + await uow.SaveChangesAsync(); + UpdateCache(config); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs new file mode 100644 index 0000000..4193337 --- /dev/null +++ b/src/EllieBot/Modules/Permissions/ResetPermissionsCommands.cs @@ -0,0 +1,37 @@ +#nullable disable +using EllieBot.Modules.Permissions.Services; + +namespace EllieBot.Modules.Permissions; + +public partial class Permissions +{ + [Group] + public partial class ResetPermissionsCommands : EllieModule + { + private readonly GlobalPermissionService _gps; + private readonly PermissionService _perms; + + public ResetPermissionsCommands(GlobalPermissionService gps, PermissionService perms) + { + _gps = gps; + _perms = perms; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task ResetPerms() + { + await _perms.Reset(ctx.Guild.Id); + await Response().Confirm(strs.perms_reset).SendAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task ResetGlobalPerms() + { + await _gps.Reset(); + await Response().Confirm(strs.global_perms_reset).SendAsync(); + } + } +} \ No newline at end of file -- 2.43.0 From e124621f90642dc0323f416598ac71757ab997a4 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:55:36 +1200 Subject: [PATCH 029/340] Added Searches module --- .../Modules/Searches/Anime/AnimeResult.cs | 41 ++ .../Searches/Anime/AnimeSearchCommands.cs | 204 ++++++ .../Searches/Anime/AnimeSearchService.cs | 79 +++ .../Modules/Searches/Anime/MangaResult.cs | 40 ++ .../Modules/Searches/Crypto/CryptoCommands.cs | 196 ++++++ .../Modules/Searches/Crypto/CryptoService.cs | 215 ++++++ .../Crypto/DefaultStockDataService.cs | 126 ++++ .../Crypto/Drawing/CandleDrawingData.cs | 12 + .../Drawing/IStockChartDrawingService.cs | 8 + .../ImagesharpStockChartDrawingService.cs | 200 ++++++ .../Searches/Crypto/IStockDataService.cs | 8 + .../Searches/Crypto/_common/CandleData.cs | 8 + .../Searches/Crypto/_common/ImageData.cs | 7 + .../Searches/Crypto/_common/QuoteResponse.cs | 43 ++ .../Searches/Crypto/_common/StockData.cs | 15 + .../Searches/Crypto/_common/SymbolData.cs | 3 + .../Crypto/_common/YahooFinanceCandleData.cs | 12 + .../_common/YahooFinanceSearchResponse.cs | 19 + .../_common/YahooFinanceSearchResponseItem.cs | 25 + .../Crypto/_common/YahooQueryModel.cs | 9 + .../Modules/Searches/Feeds/FeedCommands.cs | 146 ++++ .../Modules/Searches/Feeds/FeedsService.cs | 294 ++++++++ src/EllieBot/Modules/Searches/JokeCommands.cs | 53 ++ .../Modules/Searches/MemegenCommands.cs | 99 +++ src/EllieBot/Modules/Searches/OsuCommands.cs | 297 ++++++++ .../Modules/Searches/PathOfExileCommands.cs | 312 +++++++++ .../Modules/Searches/PokemonSearchCommands.cs | 74 ++ .../Search/DefaultSearchServiceFactory.cs | 65 ++ .../Search/Google/GoogleCustomSearchResult.cs | 22 + .../Searches/Search/Google/GoogleImageData.cs | 12 + .../Search/Google/GoogleImageResult.cs | 19 + .../Search/Google/GoogleImageResultEntry.cs | 13 + .../Google/GoogleSearchResultInformation.cs | 13 + .../Search/Google/GoogleSearchService.cs | 66 ++ .../Google/OfficialGoogleSearchResultEntry.cs | 19 + .../GoogleScrape/GoogleScrapeService.cs | 121 ++++ .../PlainGoogleScrapeSearchResult.cs | 8 + .../GoogleScrape/PlainSearchResultEntry.cs | 9 + .../GoogleScrape/PlainSearchResultInfo.cs | 7 + .../Searches/Search/IImageSearchResult.cs | 13 + .../Modules/Searches/Search/ISearchResult.cs | 8 + .../Searches/Search/ISearchResultEntry.cs | 9 + .../Search/ISearchResultInformation.cs | 7 + .../Modules/Searches/Search/ISearchService.cs | 9 + .../Searches/Search/ISearchServiceFactory.cs | 10 + .../Modules/Searches/Search/SearchCommands.cs | 202 ++++++ .../Searches/Search/SearchServiceBase.cs | 9 + .../Search/Searx/SearxImageSearchResult.cs | 28 + .../Searx/SearxImageSearchResultEntry.cs | 14 + .../Searches/Search/Searx/SearxInfobox.cs | 30 + .../Search/Searx/SearxSearchAttribute.cs | 15 + .../Search/Searx/SearxSearchResult.cs | 47 ++ .../Search/Searx/SearxSearchResultEntry.cs | 51 ++ .../Searx/SearxSearchResultInformation.cs | 7 + .../Search/Searx/SearxSearchService.cs | 76 ++ .../Searches/Search/Searx/SearxUrlData.cs | 15 + .../Search/Youtube/IYoutubeSearchService.cs | 6 + .../Search/Youtube/InvidiousSearchResponse.cs | 9 + .../Youtube/InvidiousYtSearchService.cs | 46 ++ .../Searches/Search/Youtube/VideoInfo.cs | 9 + .../Youtube/YoutubeDataApiSearchService.cs | 26 + .../Youtube/YtdlYoutubeSearchService.cs | 7 + .../Youtube/YtdlpYoutubeSearchService.cs | 7 + .../Search/Youtube/YtdlxServiceBase.cs | 34 + src/EllieBot/Modules/Searches/Searches.cs | 600 ++++++++++++++++ .../Modules/Searches/SearchesService.cs | 457 ++++++++++++ .../StreamNotificationCommands.cs | 211 ++++++ .../StreamNotificationService.cs | 651 ++++++++++++++++++ .../StreamOnlineMessageDeleterService.cs | 99 +++ .../Searches/Translate/ITranslateService.cs | 17 + .../Searches/Translate/TranslateService.cs | 224 ++++++ .../Searches/Translate/TranslatorCommands.cs | 95 +++ src/EllieBot/Modules/Searches/XkcdCommands.cs | 97 +++ .../Searches/YoutubeTrack/YtTrackService.cs | 134 ++++ .../Searches/YoutubeTrack/YtUploadCommands.cs | 54 ++ .../Modules/Searches/_common/AtlExtensions.cs | 12 + .../Modules/Searches/_common/BibleVerses.cs | 20 + .../_common/Config/ImgSearchEngine.cs | 7 + .../Searches/_common/Config/SearchesConfig.cs | 86 +++ .../_common/Config/SearchesConfigService.cs | 58 ++ .../_common/Config/WebSearchEngine.cs | 9 + .../Modules/Searches/_common/CryptoData.cs | 66 ++ .../Modules/Searches/_common/DefineModel.cs | 43 ++ .../Modules/Searches/_common/E621Object.cs | 24 + .../Exceptions/StreamNotFoundException.cs | 19 + .../Modules/Searches/_common/Extensions.cs | 9 + .../Modules/Searches/_common/Gallery.cs | 44 ++ .../Searches/_common/GatariUserResponse.cs | 52 ++ .../_common/GatariUserStatsResponse.cs | 76 ++ .../Searches/_common/GoogleSearchResult.cs | 16 + .../Searches/_common/HearthstoneCardData.cs | 13 + .../Searches/_common/LowerCaseNamingPolicy.cs | 12 + .../Modules/Searches/_common/MagicItem.cs | 8 + .../Modules/Searches/_common/MtgData.cs | 26 + .../Modules/Searches/_common/NovelData.cs | 14 + .../Modules/Searches/_common/OmdbMovie.cs | 13 + .../Modules/Searches/_common/OsuMapData.cs | 9 + .../Modules/Searches/_common/OsuUserBets.cs | 58 ++ .../Modules/Searches/_common/OsuUserData.cs | 70 ++ .../Searches/_common/PathOfExileModels.cs | 40 ++ .../Modules/Searches/_common/SteamGameId.cs | 35 + .../Models/HelixStreamsResponse.cs | 64 ++ .../Models/HelixUsersResponse.cs | 46 ++ .../Models/PicartoChannelResponse.cs | 157 +++++ .../StreamNotifications/Models/StreamData.cs | 21 + .../Models/StreamDataKey.cs | 16 + .../Models/TrovoGetUsersResponse.cs | 61 ++ .../Models/TrovoRequestData.cs | 10 + .../Models/TrovoSocialLink.cs | 13 + .../Models/TwitchResponseV5.cs | 114 +++ .../Models/TwitchUsersResponseV5.cs | 37 + .../StreamNotifications/NotifChecker.cs | 215 ++++++ .../Providers/PicartoProvider.cs | 100 +++ .../StreamNotifications/Providers/Provider.cs | 63 ++ .../Providers/TrovoProvider.cs | 126 ++++ .../Providers/TwitchHelixProvider.cs | 194 ++++++ .../Modules/Searches/_common/TimeData.cs | 9 + .../Modules/Searches/_common/TimeModels.cs | 22 + .../Modules/Searches/_common/UrbanDef.cs | 14 + .../Modules/Searches/_common/WeatherModels.cs | 67 ++ .../Searches/_common/WikipediaApiModel.cs | 18 + .../Modules/Searches/_common/WoWJoke.cs | 11 + 122 files changed, 8469 insertions(+) create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeResult.cs create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Anime/MangaResult.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/CryptoService.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs create mode 100644 src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs create mode 100644 src/EllieBot/Modules/Searches/Feeds/FeedsService.cs create mode 100644 src/EllieBot/Modules/Searches/JokeCommands.cs create mode 100644 src/EllieBot/Modules/Searches/MemegenCommands.cs create mode 100644 src/EllieBot/Modules/Searches/OsuCommands.cs create mode 100644 src/EllieBot/Modules/Searches/PathOfExileCommands.cs create mode 100644 src/EllieBot/Modules/Searches/PokemonSearchCommands.cs create mode 100644 src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs create mode 100644 src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs create mode 100644 src/EllieBot/Modules/Searches/Search/SearchCommands.cs create mode 100644 src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs create mode 100644 src/EllieBot/Modules/Searches/Searches.cs create mode 100644 src/EllieBot/Modules/Searches/SearchesService.cs create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs create mode 100644 src/EllieBot/Modules/Searches/Translate/ITranslateService.cs create mode 100644 src/EllieBot/Modules/Searches/Translate/TranslateService.cs create mode 100644 src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs create mode 100644 src/EllieBot/Modules/Searches/XkcdCommands.cs create mode 100644 src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs create mode 100644 src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs create mode 100644 src/EllieBot/Modules/Searches/_common/AtlExtensions.cs create mode 100644 src/EllieBot/Modules/Searches/_common/BibleVerses.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs create mode 100644 src/EllieBot/Modules/Searches/_common/CryptoData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/DefineModel.cs create mode 100644 src/EllieBot/Modules/Searches/_common/E621Object.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Exceptions/StreamNotFoundException.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Extensions.cs create mode 100644 src/EllieBot/Modules/Searches/_common/Gallery.cs create mode 100644 src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/GatariUserStatsResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs create mode 100644 src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/LowerCaseNamingPolicy.cs create mode 100644 src/EllieBot/Modules/Searches/_common/MagicItem.cs create mode 100644 src/EllieBot/Modules/Searches/_common/MtgData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/NovelData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/OmdbMovie.cs create mode 100644 src/EllieBot/Modules/Searches/_common/OsuMapData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/OsuUserBets.cs create mode 100644 src/EllieBot/Modules/Searches/_common/OsuUserData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/PathOfExileModels.cs create mode 100644 src/EllieBot/Modules/Searches/_common/SteamGameId.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixStreamsResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixUsersResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/PicartoChannelResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamDataKey.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoGetUsersResponse.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoRequestData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoSocialLink.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchResponseV5.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchUsersResponseV5.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/NotifChecker.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/PicartoProvider.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/Provider.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TrovoProvider.cs create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TwitchHelixProvider.cs create mode 100644 src/EllieBot/Modules/Searches/_common/TimeData.cs create mode 100644 src/EllieBot/Modules/Searches/_common/TimeModels.cs create mode 100644 src/EllieBot/Modules/Searches/_common/UrbanDef.cs create mode 100644 src/EllieBot/Modules/Searches/_common/WeatherModels.cs create mode 100644 src/EllieBot/Modules/Searches/_common/WikipediaApiModel.cs create mode 100644 src/EllieBot/Modules/Searches/_common/WoWJoke.cs diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs new file mode 100644 index 0000000..c47eed7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs @@ -0,0 +1,41 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class AnimeResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("airing_status")] + public string AiringStatusParsed { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_episodes")] + public int TotalEpisodes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLarge { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + + public string AiringStatus + => AiringStatusParsed.ToTitleCase(); + + public string Link + => "http://anilist.co/anime/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs new file mode 100644 index 0000000..8acae72 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs @@ -0,0 +1,204 @@ +#nullable disable +using AngleSharp; +using AngleSharp.Html.Dom; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class AnimeSearchCommands : EllieModule + { + // [EllieCommand, Aliases] + // public async Task Novel([Leftover] string query) + // { + // if (string.IsNullOrWhiteSpace(query)) + // return; + // + // var novelData = await _service.GetNovelData(query); + // + // if (novelData is null) + // { + // await Response().Error(strs.failed_finding_novel).SendAsync(); + // return; + // } + // + // var embed = _sender.CreateEmbed() + // .WithOkColor() + // .WithDescription(novelData.Description.Replace("
", Environment.NewLine, StringComparison.InvariantCulture)) + // .WithTitle(novelData.Title) + // .WithUrl(novelData.Link) + // .WithImageUrl(novelData.ImageUrl) + // .AddField(GetText(strs.authors), string.Join("\n", novelData.Authors), true) + // .AddField(GetText(strs.status), novelData.Status, true) + // .AddField(GetText(strs.genres), string.Join(" ", novelData.Genres.Any() ? novelData.Genres : new[] { "none" }), true) + // .WithFooter($"{GetText(strs.score)} {novelData.Score}"); + // + // await Response().Embed(embed).SendAsync(); + // } + + [Cmd] + [Priority(0)] + public async Task Mal([Leftover] string name) + { + if (string.IsNullOrWhiteSpace(name)) + return; + + var fullQueryLink = "https://myanimelist.net/profile/" + name; + + var config = Configuration.Default.WithDefaultLoader(); + using var document = await BrowsingContext.New(config).OpenAsync(fullQueryLink); + var imageElem = + document.QuerySelector( + "body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-left > div.user-profile > div.user-image > img"); + var imageUrl = ((IHtmlImageElement)imageElem)?.Source + ?? "http://icecream.me/uploads/870b03f36b59cc16ebfe314ef2dde781.png"; + + var stats = document + .QuerySelectorAll( + "body > div#myanimelist > div.wrapper > div#contentWrapper > div#content > div.content-container > div.container-right > div#statistics > div.user-statistics-stats > div.stats > div.clearfix > ul.stats-status > li > span") + .Select(x => x.InnerHtml) + .ToList(); + + var favorites = document.QuerySelectorAll("div.user-favorites > div.di-tc"); + + var favAnime = GetText(strs.anime_no_fav); + if (favorites.Length > 0 && favorites[0].QuerySelector("p") is null) + { + favAnime = string.Join("\n", + favorites[0] + .QuerySelectorAll("ul > li > div.di-tc.va-t > a") + .Shuffle() + .Take(3) + .Select(x => + { + var elem = (IHtmlAnchorElement)x; + return $"[{elem.InnerHtml}]({elem.Href})"; + })); + } + + var info = document.QuerySelectorAll("ul.user-status:nth-child(3) > li.clearfix") + .Select(x => Tuple.Create(x.Children[0].InnerHtml, x.Children[1].InnerHtml)) + .ToList(); + + var daysAndMean = document.QuerySelectorAll("div.anime:nth-child(1) > div:nth-child(2) > div") + .Select(x => x.TextContent.Split(':').Select(y => y.Trim()).ToArray()) + .ToArray(); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.mal_profile(name))) + .AddField("💚 " + GetText(strs.watching), stats[0], true) + .AddField("💙 " + GetText(strs.completed), stats[1], true); + if (info.Count < 3) + embed.AddField("💛 " + GetText(strs.on_hold), stats[2], true); + embed.AddField("💔 " + GetText(strs.dropped), stats[3], true) + .AddField("⚪ " + GetText(strs.plan_to_watch), stats[4], true) + .AddField("🕐 " + daysAndMean[0][0], daysAndMean[0][1], true) + .AddField("📊 " + daysAndMean[1][0], daysAndMean[1][1], true) + .AddField(MalInfoToEmoji(info[0].Item1) + " " + info[0].Item1, info[0].Item2.TrimTo(20), true) + .AddField(MalInfoToEmoji(info[1].Item1) + " " + info[1].Item1, info[1].Item2.TrimTo(20), true); + if (info.Count > 2) + embed.AddField(MalInfoToEmoji(info[2].Item1) + " " + info[2].Item1, info[2].Item2.TrimTo(20), true); + + embed.WithDescription($@" +** https://myanimelist.net/animelist/{name} ** + +**{GetText(strs.top_3_fav_anime)}** +{favAnime}") + .WithUrl(fullQueryLink) + .WithImageUrl(imageUrl); + + await Response().Embed(embed).SendAsync(); + } + + private static string MalInfoToEmoji(string info) + { + info = info.Trim().ToLowerInvariant(); + switch (info) + { + case "gender": + return "🚁"; + case "location": + return "🗺"; + case "last online": + return "👥"; + case "birthday": + return "📆"; + default: + return "❔"; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task Mal(IGuildUser usr) + => Mal(usr.Username); + + [Cmd] + public async Task Anime([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var animeData = await _service.GetAnimeData(query); + + if (animeData is null) + { + await Response().Error(strs.failed_finding_anime).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(animeData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(animeData.TitleEnglish) + .WithUrl(animeData.Link) + .WithImageUrl(animeData.ImageUrlLarge) + .AddField(GetText(strs.episodes), animeData.TotalEpisodes.ToString(), true) + .AddField(GetText(strs.status), animeData.AiringStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", animeData.Genres.Any() ? animeData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {animeData.AverageScore} / 100"); + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Manga([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + var mangaData = await _service.GetMangaData(query); + + if (mangaData is null) + { + await Response().Error(strs.failed_finding_manga).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithDescription(mangaData.Synopsis.Replace("
", + Environment.NewLine, + StringComparison.InvariantCulture)) + .WithTitle(mangaData.TitleEnglish) + .WithUrl(mangaData.Link) + .WithImageUrl(mangaData.ImageUrlLge) + .AddField(GetText(strs.chapters), mangaData.TotalChapters.ToString(), true) + .AddField(GetText(strs.status), mangaData.PublishingStatus, true) + .AddField(GetText(strs.genres), + string.Join(",\n", mangaData.Genres.Any() ? mangaData.Genres : ["none"]), + true) + .WithFooter($"{GetText(strs.score)} {mangaData.AverageScore} / 100"); + + await Response().Embed(embed).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs new file mode 100644 index 0000000..4cf1b01 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs @@ -0,0 +1,79 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches.Services; + +public class AnimeSearchService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + + public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory) + { + _cache = cache; + _httpFactory = httpFactory; + } + + public async Task GetAnimeData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"anime2:{link}"); + + try + { + var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + var link = $"https://aniapi.nadeko.bot/anime/{suffix}"; + link = link.ToLowerInvariant(); + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12)); + } + + return data; + } + catch + { + return null; + } + } + + public async Task GetMangaData(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + TypedKey GetKey(string link) + => new TypedKey($"manga2:{link}"); + + try + { + var link = "https://aniapi.nadeko.bot/manga/" + + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture)); + link = link.ToLowerInvariant(); + + var result = await _cache.GetAsync(GetKey(link)); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetFromJsonAsync(link); + + await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3)); + } + + + return data; + } + catch + { + return null; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Anime/MangaResult.cs b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs new file mode 100644 index 0000000..9a32703 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs @@ -0,0 +1,40 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class MangaResult +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("publishing_status")] + public string PublishingStatus { get; set; } + + [JsonPropertyName("image_url_lge")] + public string ImageUrlLge { get; set; } + + [JsonPropertyName("title_english")] + public string TitleEnglish { get; set; } + + [JsonPropertyName("total_chapters")] + public int TotalChapters { get; set; } + + [JsonPropertyName("total_volumes")] + public int TotalVolumes { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } + + [JsonPropertyName("genres")] + public string[] Genres { get; set; } + + [JsonPropertyName("average_score")] + public float AverageScore { get; set; } + + public string Link + => "http://anilist.co/manga/" + Id; + + public string Synopsis + => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "..."; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs new file mode 100644 index 0000000..37353b1 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs @@ -0,0 +1,196 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; +using System.Globalization; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class FinanceCommands : EllieModule + { + private readonly IStockDataService _stocksService; + private readonly IStockChartDrawingService _stockDrawingService; + + public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService) + { + _stocksService = stocksService; + _stockDrawingService = stockDrawingService; + } + + [Cmd] + public async Task Stock([Leftover]string query) + { + using var typing = ctx.Channel.EnterTypingState(); + + var stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + var symbols = await _stocksService.SearchSymbolAsync(query); + + if (symbols.Count == 0) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var symbol = symbols.First(); + var promptEmbed = _sender.CreateEmbed() + .WithDescription(symbol.Description) + .WithTitle(GetText(strs.did_you_mean(symbol.Symbol))); + + if (!await PromptUserConfirmAsync(promptEmbed)) + return; + + query = symbol.Symbol; + stock = await _stocksService.GetStockDataAsync(query); + + if (stock is null) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + } + + var candles = await _stocksService.GetCandleDataAsync(query); + var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles); + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sign = stock.Price >= stock.Close + ? "\\🔼" + : "\\🔻"; + + var change = (stock.Price - stock.Close).ToString("N2", Culture); + var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture); + + var sign50 = stock.Change50d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change50 = (stock.Change50d).ToString("P1", Culture); + + var sign200 = stock.Change200d >= 0 + ? "\\🔼" + : "\\🔻"; + + var change200 = (stock.Change200d).ToString("P1", Culture); + + var price = stock.Price.ToString("C2", localCulture); + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(stock.Symbol) + .WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}") + .WithTitle(stock.Name) + .AddField(GetText(strs.price), $"{sign} **{price}**", true) + .AddField(GetText(strs.market_cap), stock.MarketCap, true) + .AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true) + .AddField("Change", $"{change} ({changePercent})", true) + // .AddField("Change 50d", $"{sign50}{change50}", true) + // .AddField("Change 200d", $"{sign200}{change200}", true) + .WithFooter(stock.Exchange); + + var message = await Response().Embed(eb).SendAsync(); + await using var imageData = await stockImageTask; + if (imageData is null) + return; + + var fileName = $"{query}-sparkline.{imageData.Extension}"; + using var attachment = new FileAttachment( + imageData.FileData, + fileName + ); + await message.ModifyAsync(mp => + { + mp.Attachments = + new(new[] + { + attachment + }); + + mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build(); + }); + } + + + [Cmd] + public async Task Crypto(string name) + { + name = name?.ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(name)) + return; + + var (crypto, nearest) = await _service.GetCryptoData(name); + + if (nearest is not null) + { + var embed = _sender.CreateEmbed() + .WithTitle(GetText(strs.crypto_not_found)) + .WithDescription( + GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})")))); + + if (await PromptUserConfirmAsync(embed)) + crypto = nearest; + } + + if (crypto is null) + { + await Response().Error(strs.crypto_not_found).SendAsync(); + return; + } + + var usd = crypto.Quote["USD"]; + + var localCulture = (CultureInfo)Culture.Clone(); + localCulture.NumberFormat.CurrencySymbol = "$"; + + var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture); + var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture); + var price = usd.Price < 0.01 + ? usd.Price.ToString(localCulture) + : usd.Price.ToString("C2", localCulture); + + var volume = usd.Volume24h.ToString("C0", localCulture); + var marketCap = usd.MarketCap.ToString("C0", localCulture); + var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture); + + await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0); + var fileName = $"{crypto.Slug}_7d.png"; + + var toSend = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"#{crypto.CmcRank}") + .WithTitle($"{crypto.Name} ({crypto.Symbol})") + .WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/") + .WithThumbnailUrl($"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png") + .AddField(GetText(strs.market_cap), marketCap, true) + .AddField(GetText(strs.price), price, true) + .AddField(GetText(strs.volume_24h), volume, true) + .AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true) + .AddField(GetText(strs.market_cap_dominance), dominance, true) + .WithImageUrl($"attachment://{fileName}"); + + if (crypto.CirculatingSupply is double cs) + { + var csStr = cs.ToString("N0", localCulture); + + if (crypto.MaxSupply is double ms) + { + var perc = (cs / ms).ToString("P1", localCulture); + + toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true); + } + else + { + toSend.AddField(GetText(strs.circulating_supply), csStr, true); + } + } + + + await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build()); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs new file mode 100644 index 0000000..146dac3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs @@ -0,0 +1,215 @@ +#nullable enable +using EllieBot.Modules.Searches.Common; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Globalization; +using System.Net.Http.Json; +using System.Xml; +using Color = SixLabors.ImageSharp.Color; +using StringExtensions = EllieBot.Extensions.StringExtensions; + +namespace EllieBot.Modules.Searches.Services; + +public class CryptoService : IEService +{ + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + private readonly IBotCredentials _creds; + + private readonly SemaphoreSlim _getCryptoLock = new(1, 1); + + public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds) + { + _cache = cache; + _httpFactory = httpFactory; + _creds = creds; + } + + private PointF[] GetSparklinePointsFromSvgText(string svgText) + { + var xml = new XmlDocument(); + xml.LoadXml(svgText); + + var gElement = xml["svg"]?["g"]; + if (gElement is null) + return Array.Empty(); + + Span points = new PointF[gElement.ChildNodes.Count]; + var cnt = 0; + + bool GetValuesFromAttributes( + XmlAttributeCollection attrs, + out float x1, + out float y1, + out float x2, + out float y2) + { + (x1, y1, x2, y2) = (0, 0, 0, 0); + return attrs["x1"]?.Value is string x1Str + && float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1) + && attrs["y1"]?.Value is string y1Str + && float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1) + && attrs["x2"]?.Value is string x2Str + && float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2) + && attrs["y2"]?.Value is string y2Str + && float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2); + } + + foreach (XmlElement x in gElement.ChildNodes) + { + if (x.Name != "line") + continue; + + if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2)) + { + points[cnt++] = new(x1, y1); + // this point will be set twice to the same value + // on all points except the last one + if (cnt + 1 < points.Length) + points[cnt + 1] = new(x2, y2); + } + } + + if (cnt == 0) + return Array.Empty(); + + return points.Slice(0, cnt).ToArray(); + } + + private SixLabors.ImageSharp.Image GenerateSparklineChart(PointF[] points, bool up) + { + const int width = 164; + const int height = 48; + + var img = new Image(width, height, Color.Transparent); + var color = up + ? Color.Green + : Color.FromRgb(220, 0, 0); + + img.Mutate(x => + { + x.DrawLines(color, 2, points); + }); + + return img; + } + + public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return (null, null); + + name = name.ToUpperInvariant(); + var cryptos = await GetCryptoDataInternal(); + + if (cryptos is null or { Count: 0 }) + return (null, null); + + var crypto = cryptos.FirstOrDefault(x + => x.Slug.ToUpperInvariant() == name + || x.Name.ToUpperInvariant() == name + || x.Symbol.ToUpperInvariant() == name); + + if (crypto is not null) + return (crypto, null); + + + var nearest = cryptos + .Select(elem => (Elem: elem, + Distance: elem.Name.ToUpperInvariant().LevenshteinDistance(name))) + .OrderBy(x => x.Distance) + .FirstOrDefault(x => x.Distance <= 2); + + return (null, nearest.Elem); + } + + public async Task?> GetCryptoDataInternal() + { + await _getCryptoLock.WaitAsync(); + try + { + var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"), + async () => + { + try + { + using var http = _httpFactory.CreateClient(); + var data = await http.GetFromJsonAsync( + "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?" + + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}" + + "&start=1" + + "&limit=5000" + + "&convert=USD"); + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting crypto data: {Message}", ex.Message); + return default; + } + }, + TimeSpan.FromHours(2)); + + if (data is null) + return default; + + return data.Data; + } + catch (Exception ex) + { + Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message); + return default; + } + finally + { + _getCryptoLock.Release(); + } + } + + private TypedKey GetSparklineKey(int id) + => new($"crypto:sparkline:{id}"); + + public async Task GetSparklineAsync(int id, bool up) + { + try + { + var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id), + async () => + { + // if it fails, generate a new one + var points = await DownloadSparklinePointsAsync(id); + var sparkline = GenerateSparklineChart(points, up); + + using var stream = await sparkline.ToStreamAsync(); + return stream.ToArray(); + }, + TimeSpan.FromHours(1)); + + if (bytes is { Length: > 0 }) + { + return bytes.ToStream(); + } + + return default; + } + catch (Exception ex) + { + Log.Warning(ex, + "Exception occurred while downloading sparkline points: {ErrorMessage}", + ex.Message); + return default; + } + } + + private async Task DownloadSparklinePointsAsync(int id) + { + using var http = _httpFactory.CreateClient(); + var str = await http.GetStringAsync( + $"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg"); + var points = GetSparklinePointsFromSvgText(str); + return points; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs new file mode 100644 index 0000000..5b5bf40 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs @@ -0,0 +1,126 @@ +using AngleSharp; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultStockDataService : IStockDataService, IEService +{ + private readonly IHttpClientFactory _httpClientFactory; + + public DefaultStockDataService(IHttpClientFactory httpClientFactory) + => _httpClientFactory = httpClientFactory; + + public async Task GetStockDataAsync(string query) + { + try + { + if (!query.IsAlphaNumeric()) + return default; + + using var http = _httpClientFactory.CreateClient(); + + + + var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}"; + + var config = Configuration.Default.WithDefaultLoader(); + using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage); + var divElem = + document.QuerySelector( + "#quote-header-info > div:nth-child(2) > div > div > h1"); + var tickerName = (divElem)?.TextContent; + + var marketcap = document + .QuerySelectorAll("table") + .Skip(1) + .First() + .QuerySelector("tbody > tr > td:nth-child(2)") + ?.TextContent; + + + var volume = document.QuerySelector("td[data-test='AVERAGE_VOLUME_3MONTH-value']") + ?.TextContent; + + var close= document.QuerySelector("td[data-test='PREV_CLOSE-value']") + ?.TextContent ?? "0"; + + var price = document + .QuerySelector("#quote-header-info") + ?.QuerySelector("fin-streamer[data-field='regularMarketPrice']") + ?.TextContent ?? close; + + // var data = await http.GetFromJsonAsync( + // $"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}"); + // + // if (data is null) + // return default; + + // var symbol = data.QuoteResponse.Result.FirstOrDefault(); + + // if (symbol is null) + // return default; + + return new() + { + Name = tickerName, + Symbol = query, + Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture), + Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture), + MarketCap = marketcap, + DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture), + }; + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.ToString()); + return default; + } + } + + public async Task> SearchSymbolAsync(string query) + { + if (string.IsNullOrWhiteSpace(query)) + throw new ArgumentNullException(nameof(query)); + + query = Uri.EscapeDataString(query); + + using var http = _httpClientFactory.CreateClient(); + + var res = await http.GetStringAsync( + "https://finance.yahoo.com/_finance_doubledown/api/resource/searchassist" + + $";searchTerm={query}" + + "?device=console"); + + var data = JsonSerializer.Deserialize(res); + + if (data is null or { Items: null }) + return Array.Empty(); + + return data.Items + .Where(x => x.Type == "S") + .Select(x => new SymbolData(x.Symbol, x.Name)) + .ToList(); + } + + private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture); + + public async Task> GetCandleDataAsync(string query) + { + using var http = _httpClientFactory.CreateClient(); + await using var resStream = await http.GetStreamAsync( + $"https://query1.finance.yahoo.com/v7/finance/download/{query}" + + $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}" + + $"&period2={DateTime.UtcNow.ToTimestamp()}" + + "&interval=1d"); + + using var textReader = new StreamReader(resStream); + using var csv = new CsvReader(textReader, _csvConfig); + var records = csv.GetRecords().ToArray(); + + return records + .Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume)); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs new file mode 100644 index 0000000..97da2da --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs @@ -0,0 +1,12 @@ +using SixLabors.ImageSharp; + +namespace EllieBot.Modules.Searches; + +/// +/// All data required to draw a candle +/// +/// Whether the candle is green +/// Rectangle for the body +/// High line point +/// Low line point +public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs new file mode 100644 index 0000000..9676e88 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockChartDrawingService +{ + Task GenerateSparklineAsync(IReadOnlyCollection series); + Task GenerateCombinedChartAsync(IReadOnlyCollection series); + Task GenerateCandleChartAsync(IReadOnlyCollection series); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs new file mode 100644 index 0000000..731fc78 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs @@ -0,0 +1,200 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Runtime.CompilerServices; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public sealed class ImagesharpStockChartDrawingService : IStockChartDrawingService, IEService +{ + private const int WIDTH = 300; + private const int HEIGHT = 100; + private const decimal MAX_HEIGHT = HEIGHT * 0.8m; + + private static readonly Rgba32 _backgroundColor = Rgba32.ParseHex("17181E"); + private static readonly Rgba32 _lineGuideColor = Rgba32.ParseHex("212125"); + private static readonly Rgba32 _sparklineColor = Rgba32.ParseHex("2961FC"); + private static readonly Rgba32 _greenBrush = Rgba32.ParseHex("26A69A"); + private static readonly Rgba32 _redBrush = Rgba32.ParseHex("EF5350"); + + private static float GetNormalizedPoint(decimal max, decimal point, decimal range) + => (float)((MAX_HEIGHT * ((max - point) / range)) + HeightOffset()); + + private PointF[] GetSparklinePointsInternal(IReadOnlyCollection series) + { + var candleStep = WIDTH / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var points = new PointF[series.Count]; + + var i = 0; + foreach (var candle in series) + { + var x = candleStep * (i + 1); + + var y = GetNormalizedPoint(max, candle.Close, range); + points[i++] = new(x, y); + } + + return points; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static decimal HeightOffset() + => (HEIGHT - MAX_HEIGHT) / 2m; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Image CreateCanvasInternal() + => new Image(WIDTH, HEIGHT, _backgroundColor); + + private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection series) + { + var candleMargin = 2; + var candleStep = (WIDTH - (candleMargin * series.Count)) / (series.Count + 1); + var max = series.Max(static x => x.High); + var min = series.Min(static x => x.Low); + + var range = max - min; + + var drawData = new CandleDrawingData[series.Count]; + + var candleWidth = candleStep; + + var i = 0; + foreach (var candle in series) + { + var offsetX = (i - 1) * candleMargin; + var x = (candleStep * (i + 1)) + offsetX; + var yOpen = GetNormalizedPoint(max, candle.Open, range); + var yClose = GetNormalizedPoint(max, candle.Close, range); + var y = candle.Open > candle.Close + ? yOpen + : yClose; + + var sizeH = Math.Abs(yOpen - yClose); + + var high = GetNormalizedPoint(max, candle.High, range); + var low = GetNormalizedPoint(max, candle.Low, range); + drawData[i] = new(candle.Open < candle.Close, + new(x, y, candleWidth, sizeH), + new(x + (candleStep / 2), high), + new(x + (candleStep / 2), low)); + ++i; + } + + return drawData; + } + + private void DrawChartData(Image image, CandleDrawingData[] drawData) + => image.Mutate(ctx => + { + foreach (var data in drawData) + ctx.DrawLines(data.IsGreen + ? _greenBrush + : _redBrush, + 1, + data.High, + data.Low); + + + foreach (var data in drawData) + ctx.Fill(data.IsGreen + ? _greenBrush + : _redBrush, + data.BodyRect); + }); + + private void DrawLineGuides(Image image, IReadOnlyCollection series) + { + var max = series.Max(x => x.High); + var min = series.Min(x => x.Low); + + var step = (max - min) / 5; + + var lines = new float[6]; + + for (var i = 0; i < 6; i++) + { + var y = GetNormalizedPoint(max, min + (step * i), max - min); + lines[i] = y; + } + + image.Mutate(ctx => + { + // draw guides + foreach (var y in lines) + ctx.DrawLines(_lineGuideColor, 1, new PointF(0, y), new PointF(WIDTH, y)); + + // // draw min and max price on the chart + // ctx.DrawText(min.ToString(CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, (float)HeightOffset() - 5) + // ); + // + // ctx.DrawText(max.ToString("N1", CultureInfo.InvariantCulture), + // SystemFonts.CreateFont("Arial", 5), + // Color.White, + // new PointF(0, HEIGHT - (float)HeightOffset()) + // ); + }); + } + + public Task GenerateSparklineAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + var points = GetSparklinePointsInternal(series); + + image.Mutate(ctx => + { + ctx.DrawLines(_sparklineColor, 2, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCombinedChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var chartData = GetChartDrawingDataInternal(series); + DrawChartData(image, chartData); + + var points = GetSparklinePointsInternal(series); + image.Mutate(ctx => + { + ctx.DrawLines(Color.ParseHex("00FFFFAA"), 1, points); + }); + + return Task.FromResult(new("png", image.ToStream())); + } + + public Task GenerateCandleChartAsync(IReadOnlyCollection series) + { + if (series.Count == 0) + return Task.FromResult(default); + + using var image = CreateCanvasInternal(); + + DrawLineGuides(image, series); + + var drawData = GetChartDrawingDataInternal(series); + DrawChartData(image, drawData); + + return Task.FromResult(new("png", image.ToStream())); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs new file mode 100644 index 0000000..5f778e8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface IStockDataService +{ + public Task GetStockDataAsync(string symbol); + Task> SearchSymbolAsync(string query); + Task> GetCandleDataAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs new file mode 100644 index 0000000..97d1a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public record CandleData( + decimal Open, + decimal Close, + decimal High, + decimal Low, + long Volume); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs new file mode 100644 index 0000000..d49d1ba --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public record ImageData(string Extension, Stream FileData) : IAsyncDisposable +{ + public ValueTask DisposeAsync() + => FileData.DisposeAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs new file mode 100644 index 0000000..13ea277 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs @@ -0,0 +1,43 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class QuoteResponse +{ + public class ResultModel + { + [JsonPropertyName("longName")] + public string LongName { get; set; } + + [JsonPropertyName("regularMarketPrice")] + public double RegularMarketPrice { get; set; } + + [JsonPropertyName("regularMarketPreviousClose")] + public double RegularMarketPreviousClose { get; set; } + + [JsonPropertyName("fullExchangeName")] + public string FullExchangeName { get; set; } + + [JsonPropertyName("averageDailyVolume10Day")] + public int AverageDailyVolume10Day { get; set; } + + [JsonPropertyName("fiftyDayAverageChangePercent")] + public double FiftyDayAverageChangePercent { get; set; } + + [JsonPropertyName("twoHundredDayAverageChangePercent")] + public double TwoHundredDayAverageChangePercent { get; set; } + + [JsonPropertyName("marketCap")] + public long MarketCap { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + } + + [JsonPropertyName("result")] + public List Result { get; set; } + + [JsonPropertyName("error")] + public object Error { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs new file mode 100644 index 0000000..dfb99c7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs @@ -0,0 +1,15 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public class StockData +{ + public string Name { get; set; } + public string Symbol { get; set; } + public double Price { get; set; } + public string MarketCap { get; set; } + public double Close { get; set; } + public double Change50d { get; set; } + public double Change200d { get; set; } + public long DailyVolume { get; set; } + public string Exchange { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs new file mode 100644 index 0000000..01ef65d --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs @@ -0,0 +1,3 @@ +namespace EllieBot.Modules.Searches; + +public record SymbolData(string Symbol, string Description); \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs new file mode 100644 index 0000000..619bdc3 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs @@ -0,0 +1,12 @@ +namespace EllieBot.Modules.Searches; + +public class YahooFinanceCandleData +{ + public DateTime Date { get; set; } + public decimal Open { get; set; } + public decimal High { get; set; } + public decimal Low { get; set; } + public decimal Close { get; set; } + public decimal AdjClose { get; set; } + public long Volume { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs new file mode 100644 index 0000000..168dd82 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs @@ -0,0 +1,19 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponse +{ + [JsonPropertyName("suggestionTitleAccessor")] + public string SuggestionTitleAccessor { get; set; } + + [JsonPropertyName("suggestionMeta")] + public List SuggestionMeta { get; set; } + + [JsonPropertyName("hiConf")] + public bool HiConf { get; set; } + + [JsonPropertyName("items")] + public List Items { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs new file mode 100644 index 0000000..e8eaa9f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs @@ -0,0 +1,25 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooFinanceSearchResponseItem +{ + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("exch")] + public string Exch { get; set; } + + [JsonPropertyName("type")] + public string Type { get; set; } + + [JsonPropertyName("exchDisp")] + public string ExchDisp { get; set; } + + [JsonPropertyName("typeDisp")] + public string TypeDisp { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs new file mode 100644 index 0000000..4efc94f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public class YahooQueryModel +{ + [JsonPropertyName("quoteResponse")] + public QuoteResponse QuoteResponse { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs new file mode 100644 index 0000000..37376f0 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs @@ -0,0 +1,146 @@ +#nullable disable +using CodeHollow.FeedReader; +using EllieBot.Modules.Searches.Services; +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class FeedCommands : EllieModule + { + private static readonly Regex _ytChannelRegex = + new(@"youtube\.com\/(?:c\/|channel\/|user\/)?(?[a-zA-Z0-9\-_]{1,})"); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task YtUploadNotif(string url, [Leftover] string message = null) + => YtUploadNotif(url, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public Task YtUploadNotif(string url, ITextChannel channel = null, [Leftover] string message = null) + { + var m = _ytChannelRegex.Match(url); + if (!m.Success) + return Response().Error(strs.invalid_input).SendAsync(); + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + var channelId = m.Groups["channelid"].Value; + + return Feed($"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}", channel, message); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Feed(string url, [Leftover] string message = null) + => Feed(url, null, message); + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Feed(string url, ITextChannel channel = null, [Leftover] string message = null) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + await Response().Error(strs.feed_invalid_url).SendAsync(); + return; + } + + if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) + message = message?.SanitizeAllMentions(); + + channel ??= (ITextChannel)ctx.Channel; + try + { + await FeedReader.ReadAsync(url); + } + catch (Exception ex) + { + Log.Information(ex, "Unable to get feeds from that url"); + await Response().Error(strs.feed_cant_parse).SendAsync(); + return; + } + + if (ctx.User is not IGuildUser gu || !gu.GuildPermissions.Administrator) + message = message?.SanitizeMentions(true); + + var result = _service.AddFeed(ctx.Guild.Id, channel.Id, url, message); + if (result == FeedAddResult.Success) + { + await Response().Confirm(strs.feed_added).SendAsync(); + return; + } + + if (result == FeedAddResult.Duplicate) + { + await Response().Error(strs.feed_duplicate).SendAsync(); + return; + } + + if (result == FeedAddResult.LimitReached) + { + await Response().Error(strs.feed_limit_reached).SendAsync(); + return; + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedRemove(int index) + { + if (_service.RemoveFeed(ctx.Guild.Id, --index)) + await Response().Confirm(strs.feed_removed).SendAsync(); + else + await Response().Error(strs.feed_out_of_range).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task FeedList(int page = 1) + { + if (--page < 0) + return; + + var feeds = _service.GetFeeds(ctx.Guild.Id); + + if (!feeds.Any()) + { + await Response() + .Embed(_sender.CreateEmbed().WithOkColor().WithDescription(GetText(strs.feed_no_feed))) + .SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(feeds) + .PageSize(10) + .CurrentPage(page) + .Page((items, cur) => + { + var embed = _sender.CreateEmbed().WithOkColor(); + var i = 0; + var fs = string.Join("\n", + items.Select(x => $"`{(cur * 10) + ++i}.` <#{x.ChannelId}> {x.Url}")); + + return embed.WithDescription(fs); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs new file mode 100644 index 0000000..d19195c --- /dev/null +++ b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs @@ -0,0 +1,294 @@ +#nullable disable +using CodeHollow.FeedReader; +using CodeHollow.FeedReader.Feeds; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches.Services; + +public class FeedsService : IEService +{ + private readonly DbService _db; + private readonly ConcurrentDictionary> _subs; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + + private readonly ConcurrentDictionary _lastPosts = new(); + private readonly Dictionary _errorCounters = new(); + + public FeedsService( + IBot bot, + DbService db, + DiscordSocketClient client, + IMessageSenderService sender) + { + _db = db; + + using (var uow = db.GetDbContext()) + { + var guildConfigIds = bot.AllGuildConfigs.Select(x => x.Id).ToList(); + _subs = uow.Set() + .AsQueryable() + .Where(x => guildConfigIds.Contains(x.Id)) + .Include(x => x.FeedSubs) + .ToList() + .SelectMany(x => x.FeedSubs) + .GroupBy(x => x.Url.ToLower()) + .ToDictionary(x => x.Key, x => x.ToList()) + .ToConcurrent(); + } + + _client = client; + _sender = sender; + + _ = Task.Run(TrackFeeds); + } + + private void ClearErrors(string url) + => _errorCounters.Remove(url); + + private async Task AddError(string url, List ids) + { + try + { + var newValue = _errorCounters[url] = _errorCounters.GetValueOrDefault(url) + 1; + + if (newValue >= 100) + { + // remove from db + await using var ctx = _db.GetDbContext(); + await ctx.GetTable() + .DeleteAsync(x => ids.Contains(x.Id)); + + // remove from the local cache + _subs.TryRemove(url, out _); + + // reset the error counter + ClearErrors(url); + } + + return newValue; + } + catch (Exception ex) + { + Log.Error(ex, "Error adding rss errors..."); + return 0; + } + } + + public async Task TrackFeeds() + { + while (true) + { + var allSendTasks = new List(_subs.Count); + foreach (var kvp in _subs) + { + if (kvp.Value.Count == 0) + continue; + + var rssUrl = kvp.Value.First().Url; + try + { + var feed = await FeedReader.ReadAsync(rssUrl); + + var items = feed + .Items.Select(item => (Item: item, + LastUpdate: item.PublishingDate?.ToUniversalTime() + ?? (item.SpecificItem as AtomFeedItem)?.UpdatedDate?.ToUniversalTime())) + .Where(data => data.LastUpdate is not null) + .Select(data => (data.Item, LastUpdate: (DateTime)data.LastUpdate)) + .OrderByDescending(data => data.LastUpdate) + .Reverse() // start from the oldest + .ToList(); + + if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate)) + { + lastFeedUpdate = _lastPosts[kvp.Key] = + items.Any() ? items[items.Count - 1].LastUpdate : DateTime.UtcNow; + } + + foreach (var (feedItem, itemUpdateDate) in items) + { + if (itemUpdateDate <= lastFeedUpdate) + continue; + + var embed = _sender.CreateEmbed().WithFooter(rssUrl); + + _lastPosts[kvp.Key] = itemUpdateDate; + + var link = feedItem.SpecificItem.Link; + if (!string.IsNullOrWhiteSpace(link) && Uri.IsWellFormedUriString(link, UriKind.Absolute)) + embed.WithUrl(link); + + var title = string.IsNullOrWhiteSpace(feedItem.Title) ? "-" : feedItem.Title; + + var gotImage = false; + if (feedItem.SpecificItem is MediaRssFeedItem mrfi + && (mrfi.Enclosure?.MediaType?.StartsWith("image/") ?? false)) + { + var imgUrl = mrfi.Enclosure.Url; + if (!string.IsNullOrWhiteSpace(imgUrl) + && Uri.IsWellFormedUriString(imgUrl, UriKind.Absolute)) + { + embed.WithImageUrl(imgUrl); + gotImage = true; + } + } + + if (!gotImage && feedItem.SpecificItem is AtomFeedItem afi) + { + var previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "preview"); + + if (previewElement is null) + { + previewElement = afi.Element.Elements() + .FirstOrDefault(x => x.Name.LocalName == "thumbnail"); + } + + if (previewElement is not null) + { + var urlAttribute = previewElement.Attribute("url"); + if (urlAttribute is not null + && !string.IsNullOrWhiteSpace(urlAttribute.Value) + && Uri.IsWellFormedUriString(urlAttribute.Value, UriKind.Absolute)) + { + embed.WithImageUrl(urlAttribute.Value); + gotImage = true; + } + } + } + + embed.WithTitle(title.TrimTo(256)); + + var desc = feedItem.Description?.StripHtml(); + if (!string.IsNullOrWhiteSpace(feedItem.Description)) + embed.WithDescription(desc.TrimTo(2048)); + + //send the created embed to all subscribed channels + var feedSendTasks = kvp.Value + .Where(x => x.GuildConfig is not null) + .Select(x => + { + var ch = _client.GetGuild(x.GuildConfig.GuildId) + ?.GetTextChannel(x.ChannelId); + + if (ch is null) + return null; + + return _sender.Response(ch) + .Embed(embed) + .Text(string.IsNullOrWhiteSpace(x.Message) + ? string.Empty + : x.Message) + .SendAsync(); + }) + .Where(x => x is not null); + + allSendTasks.Add(feedSendTasks.WhenAll()); + + // as data retrieval was successful, reset error counter + ClearErrors(rssUrl); + } + } + catch (Exception ex) + { + var errorCount = await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList()); + + Log.Warning("An error occured while getting rss stream ({ErrorCount} / 100) {RssFeed}" + + "\n {Message}", + errorCount, + rssUrl, + $"[{ex.GetType().Name}]: {ex.Message}"); + } + } + + await Task.WhenAll(Task.WhenAll(allSendTasks), Task.Delay(30000)); + } + } + + public List GetFeeds(ulong guildId) + { + using var uow = _db.GetDbContext(); + return uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + } + + public FeedAddResult AddFeed( + ulong guildId, + ulong channelId, + string rssFeed, + string message) + { + ArgumentNullException.ThrowIfNull(rssFeed, nameof(rssFeed)); + + var fs = new FeedSub + { + ChannelId = channelId, + Url = rssFeed.Trim(), + Message = message + }; + + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)); + + if (gc.FeedSubs.Any(x => x.Url.ToLower() == fs.Url.ToLower())) + return FeedAddResult.Duplicate; + if (gc.FeedSubs.Count >= 10) + return FeedAddResult.LimitReached; + + gc.FeedSubs.Add(fs); + uow.SaveChanges(); + //adding all, in case bot wasn't on this guild when it started + foreach (var feed in gc.FeedSubs) + { + _subs.AddOrUpdate(feed.Url.ToLower(), + [feed], + (_, old) => + { + old.Add(feed); + return old; + }); + } + + return FeedAddResult.Success; + } + + public bool RemoveFeed(ulong guildId, int index) + { + if (index < 0) + return false; + + using var uow = _db.GetDbContext(); + var items = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs)) + .FeedSubs.OrderBy(x => x.Id) + .ToList(); + + if (items.Count <= index) + return false; + var toRemove = items[index]; + _subs.AddOrUpdate(toRemove.Url.ToLower(), + [], + (_, old) => + { + old.Remove(toRemove); + return old; + }); + uow.Remove(toRemove); + uow.SaveChanges(); + + return true; + } +} + +public enum FeedAddResult +{ + Success, + LimitReached, + Invalid, + Duplicate, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/JokeCommands.cs b/src/EllieBot/Modules/Searches/JokeCommands.cs new file mode 100644 index 0000000..d41c50a --- /dev/null +++ b/src/EllieBot/Modules/Searches/JokeCommands.cs @@ -0,0 +1,53 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class JokeCommands : EllieModule + { + [Cmd] + public async Task Yomama() + => await Response().Confirm(await _service.GetYomamaJoke()).SendAsync(); + + [Cmd] + public async Task Randjoke() + { + var (setup, punchline) = await _service.GetRandomJoke(); + await Response().Confirm(setup, punchline).SendAsync(); + } + + [Cmd] + public async Task ChuckNorris() + => await Response().Confirm(await _service.GetChuckNorrisJoke()).SendAsync(); + + [Cmd] + public async Task WowJoke() + { + if (!_service.WowJokes.Any()) + { + await Response().Error(strs.jokes_not_loaded).SendAsync(); + return; + } + + var joke = _service.WowJokes[new EllieRandom().Next(0, _service.WowJokes.Count)]; + await Response().Confirm(joke.Question, joke.Answer).SendAsync(); + } + + [Cmd] + public async Task MagicItem() + { + if (!_service.MagicItems.Any()) + { + await Response().Error(strs.magicitems_not_loaded).SendAsync(); + return; + } + + var item = _service.MagicItems[new EllieRandom().Next(0, _service.MagicItems.Count)]; + + await Response().Confirm("✨" + item.Name, item.Description).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/MemegenCommands.cs b/src/EllieBot/Modules/Searches/MemegenCommands.cs new file mode 100644 index 0000000..dbe2679 --- /dev/null +++ b/src/EllieBot/Modules/Searches/MemegenCommands.cs @@ -0,0 +1,99 @@ +#nullable disable +using Newtonsoft.Json; +using System.Collections.Immutable; +using System.Text; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class MemegenCommands : EllieModule + { + private static readonly ImmutableDictionary _map = new Dictionary + { + { '?', "~q" }, + { '%', "~p" }, + { '#', "~h" }, + { '/', "~s" }, + { ' ', "-" }, + { '-', "--" }, + { '_', "__" }, + { '"', "''" } + }.ToImmutableDictionary(); + + private readonly IHttpClientFactory _httpFactory; + + public MemegenCommands(IHttpClientFactory factory) + => _httpFactory = factory; + + [Cmd] + public async Task Memelist(int page = 1) + { + if (--page < 0) + return; + + using var http = _httpFactory.CreateClient("memelist"); + using var res = await http.GetAsync("https://api.memegen.link/templates/"); + + var rawJson = await res.Content.ReadAsStringAsync(); + + var data = JsonConvert.DeserializeObject>(rawJson)!; + + await Response() + .Paginated() + .Items(data) + .PageSize(15) + .CurrentPage(page) + .Page((items, curPage) => + { + var templates = string.Empty; + foreach (var template in items) + templates += $"**{template.Name}:**\n key: `{template.Id}`\n"; + var embed = _sender.CreateEmbed().WithOkColor().WithDescription(templates); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + public async Task Memegen(string meme, [Leftover] string memeText = null) + { + var memeUrl = $"http://api.memegen.link/{meme}"; + if (!string.IsNullOrWhiteSpace(memeText)) + { + var memeTextArray = memeText.Split(';'); + foreach (var text in memeTextArray) + { + var newText = Replace(text); + memeUrl += $"/{newText}"; + } + } + + memeUrl += ".png"; + await Response().Text(memeUrl).SendAsync(); + } + + private static string Replace(string input) + { + var sb = new StringBuilder(); + + foreach (var c in input) + { + if (_map.TryGetValue(c, out var tmp)) + sb.Append(tmp); + else + sb.Append(c); + } + + return sb.ToString(); + } + + private class MemegenTemplate + { + public string Name { get; set; } + public string Id { get; set; } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/OsuCommands.cs b/src/EllieBot/Modules/Searches/OsuCommands.cs new file mode 100644 index 0000000..599df2d --- /dev/null +++ b/src/EllieBot/Modules/Searches/OsuCommands.cs @@ -0,0 +1,297 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class OsuCommands : EllieModule + { + private readonly IBotCredentials _creds; + private readonly IHttpClientFactory _httpFactory; + + public OsuCommands(IBotCredentials creds, IHttpClientFactory factory) + { + _creds = creds; + _httpFactory = factory; + } + + [Cmd] + public async Task Osu(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(user)) + return; + + using var http = _httpFactory.CreateClient(); + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + + try + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error(strs.osu_api_key).SendAsync(); + return; + } + + var smode = ResolveGameMode(modeNumber); + var userReq = $"https://osu.ppy.sh/api/get_user?k={_creds.OsuApiKey}&u={user}&m={modeNumber}"; + var userResString = await http.GetStringAsync(userReq); + var objs = JsonConvert.DeserializeObject>(userResString); + + if (objs.Count == 0) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var obj = objs[0]; + var userId = obj.UserId; + + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu! {smode} profile for {user}") + .WithThumbnailUrl($"https://a.ppy.sh/{userId}") + .WithDescription($"https://osu.ppy.sh/u/{userId}") + .AddField("Official Rank", $"#{obj.PpRank}", true) + .AddField("Country Rank", + $"#{obj.PpCountryRank} :flag_{obj.Country.ToLower()}:", + true) + .AddField("Total PP", Math.Round(obj.PpRaw, 2), true) + .AddField("Accuracy", Math.Round(obj.Accuracy, 2) + "%", true) + .AddField("Playcount", obj.Playcount, true) + .AddField("Level", Math.Round(obj.Level), true)).SendAsync(); + } + catch (ArgumentOutOfRangeException) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + } + catch (Exception ex) + { + await Response().Error(strs.osu_failed).SendAsync(); + Log.Warning(ex, "Osu command failed"); + } + } + + [Cmd] + public async Task Gatari(string user, [Leftover] string mode = null) + { + using var http = _httpFactory.CreateClient(); + var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode); + + var modeStr = ResolveGameMode(modeNumber); + var resString = await http.GetStringAsync($"https://api.gatari.pw/user/stats?u={user}&mode={modeNumber}"); + + var statsResponse = JsonConvert.DeserializeObject(resString); + if (statsResponse.Code != 200 || statsResponse.Stats.Id == 0) + { + await Response().Error(strs.osu_user_not_found).SendAsync(); + return; + } + + var usrResString = await http.GetStringAsync($"https://api.gatari.pw/users/get?u={user}"); + + var userData = JsonConvert.DeserializeObject(usrResString).Users[0]; + var userStats = statsResponse.Stats; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"osu!Gatari {modeStr} profile for {user}") + .WithThumbnailUrl($"https://a.gatari.pw/{userStats.Id}") + .WithDescription($"https://osu.gatari.pw/u/{userStats.Id}") + .AddField("Official Rank", $"#{userStats.Rank}", true) + .AddField("Country Rank", + $"#{userStats.CountryRank} :flag_{userData.Country.ToLower()}:", + true) + .AddField("Total PP", userStats.Pp, true) + .AddField("Accuracy", $"{Math.Round(userStats.AvgAccuracy, 2)}%", true) + .AddField("Playcount", userStats.Playcount, true) + .AddField("Level", userStats.Level, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Osu5(string user, [Leftover] string mode = null) + { + if (string.IsNullOrWhiteSpace(_creds.OsuApiKey)) + { + await Response().Error("An osu! API key is required.").SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(user)) + { + await Response().Error("Please provide a username.").SendAsync(); + return; + } + + using var http = _httpFactory.CreateClient(); + var m = 0; + if (!string.IsNullOrWhiteSpace(mode)) + m = ResolveGameMode(mode); + + var reqString = "https://osu.ppy.sh/api/get_user_best" + + $"?k={_creds.OsuApiKey}" + + $"&u={Uri.EscapeDataString(user)}" + + "&type=string" + + "&limit=5" + + $"&m={m}"; + + var resString = await http.GetStringAsync(reqString); + var obj = JsonConvert.DeserializeObject>(resString); + + var mapTasks = obj.Select(async item => + { + var mapReqString = "https://osu.ppy.sh/api/get_beatmaps" + + $"?k={_creds.OsuApiKey}" + + $"&b={item.BeatmapId}"; + + var mapResString = await http.GetStringAsync(mapReqString); + var map = JsonConvert.DeserializeObject>(mapResString).FirstOrDefault(); + if (map is null) + return default; + var pp = Math.Round(item.Pp, 2); + var acc = CalculateAcc(item, m); + var mods = ResolveMods(item.EnabledMods); + + var title = $"{map.Artist}-{map.Title} ({map.Version})"; + var desc = $@"[/b/{item.BeatmapId}](https://osu.ppy.sh/b/{item.BeatmapId}) +{pp + "pp",-7} | {acc + "%",-7} +"; + if (mods != "+") + desc += Format.Bold(mods); + + return (title, desc); + }); + + var eb = _sender.CreateEmbed().WithOkColor().WithTitle($"Top 5 plays for {user}"); + + var mapData = await mapTasks.WhenAll(); + foreach (var (title, desc) in mapData.Where(x => x != default)) + eb.AddField(title, desc); + + await Response().Embed(eb).SendAsync(); + } + + //https://osu.ppy.sh/wiki/Accuracy + private static double CalculateAcc(OsuUserBests play, int mode) + { + double hitPoints; + double totalHits; + if (mode == 0) + { + hitPoints = (play.Count50 * 50) + (play.Count100 * 100) + (play.Count300 * 300); + totalHits = play.Count50 + play.Count100 + play.Count300 + play.Countmiss; + totalHits *= 300; + } + else if (mode == 1) + { + hitPoints = (play.Countmiss * 0) + (play.Count100 * 0.5) + play.Count300; + totalHits = (play.Countmiss + play.Count100 + play.Count300) * 300; + hitPoints *= 300; + } + else if (mode == 2) + { + hitPoints = play.Count50 + play.Count100 + play.Count300; + totalHits = play.Countmiss + play.Count50 + play.Count100 + play.Count300 + play.Countkatu; + } + else + { + hitPoints = (play.Count50 * 50) + + (play.Count100 * 100) + + (play.Countkatu * 200) + + ((play.Count300 + play.Countgeki) * 300); + + totalHits = (play.Countmiss + + play.Count50 + + play.Count100 + + play.Countkatu + + play.Count300 + + play.Countgeki) + * 300; + } + + + return Math.Round(hitPoints / totalHits * 100, 2); + } + + private static int ResolveGameMode(string mode) + { + switch (mode.ToUpperInvariant()) + { + case "STD": + case "STANDARD": + return 0; + case "TAIKO": + return 1; + case "CTB": + case "CATCHTHEBEAT": + return 2; + case "MANIA": + case "OSU!MANIA": + return 3; + default: + return 0; + } + } + + private static string ResolveGameMode(int mode) + { + switch (mode) + { + case 0: + return "Standard"; + case 1: + return "Taiko"; + case 2: + return "Catch"; + case 3: + return "Mania"; + default: + return "Standard"; + } + } + + //https://github.com/ppy/osu-api/wiki#mods + private static string ResolveMods(int mods) + { + var modString = "+"; + + if (IsBitSet(mods, 0)) + modString += "NF"; + if (IsBitSet(mods, 1)) + modString += "EZ"; + if (IsBitSet(mods, 8)) + modString += "HT"; + + if (IsBitSet(mods, 3)) + modString += "HD"; + if (IsBitSet(mods, 4)) + modString += "HR"; + if (IsBitSet(mods, 6) && !IsBitSet(mods, 9)) + modString += "DT"; + if (IsBitSet(mods, 9)) + modString += "NC"; + if (IsBitSet(mods, 10)) + modString += "FL"; + + if (IsBitSet(mods, 5)) + modString += "SD"; + if (IsBitSet(mods, 14)) + modString += "PF"; + + if (IsBitSet(mods, 7)) + modString += "RX"; + if (IsBitSet(mods, 11)) + modString += "AT"; + if (IsBitSet(mods, 12)) + modString += "SO"; + return modString; + } + + private static bool IsBitSet(int mods, int pos) + => (mods & (1 << pos)) != 0; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/PathOfExileCommands.cs b/src/EllieBot/Modules/Searches/PathOfExileCommands.cs new file mode 100644 index 0000000..a966a5b --- /dev/null +++ b/src/EllieBot/Modules/Searches/PathOfExileCommands.cs @@ -0,0 +1,312 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Globalization; +using System.Text; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class PathOfExileCommands : EllieModule + { + private const string POE_URL = "https://www.pathofexile.com/character-window/get-characters?accountName="; + private const string PON_URL = "http://poe.ninja/api/Data/GetCurrencyOverview?league="; + private const string POGS_URL = "http://pathofexile.gamepedia.com/api.php?action=opensearch&search="; + + private const string POG_URL = + "https://pathofexile.gamepedia.com/api.php?action=browsebysubject&format=json&subject="; + + private const string POGI_URL = + "https://pathofexile.gamepedia.com/api.php?action=query&prop=imageinfo&iiprop=url&format=json&titles=File:"; + + private const string PROFILE_URL = "https://www.pathofexile.com/account/view-profile/"; + + private readonly IHttpClientFactory _httpFactory; + + private Dictionary currencyDictionary = new(StringComparer.OrdinalIgnoreCase) + { + { "Chaos Orb", "Chaos Orb" }, + { "Orb of Alchemy", "Orb of Alchemy" }, + { "Jeweller's Orb", "Jeweller's Orb" }, + { "Exalted Orb", "Exalted Orb" }, + { "Mirror of Kalandra", "Mirror of Kalandra" }, + { "Vaal Orb", "Vaal Orb" }, + { "Orb of Alteration", "Orb of Alteration" }, + { "Orb of Scouring", "Orb of Scouring" }, + { "Divine Orb", "Divine Orb" }, + { "Orb of Annulment", "Orb of Annulment" }, + { "Master Cartographer's Sextant", "Master Cartographer's Sextant" }, + { "Journeyman Cartographer's Sextant", "Journeyman Cartographer's Sextant" }, + { "Apprentice Cartographer's Sextant", "Apprentice Cartographer's Sextant" }, + { "Blessed Orb", "Blessed Orb" }, + { "Orb of Regret", "Orb of Regret" }, + { "Gemcutter's Prism", "Gemcutter's Prism" }, + { "Glassblower's Bauble", "Glassblower's Bauble" }, + { "Orb of Fusing", "Orb of Fusing" }, + { "Cartographer's Chisel", "Cartographer's Chisel" }, + { "Chromatic Orb", "Chromatic Orb" }, + { "Orb of Augmentation", "Orb of Augmentation" }, + { "Blacksmith's Whetstone", "Blacksmith's Whetstone" }, + { "Orb of Transmutation", "Orb of Transmutation" }, + { "Armourer's Scrap", "Armourer's Scrap" }, + { "Scroll of Wisdom", "Scroll of Wisdom" }, + { "Regal Orb", "Regal Orb" }, + { "Chaos", "Chaos Orb" }, + { "Alch", "Orb of Alchemy" }, + { "Alchs", "Orb of Alchemy" }, + { "Jews", "Jeweller's Orb" }, + { "Jeweller", "Jeweller's Orb" }, + { "Jewellers", "Jeweller's Orb" }, + { "Jeweller's", "Jeweller's Orb" }, + { "X", "Exalted Orb" }, + { "Ex", "Exalted Orb" }, + { "Exalt", "Exalted Orb" }, + { "Exalts", "Exalted Orb" }, + { "Mirror", "Mirror of Kalandra" }, + { "Mirrors", "Mirror of Kalandra" }, + { "Vaal", "Vaal Orb" }, + { "Alt", "Orb of Alteration" }, + { "Alts", "Orb of Alteration" }, + { "Scour", "Orb of Scouring" }, + { "Scours", "Orb of Scouring" }, + { "Divine", "Divine Orb" }, + { "Annul", "Orb of Annulment" }, + { "Annulment", "Orb of Annulment" }, + { "Master Sextant", "Master Cartographer's Sextant" }, + { "Journeyman Sextant", "Journeyman Cartographer's Sextant" }, + { "Apprentice Sextant", "Apprentice Cartographer's Sextant" }, + { "Blessed", "Blessed Orb" }, + { "Regret", "Orb of Regret" }, + { "Regrets", "Orb of Regret" }, + { "Gcp", "Gemcutter's Prism" }, + { "Glassblowers", "Glassblower's Bauble" }, + { "Glassblower's", "Glassblower's Bauble" }, + { "Fusing", "Orb of Fusing" }, + { "Fuses", "Orb of Fusing" }, + { "Fuse", "Orb of Fusing" }, + { "Chisel", "Cartographer's Chisel" }, + { "Chisels", "Cartographer's Chisel" }, + { "Chance", "Orb of Chance" }, + { "Chances", "Orb of Chance" }, + { "Chrome", "Chromatic Orb" }, + { "Chromes", "Chromatic Orb" }, + { "Aug", "Orb of Augmentation" }, + { "Augmentation", "Orb of Augmentation" }, + { "Augment", "Orb of Augmentation" }, + { "Augments", "Orb of Augmentation" }, + { "Whetstone", "Blacksmith's Whetstone" }, + { "Whetstones", "Blacksmith's Whetstone" }, + { "Transmute", "Orb of Transmutation" }, + { "Transmutes", "Orb of Transmutation" }, + { "Armourers", "Armourer's Scrap" }, + { "Armourer's", "Armourer's Scrap" }, + { "Wisdom Scroll", "Scroll of Wisdom" }, + { "Wisdom Scrolls", "Scroll of Wisdom" }, + { "Regal", "Regal Orb" }, + { "Regals", "Regal Orb" } + }; + + public PathOfExileCommands(IHttpClientFactory httpFactory) + => _httpFactory = httpFactory; + + [Cmd] + public async Task PathOfExile(string usr, string league = "", int page = 1) + { + if (--page < 0) + return; + + if (string.IsNullOrWhiteSpace(usr)) + { + await Response().Error("Please provide an account name.").SendAsync(); + return; + } + + var characters = new List(); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{POE_URL}{usr}"); + characters = JsonConvert.DeserializeObject>(res); + } + catch + { + var embed = _sender.CreateEmbed().WithDescription(GetText(strs.account_not_found)).WithErrorColor(); + + await Response().Embed(embed).SendAsync(); + return; + } + + if (!string.IsNullOrWhiteSpace(league)) + characters.RemoveAll(c => c.League != league); + + await Response() + .Paginated() + .Items(characters) + .PageSize(9) + .CurrentPage(page) + .Page((items, curPage) => + { + var embed = _sender.CreateEmbed() + .WithAuthor($"Characters on {usr}'s account", + "https://web.poecdn.com/image/favicon/ogimage.png", + $"{PROFILE_URL}{usr}") + .WithOkColor(); + + if (characters.Count == 0) + return embed.WithDescription("This account has no characters."); + + var sb = new StringBuilder(); + sb.AppendLine($"```{"#",-5}{"Character Name",-23}{"League",-10}{"Class",-13}{"Level",-3}"); + for (var i = 0; i < items.Count; i++) + { + var character = items[i]; + + sb.AppendLine( + $"#{i + 1 + (curPage * 9),-4}{character.Name,-23}{ShortLeagueName(character.League),-10}{character.Class,-13}{character.Level,-3}"); + } + + sb.AppendLine("```"); + embed.WithDescription(sb.ToString()); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + public async Task PathOfExileLeagues() + { + var leagues = new List(); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("http://api.pathofexile.com/leagues?type=main&compact=1"); + leagues = JsonConvert.DeserializeObject>(res); + } + catch + { + var eembed = _sender.CreateEmbed().WithDescription(GetText(strs.leagues_not_found)).WithErrorColor(); + + await Response().Embed(eembed).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithAuthor("Path of Exile Leagues", + "https://web.poecdn.com/image/favicon/ogimage.png", + "https://www.pathofexile.com") + .WithOkColor(); + + var sb = new StringBuilder(); + sb.AppendLine($"```{"#",-5}{"League Name",-23}"); + for (var i = 0; i < leagues.Count; i++) + { + var league = leagues[i]; + + sb.AppendLine($"#{i + 1,-4}{league.Id,-23}"); + } + + sb.AppendLine("```"); + + embed.WithDescription(sb.ToString()); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task PathOfExileCurrency( + string leagueName, + string currencyName, + string convertName = "Chaos Orb") + { + if (string.IsNullOrWhiteSpace(leagueName)) + { + await Response().Error("Please provide league name.").SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(currencyName)) + { + await Response().Error("Please provide currency name.").SendAsync(); + return; + } + + var cleanCurrency = ShortCurrencyName(currencyName); + var cleanConvert = ShortCurrencyName(convertName); + + try + { + var res = $"{PON_URL}{leagueName}"; + using var http = _httpFactory.CreateClient(); + var obj = JObject.Parse(await http.GetStringAsync(res)); + + var chaosEquivalent = 0.0F; + var conversionEquivalent = 0.0F; + + // poe.ninja API does not include a "chaosEquivalent" property for Chaos Orbs. + if (cleanCurrency == "Chaos Orb") + chaosEquivalent = 1.0F; + else + { + var currencyInput = obj["lines"] + .Values() + .Where(i => i["currencyTypeName"].Value() == cleanCurrency) + .FirstOrDefault(); + chaosEquivalent = float.Parse(currencyInput["chaosEquivalent"].ToString(), + CultureInfo.InvariantCulture); + } + + if (cleanConvert == "Chaos Orb") + conversionEquivalent = 1.0F; + else + { + var currencyOutput = obj["lines"] + .Values() + .Where(i => i["currencyTypeName"].Value() == cleanConvert) + .FirstOrDefault(); + conversionEquivalent = float.Parse(currencyOutput["chaosEquivalent"].ToString(), + CultureInfo.InvariantCulture); + } + + var embed = _sender.CreateEmbed() + .WithAuthor($"{leagueName} Currency Exchange", + "https://web.poecdn.com/image/favicon/ogimage.png", + "http://poe.ninja") + .AddField("Currency Type", cleanCurrency, true) + .AddField($"{cleanConvert} Equivalent", chaosEquivalent / conversionEquivalent, true) + .WithOkColor(); + + await Response().Embed(embed).SendAsync(); + } + catch + { + var embed = _sender.CreateEmbed().WithDescription(GetText(strs.ninja_not_found)).WithErrorColor(); + + await Response().Embed(embed).SendAsync(); + } + } + + private string ShortCurrencyName(string str) + { + if (currencyDictionary.ContainsValue(str)) + return str; + + var currency = currencyDictionary[str]; + + return currency; + } + + private static string ShortLeagueName(string str) + { + var league = str.Replace("Hardcore", "HC", StringComparison.InvariantCultureIgnoreCase); + + return league; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs new file mode 100644 index 0000000..6250e87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs @@ -0,0 +1,74 @@ +#nullable disable +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class PokemonSearchCommands : EllieModule + { + private readonly ILocalDataCache _cache; + + public PokemonSearchCommands(ILocalDataCache cache) + => _cache = cache; + + [Cmd] + public async Task Pokemon([Leftover] string pokemon = null) + { + pokemon = pokemon?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(pokemon)) + return; + + foreach (var kvp in await _cache.GetPokemonsAsync()) + { + if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant()) + { + var p = kvp.Value; + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Key.ToTitleCase()) + .WithDescription(p.BaseStats.ToString()) + .WithThumbnailUrl( + $"https://assets.pokemon.com/assets/cms2/img/pokedex/detail/{p.Id.ToString("000")}.png") + .AddField(GetText(strs.types), string.Join("\n", p.Types), true) + .AddField(GetText(strs.height_weight), + GetText(strs.height_weight_val(p.HeightM, p.WeightKg)), + true) + .AddField(GetText(strs.abilities), + string.Join("\n", p.Abilities.Select(a => a.Value)), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_none).SendAsync(); + } + + [Cmd] + public async Task PokemonAbility([Leftover] string ability = null) + { + ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture); + if (string.IsNullOrWhiteSpace(ability)) + return; + foreach (var kvp in await _cache.GetPokemonAbilitiesAsync()) + { + if (kvp.Key.ToUpperInvariant() == ability) + { + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(kvp.Value.Name) + .WithDescription(string.IsNullOrWhiteSpace(kvp.Value.Desc) + ? kvp.Value.ShortDesc + : kvp.Value.Desc) + .AddField(GetText(strs.rating), + kvp.Value.Rating.ToString(Culture), + true)).SendAsync(); + return; + } + } + + await Response().Error(strs.pokemon_ability_none).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs new file mode 100644 index 0000000..fa3c634 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs @@ -0,0 +1,65 @@ +using EllieBot.Modules.Searches.GoogleScrape; +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public sealed class DefaultSearchServiceFactory : ISearchServiceFactory, IEService +{ + private readonly SearchesConfigService _scs; + private readonly SearxSearchService _sss; + private readonly GoogleSearchService _gss; + + private readonly YtdlpYoutubeSearchService _ytdlp; + private readonly YtdlYoutubeSearchService _ytdl; + private readonly YoutubeDataApiSearchService _ytdata; + private readonly InvidiousYtSearchService _iYtSs; + private readonly GoogleScrapeService _gscs; + + public DefaultSearchServiceFactory( + SearchesConfigService scs, + GoogleSearchService gss, + GoogleScrapeService gscs, + SearxSearchService sss, + YtdlpYoutubeSearchService ytdlp, + YtdlYoutubeSearchService ytdl, + YoutubeDataApiSearchService ytdata, + InvidiousYtSearchService iYtSs) + { + _scs = scs; + _sss = sss; + _gss = gss; + _gscs = gscs; + _iYtSs = iYtSs; + + _ytdlp = ytdlp; + _ytdl = ytdl; + _ytdata = ytdata; + } + + public ISearchService GetSearchService(string? hint = null) + => _scs.Data.WebSearchEngine switch + { + WebSearchEngine.Google => _gss, + WebSearchEngine.Google_Scrape => _gscs, + WebSearchEngine.Searx => _sss, + _ => _gss + }; + + public ISearchService GetImageSearchService(string? hint = null) + => _scs.Data.ImgSearchEngine switch + { + ImgSearchEngine.Google => _gss, + ImgSearchEngine.Searx => _sss, + _ => _gss + }; + + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null) + => _scs.Data.YtProvider switch + { + YoutubeSearcher.YtDataApiv3 => _ytdata, + YoutubeSearcher.Ytdlp => _ytdlp, + YoutubeSearcher.Ytdl => _ytdl, + YoutubeSearcher.Invidious => _iYtSs, + _ => _ytdl + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs new file mode 100644 index 0000000..74fd3c0 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs @@ -0,0 +1,22 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleCustomSearchResult : ISearchResult +{ + ISearchResultInformation ISearchResult.Info + => Info; + + public string? Answer + => null; + + IReadOnlyCollection ISearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs new file mode 100644 index 0000000..503a1cc --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageData +{ + [JsonPropertyName("contextLink")] + public string ContextLink { get; init; } = null!; + + [JsonPropertyName("thumbnailLink")] + public string ThumbnailLink { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs new file mode 100644 index 0000000..9cf406b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResult : IImageSearchResult +{ + ISearchResultInformation IImageSearchResult.Info + => Info; + + IReadOnlyCollection IImageSearchResult.Entries + => Entries ?? Array.Empty(); + + [JsonPropertyName("searchInformation")] + public GoogleSearchResultInformation Info { get; init; } = null!; + + [JsonPropertyName("items")] + public IReadOnlyCollection? Entries { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs new file mode 100644 index 0000000..cd06fae --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleImageResultEntry : IImageSearchResultEntry +{ + [JsonPropertyName("link")] + public string Link { get; init; } = null!; + + [JsonPropertyName("image")] + public GoogleImageData Image { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs new file mode 100644 index 0000000..0106c0a --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs @@ -0,0 +1,13 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class GoogleSearchResultInformation : ISearchResultInformation +{ + [JsonPropertyName("formattedTotalResults")] + public string TotalResults { get; init; } = null!; + + [JsonPropertyName("formattedSearchTime")] + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs new file mode 100644 index 0000000..c74d746 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs @@ -0,0 +1,66 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public sealed class GoogleSearchService : SearchServiceBase, IEService +{ + private readonly IBotCredsProvider _creds; + private readonly IHttpClientFactory _httpFactory; + + public GoogleSearchService(IBotCredsProvider creds, IHttpClientFactory httpFactory) + { + _creds = creds; + _httpFactory = httpFactory; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.ImageSearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c3f56de3be2034c07" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(image(contextLink%2CthumbnailLink)%2Clink)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&searchType=image" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var creds = _creds.GetCreds(); + var key = creds.Google.SearchId; + var cx = string.IsNullOrWhiteSpace(key) + ? "c7f1dac95987d4571" + : key; + + using var http = _httpFactory.CreateClient("google:search"); + http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip"); + await using var stream = await http.GetStreamAsync( + $"https://customsearch.googleapis.com/customsearch/v1" + + $"?cx={cx}" + + $"&q={Uri.EscapeDataString(query)}" + + $"&fields=items(title%2Clink%2CdisplayLink%2Csnippet)%2CsearchInformation" + + $"&key={creds.GoogleApiKey}" + + $"&safe=active"); + + var result = await System.Text.Json.JsonSerializer.DeserializeAsync(stream); + + return result; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs new file mode 100644 index 0000000..bf23180 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs @@ -0,0 +1,19 @@ +using EllieBot.Modules.Searches; +using System.Text.Json.Serialization; + +namespace EllieBot.Services; + +public sealed class OfficialGoogleSearchResultEntry : ISearchResultEntry +{ + [JsonPropertyName("title")] + public string Title { get; init; } = null!; + + [JsonPropertyName("link")] + public string Url { get; init; } = null!; + + [JsonPropertyName("displayLink")] + public string DisplayUrl { get; init; } = null!; + + [JsonPropertyName("snippet")] + public string Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs new file mode 100644 index 0000000..8c20767 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs @@ -0,0 +1,121 @@ +using AngleSharp.Html.Dom; +using AngleSharp.Html.Parser; +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class GoogleScrapeService : SearchServiceBase, IEService +{ + private static readonly HtmlParser _googleParser = new(new() + { + IsScripting = false, + IsEmbedded = false, + IsSupportingProcessingInstructions = false, + IsKeepingSourceReferences = false, + IsNotSupportingFrames = true + }); + + + private readonly IHttpClientFactory _httpFactory; + + public GoogleScrapeService(IHttpClientFactory httpClientFactory) + => _httpFactory = httpClientFactory; + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + query = Uri.EscapeDataString(query)?.Replace(' ', '+'); + + var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8"; + + using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink); + msg.Headers.Add("User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36"); + msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;"); + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + + using var response = await http.SendAsync(msg); + await using var content = await response.Content.ReadAsStreamAsync(); + + using var document = await _googleParser.ParseDocumentAsync(content); + var elems = document.QuerySelectorAll("div.g, div.mnr-c > div > div"); + + var resultsElem = document.QuerySelector("#result-stats"); + var resultsArr = resultsElem?.TextContent.Split("results"); + var totalResults = resultsArr?.Length is null or 0 + ? null + : resultsArr[0]; + + var time = resultsArr is null or {Length: < 2} + ? null + : resultsArr[1] + .Replace("(", string.Empty) + .Replace("seconds)", string.Empty); + + //var time = resultsElem.Children.FirstOrDefault()?.TextContent + //^ this doesn't work for some reason, is completely missing in parsed collection + if (!elems.Any()) + return default; + + var results = elems.Select(elem => + { + var aTag = elem.QuerySelector("a"); + + if (aTag is null) + return null; + + var url = ((IHtmlAnchorElement)aTag).Href; + var title = aTag.QuerySelector("h3")?.TextContent; + + var txt = aTag.ParentElement + ?.NextElementSibling + ?.QuerySelector("span") + ?.TextContent + .StripHtml() + ?? elem + ?.QuerySelectorAll("span") + .Skip(3) + .FirstOrDefault() + ?.TextContent + .StripHtml(); + // .Select(x => x.TextContent.StripHtml()) + // .Join("\n"); + + if (string.IsNullOrWhiteSpace(url) + || string.IsNullOrWhiteSpace(title) + || string.IsNullOrWhiteSpace(txt)) + return null; + + return new PlainSearchResultEntry + { + Title = title, + Url = url, + DisplayUrl = url, + Description = txt + }; + }) + .Where(x => x is not null) + .ToList(); + + // return new GoogleSearchResult(results.AsReadOnly(), fullQueryLink, totalResults); + + return new PlainGoogleScrapeSearchResult() + { + Answer = null, + Entries = results!, + Info = new PlainSearchResultInfo() + { + SearchTime = time ?? "?", + TotalResults = totalResults ?? "?" + } + }; + } + + + // someone can mr this + public override ITask SearchImagesAsync(string query) + => throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs new file mode 100644 index 0000000..9abc999 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public class PlainGoogleScrapeSearchResult : ISearchResult +{ + public required string? Answer { get; init; } + public required IReadOnlyCollection Entries { get; init; } + public required ISearchResultInformation Info { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs new file mode 100644 index 0000000..99fad02 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultEntry : ISearchResultEntry +{ + public string Title { get; init; } = null!; + public string Url { get; init; } = null!; + public string DisplayUrl { get; init; } = null!; + public string? Description { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs new file mode 100644 index 0000000..92ba006 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.GoogleScrape; + +public sealed class PlainSearchResultInfo : ISearchResultInformation +{ + public string TotalResults { get; init; } = null!; + public string SearchTime { get; init; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs new file mode 100644 index 0000000..d470613 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Modules.Searches; + +public interface IImageSearchResult +{ + ISearchResultInformation Info { get; } + + IReadOnlyCollection Entries { get; } +} + +public interface IImageSearchResultEntry +{ + string Link { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResult.cs b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs new file mode 100644 index 0000000..d910819 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResult +{ + string? Answer { get; } + IReadOnlyCollection Entries { get; } + ISearchResultInformation Info { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs new file mode 100644 index 0000000..e4dfc44 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultEntry +{ + string Title { get; } + string Url { get; } + string DisplayUrl { get; } + string? Description { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs new file mode 100644 index 0000000..dfd9a53 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public interface ISearchResultInformation +{ + string TotalResults { get; } + string SearchTime { get; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchService.cs b/src/EllieBot/Modules/Searches/Search/ISearchService.cs new file mode 100644 index 0000000..7454a60 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchService.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public interface ISearchService +{ + ITask SearchAsync(string? query); + ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs new file mode 100644 index 0000000..bb46b09 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs @@ -0,0 +1,10 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public interface ISearchServiceFactory +{ + public ISearchService GetSearchService(string? hint = null); + public ISearchService GetImageSearchService(string? hint = null); + public IYoutubeSearchService GetYoutubeSearchService(string? hint = null); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchCommands.cs b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs new file mode 100644 index 0000000..6a98eac --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs @@ -0,0 +1,202 @@ +using EllieBot.Modules.Searches.Youtube; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + public partial class SearchCommands : EllieModule + { + private readonly ISearchServiceFactory _searchFactory; + private readonly IBotCache _cache; + + public SearchCommands( + ISearchServiceFactory searchFactory, + IBotCache cache) + { + _searchFactory = searchFactory; + _cache = cache; + } + + [Cmd] + public async Task Google([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetSearchService(); + var data = await search.SearchAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + // 3 with an answer + // 4 without an answer + // 5 is ideal but it lookes horrible on mobile + + var takeCount = string.IsNullOrWhiteSpace(data.Answer) + ? 4 + : 3; + + var descStr = data.Entries + .Take(takeCount) + .Select(static res => $@"**[{Format.Sanitize(res.Title)}]({res.Url})** +*{Format.EscapeUrl(res.DisplayUrl)}* +{Format.Sanitize(res.Description ?? "-")}") + .Join("\n\n"); + + if (!string.IsNullOrWhiteSpace(data.Answer)) + descStr = Format.Code(data.Answer) + "\n\n" + descStr; + + descStr = descStr.TrimTo(4096); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query.TrimTo(64)!) + .WithDescription(descStr) + .WithFooter( + GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), + "https://i.imgur.com/G46fm8J.png"); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Image([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var search = _searchFactory.GetImageSearchService(); + var data = await search.SearchImagesAsync(query); + + if (data is null or { Entries: null or { Count: 0 } }) + { + await Response().Error(strs.no_search_results).SendAsync(); + return; + } + + var embeds = new List(4); + + + EmbedBuilder CreateEmbed(IImageSearchResultEntry entry) + { + return _sender.CreateEmbed() + .WithOkColor() + .WithAuthor(ctx.User) + .WithTitle(query) + .WithUrl("https://google.com") + .WithImageUrl(entry.Link); + } + + embeds.Add(CreateEmbed(data.Entries.First()) + .WithFooter( + GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)), + "https://i.imgur.com/G46fm8J.png")); + + var random = data.Entries.Skip(1) + .Shuffle() + .Take(3) + .ToArray(); + + foreach (var entry in random) + { + embeds.Add(CreateEmbed(entry)); + } + + await Response().Embeds(embeds).SendAsync(); + } + + private TypedKey GetYtCacheKey(string query) + => new($"search:youtube:{query}"); + + private async Task AddYoutubeUrlToCacheAsync(string query, string url) + => await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours()); + + private async Task GetYoutubeUrlFromCacheAsync(string query) + { + var result = await _cache.GetAsync(GetYtCacheKey(query)); + + if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url)) + return null; + + return new VideoInfo() + { + Url = url + }; + } + + [Cmd] + public async Task Youtube([Leftover] string? query = null) + { + query = query?.Trim(); + + if (string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.specify_search_params).SendAsync(); + return; + } + + _ = ctx.Channel.TriggerTypingAsync(); + + var maybeResult = await GetYoutubeUrlFromCacheAsync(query) + ?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query); + if (maybeResult is not {} result || result is {Url: null}) + { + await Response().Error(strs.no_results).SendAsync(); + return; + } + + await AddYoutubeUrlToCacheAsync(query, result.Url); + await Response().Text(result.Url).SendAsync(); + } + +// [Cmd] +// public async Task DuckDuckGo([Leftover] string query = null) +// { +// query = query?.Trim(); +// if (!await ValidateQuery(query)) +// return; +// +// _ = ctx.Channel.TriggerTypingAsync(); +// +// var data = await _service.DuckDuckGoSearchAsync(query); +// if (data is null) +// { +// await Response().Error(strs.no_results).SendAsync(); +// return; +// } +// +// var desc = data.Results.Take(5) +// .Select(res => $@"[**{res.Title}**]({res.Link}) +// {res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}"); +// +// var descStr = string.Join("\n\n", desc); +// +// var embed = _sender.CreateEmbed() +// .WithAuthor(ctx.User.ToString(), +// "https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png") +// .WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr) +// .WithOkColor(); +// +// await Response().Embed(embed).SendAsync(); +// } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs new file mode 100644 index 0000000..c346306 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs @@ -0,0 +1,9 @@ +using MorseCode.ITask; + +namespace EllieBot.Modules.Searches; + +public abstract class SearchServiceBase : ISearchService +{ + public abstract ITask SearchAsync(string? query); + public abstract ITask SearchImagesAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs new file mode 100644 index 0000000..54fdcdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs @@ -0,0 +1,28 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResult : IImageSearchResult +{ + public string SearchTime { get; set; } = null!; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public IReadOnlyCollection Entries + => Results; + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs new file mode 100644 index 0000000..888a2ce --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxImageSearchResultEntry : IImageSearchResultEntry +{ + public string Link + => ImageSource.StartsWith("//") + ? "https:" + ImageSource + : ImageSource; + + [JsonPropertyName("img_src")] + public string ImageSource { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs new file mode 100644 index 0000000..1fd9ee2 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs @@ -0,0 +1,30 @@ +// using System.Text.Json.Serialization; +// +// namespace EllieBot.Modules.Searches; +// +// public sealed class SearxInfobox +// { +// [JsonPropertyName("infobox")] +// public string Infobox { get; set; } +// +// [JsonPropertyName("id")] +// public string Id { get; set; } +// +// [JsonPropertyName("content")] +// public string Content { get; set; } +// +// [JsonPropertyName("img_src")] +// public string ImgSrc { get; set; } +// +// [JsonPropertyName("urls")] +// public List Urls { get; } = new List(); +// +// [JsonPropertyName("engine")] +// public string Engine { get; set; } +// +// [JsonPropertyName("engines")] +// public List Engines { get; } = new List(); +// +// [JsonPropertyName("attributes")] +// public List Attributes { get; } = new List(); +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs new file mode 100644 index 0000000..7071ea7 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchAttribute +{ + [JsonPropertyName("label")] + public string? Label { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } + + [JsonPropertyName("entity")] + public string? Entity { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs new file mode 100644 index 0000000..3483548 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResult : ISearchResult +{ + [JsonPropertyName("query")] + public string Query { get; set; } = null!; + + [JsonPropertyName("number_of_results")] + public double NumberOfResults { get; set; } + + [JsonPropertyName("results")] + public List Results { get; set; } = new List(); + + [JsonPropertyName("answers")] + public List Answers { get; set; } = new List(); + // + // [JsonPropertyName("corrections")] + // public List Corrections { get; } = new List(); + + // [JsonPropertyName("infoboxes")] + // public List Infoboxes { get; } = new List(); + // + // [JsonPropertyName("suggestions")] + // public List Suggestions { get; } = new List(); + + // [JsonPropertyName("unresponsive_engines")] + // public List UnresponsiveEngines { get; } = new List(); + + + public string SearchTime { get; set; } = null!; + + public IReadOnlyCollection Entries + => Results; + + public ISearchResultInformation Info + => new SearxSearchResultInformation() + { + SearchTime = SearchTime, + TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture) + }; + + public string? Answer + => Answers.FirstOrDefault(); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs new file mode 100644 index 0000000..9670a17 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultEntry : ISearchResultEntry +{ + public string DisplayUrl + => Url; + + public string Description + => Content.TrimTo(768)!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("content")] + public string? Content { get; set; } + + // [JsonPropertyName("engine")] + // public string Engine { get; set; } + // + // [JsonPropertyName("parsed_url")] + // public List ParsedUrl { get; } = new List(); + // + // [JsonPropertyName("template")] + // public string Template { get; set; } + // + // [JsonPropertyName("engines")] + // public List Engines { get; } = new List(); + // + // [JsonPropertyName("positions")] + // public List Positions { get; } = new List(); + // + // [JsonPropertyName("score")] + // public double Score { get; set; } + // + // [JsonPropertyName("category")] + // public string Category { get; set; } + // + // [JsonPropertyName("pretty_url")] + // public string PrettyUrl { get; set; } + // + // [JsonPropertyName("open_group")] + // public bool OpenGroup { get; set; } + // + // [JsonPropertyName("close_group")] + // public bool? CloseGroup { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs new file mode 100644 index 0000000..33b8077 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchResultInformation : ISearchResultInformation +{ + public string TotalResults { get; init; } = string.Empty; + public string SearchTime { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs new file mode 100644 index 0000000..87e4103 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs @@ -0,0 +1,76 @@ +using MorseCode.ITask; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxSearchService : SearchServiceBase, IEService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + + private static readonly Random _rng = new EllieRandom(); + + public SearxSearchService(IHttpClientFactory http, SearchesConfigService scs) + => (_http, _scs) = (http, scs); + + private string GetRandomInstance() + { + var instances = _scs.Data.SearxInstances; + + if (instances is null or { Count: 0 }) + throw new InvalidOperationException("No searx instances specified in searches.yml"); + + return instances[_rng.Next(0, instances.Count)]; + } + + public override async ITask SearchAsync(string? query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for web search...", instanceUrl); + var sw = Stopwatch.StartNew(); + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&strict=2"); + + sw.Stop(); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxSearchResult(); + + dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } + + public override async ITask SearchImagesAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instanceUrl = GetRandomInstance(); + + Log.Information("Using {Instance} instance for img search...", instanceUrl); + var sw = Stopwatch.StartNew(); + using var http = _http.CreateClient(); + await using var res = await http.GetStreamAsync($"{instanceUrl}" + + $"?q={Uri.EscapeDataString(query)}" + + $"&format=json" + + $"&category_images=on" + + $"&strict=2"); + + sw.Stop(); + var dat = await JsonSerializer.DeserializeAsync(res); + + if (dat is null) + return new SearxImageSearchResult(); + + dat.SearchTime = sw.Elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); + return dat; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs new file mode 100644 index 0000000..07f8591 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class SearxUrlData +{ + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + [JsonPropertyName("url")] + public string Url { get; set; } = null!; + + [JsonPropertyName("official")] + public bool? Official { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs new file mode 100644 index 0000000..5b9bfab --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public interface IYoutubeSearchService +{ + Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs new file mode 100644 index 0000000..9951db8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousSearchResponse +{ + [JsonPropertyName("videoId")] + public string VideoId { get; set; } = null!; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs new file mode 100644 index 0000000..6fc8bac --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs @@ -0,0 +1,46 @@ +using EllieBot.Modules.Searches.Youtube; +using System.Net.Http.Json; + +namespace EllieBot.Modules.Searches; + +public sealed class InvidiousYtSearchService : IYoutubeSearchService, IEService +{ + private readonly IHttpClientFactory _http; + private readonly SearchesConfigService _scs; + private readonly EllieRandom _rng; + + public InvidiousYtSearchService( + IHttpClientFactory http, + SearchesConfigService scs) + { + _http = http; + _scs = scs; + _rng = new(); + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var instances = _scs.Data.InvidiousInstances; + if (instances is null or { Count: 0 }) + { + Log.Warning("Attempted to use Invidious as the .youtube provider but there are no 'invidiousInstances' " + + "specified in `data/searches.yml`"); + return null; + } + + var instance = instances[_rng.Next(0, instances.Count)]; + + using var http = _http.CreateClient(); + var res = await http.GetFromJsonAsync>( + $"{instance}/api/v1/search" + + $"?q={query}" + + $"&type=video"); + + if (res is null or {Count: 0}) + return null; + + return new VideoInfo(res[0].VideoId); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs new file mode 100644 index 0000000..5f53b9b --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public readonly struct VideoInfo +{ + public VideoInfo(string videoId) + => Url = $"https://youtube.com/watch?v={videoId}"; + + public string Url { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs new file mode 100644 index 0000000..e8bfcd2 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs @@ -0,0 +1,26 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YoutubeDataApiSearchService : IYoutubeSearchService, IEService +{ + private readonly IGoogleApiService _gapi; + + public YoutubeDataApiSearchService(IGoogleApiService gapi) + { + _gapi = gapi; + } + + public async Task SearchAsync(string query) + { + ArgumentNullException.ThrowIfNull(query); + + var results = await _gapi.GetVideoLinksByKeywordAsync(query); + var first = results.FirstOrDefault(); + if (first is null) + return null; + + return new() + { + Url = first + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs new file mode 100644 index 0000000..3ac59f8 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, IEService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, false); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs new file mode 100644 index 0000000..d7e66fa --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, IEService +{ + public override async Task SearchAsync(string query) + => await InternalGetInfoAsync(query, true); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs new file mode 100644 index 0000000..6239bdd --- /dev/null +++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs @@ -0,0 +1,34 @@ +namespace EllieBot.Modules.Searches.Youtube; + +public abstract class YoutubedlxServiceBase : IYoutubeSearchService +{ + private YtdlOperation CreateYtdlOp(bool isYtDlp) + => new YtdlOperation("-4 " + + "--geo-bypass " + + "--encoding UTF8 " + + "--get-id " + + "--no-check-certificate " + + "--default-search " + + "\"ytsearch:\" -- \"{0}\"", + isYtDlp: isYtDlp); + + protected async Task InternalGetInfoAsync(string query, bool isYtDlp) + { + var op = CreateYtdlOp(isYtDlp); + var data = await op.GetDataAsync(query); + var items = data?.Split('\n'); + if (items is null or { Length: 0 }) + return null; + + var id = items.FirstOrDefault(x => x.Length is > 5 and < 15); + if (id is null) + return null; + + return new VideoInfo() + { + Url = $"https://youtube.com/watch?v={id}" + }; + } + + public abstract Task SearchAsync(string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Searches.cs b/src/EllieBot/Modules/Searches/Searches.cs new file mode 100644 index 0000000..04050e6 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Searches.cs @@ -0,0 +1,600 @@ +#nullable disable +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; +using Color = SixLabors.ImageSharp.Color; + +namespace EllieBot.Modules.Searches; + +public partial class Searches : EllieModule +{ + private static readonly ConcurrentDictionary _cachedShortenedLinks = new(); + private readonly IBotCredentials _creds; + private readonly IGoogleApiService _google; + private readonly IHttpClientFactory _httpFactory; + private readonly IMemoryCache _cache; + private readonly ITimezoneService _tzSvc; + + public Searches( + IBotCredentials creds, + IGoogleApiService google, + IHttpClientFactory factory, + IMemoryCache cache, + ITimezoneService tzSvc) + { + _creds = creds; + _google = google; + _httpFactory = factory; + _cache = cache; + _tzSvc = tzSvc; + } + + [Cmd] + public async Task Rip([Leftover] IGuildUser usr) + { + var av = usr.RealAvatarUrl(); + await using var picStream = await _service.GetRipPictureAsync(usr.Nickname ?? usr.Username, av); + await ctx.Channel.SendFileAsync(picStream, + "rip.png", + $"Rip {Format.Bold(usr.ToString())} \n\t- " + Format.Italics(ctx.User.ToString())); + } + + [Cmd] + public async Task Weather([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + var embed = _sender.CreateEmbed(); + var data = await _service.GetWeatherDataAsync(query); + + if (data is null) + embed.WithDescription(GetText(strs.city_not_found)).WithErrorColor(); + else + { + var f = StandardConversions.CelsiusToFahrenheit; + + var tz = _tzSvc.GetTimeZoneOrUtc(ctx.Guild?.Id); + var sunrise = data.Sys.Sunrise.ToUnixTimestamp(); + var sunset = data.Sys.Sunset.ToUnixTimestamp(); + sunrise = sunrise.ToOffset(tz.GetUtcOffset(sunrise)); + sunset = sunset.ToOffset(tz.GetUtcOffset(sunset)); + var timezone = $"UTC{sunrise:zzz}"; + + embed + .AddField("🌍 " + Format.Bold(GetText(strs.location)), + $"[{data.Name + ", " + data.Sys.Country}](https://openweathermap.org/city/{data.Id})", + true) + .AddField("📏 " + Format.Bold(GetText(strs.latlong)), $"{data.Coord.Lat}, {data.Coord.Lon}", true) + .AddField("☁ " + Format.Bold(GetText(strs.condition)), + string.Join(", ", data.Weather.Select(w => w.Main)), + true) + .AddField("😓 " + Format.Bold(GetText(strs.humidity)), $"{data.Main.Humidity}%", true) + .AddField("💨 " + Format.Bold(GetText(strs.wind_speed)), data.Wind.Speed + " m/s", true) + .AddField("🌡 " + Format.Bold(GetText(strs.temperature)), + $"{data.Main.Temp:F1}°C / {f(data.Main.Temp):F1}°F", + true) + .AddField("🔆 " + Format.Bold(GetText(strs.min_max)), + $"{data.Main.TempMin:F1}°C - {data.Main.TempMax:F1}°C\n{f(data.Main.TempMin):F1}°F - {f(data.Main.TempMax):F1}°F", + true) + .AddField("🌄 " + Format.Bold(GetText(strs.sunrise)), $"{sunrise:HH:mm} {timezone}", true) + .AddField("🌇 " + Format.Bold(GetText(strs.sunset)), $"{sunset:HH:mm} {timezone}", true) + .WithOkColor() + .WithFooter("Powered by openweathermap.org", + $"https://openweathermap.org/img/w/{data.Weather[0].Icon}.png"); + } + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Time([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var (data, err) = await _service.GetTimeDataAsync(query); + if (err is not null) + { + LocStr errorKey; + switch (err) + { + case TimeErrors.ApiKeyMissing: + errorKey = strs.api_key_missing; + break; + case TimeErrors.InvalidInput: + errorKey = strs.invalid_input; + break; + case TimeErrors.NotFound: + errorKey = strs.not_found; + break; + default: + errorKey = strs.error_occured; + break; + } + + await Response().Error(errorKey).SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(data.TimeZoneName)) + { + await Response().Error(strs.timezone_db_api_key).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.time_new)) + .WithDescription(Format.Code(data.Time.ToString(Culture))) + .AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true) + .AddField(GetText(strs.timezone), data.TimeZoneName, true); + + await Response().Embed(eb).SendAsync(); + } + + [Cmd] + public async Task Movie([Leftover] string query = null) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var movie = await _service.GetMovieDataAsync(query); + if (movie is null) + { + await Response().Error(strs.imdb_fail).SendAsync(); + return; + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(movie.Title) + .WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/") + .WithDescription(movie.Plot.TrimTo(1000)) + .AddField("Rating", movie.ImdbRating, true) + .AddField("Genre", movie.Genre, true) + .AddField("Year", movie.Year, true) + .WithImageUrl(movie.Poster)) + .SendAsync(); + } + + [Cmd] + public Task RandomCat() + => InternalRandomImage(SearchesService.ImageTag.Cats); + + [Cmd] + public Task RandomDog() + => InternalRandomImage(SearchesService.ImageTag.Dogs); + + [Cmd] + public Task RandomFood() + => InternalRandomImage(SearchesService.ImageTag.Food); + + [Cmd] + public Task RandomBird() + => InternalRandomImage(SearchesService.ImageTag.Birds); + + private Task InternalRandomImage(SearchesService.ImageTag tag) + { + var url = _service.GetRandomImageUrl(tag); + return Response().Embed(_sender.CreateEmbed().WithOkColor().WithImageUrl(url)).SendAsync(); + } + + [Cmd] + public async Task Lmgtfy([Leftover] string ffs = null) + { + if (!await ValidateQuery(ffs)) + return; + + var shortenedUrl = await _google.ShortenUrl($"https://letmegooglethat.com/?q={Uri.EscapeDataString(ffs)}"); + await Response().Confirm($"<{shortenedUrl}>").SendAsync(); + } + + [Cmd] + public async Task Shorten([Leftover] string query) + { + if (!await ValidateQuery(query)) + return; + + query = query.Trim(); + if (!_cachedShortenedLinks.TryGetValue(query, out var shortLink)) + { + try + { + using var http = _httpFactory.CreateClient(); + using var req = new HttpRequestMessage(HttpMethod.Post, "https://goolnk.com/api/v1/shorten"); + var formData = new MultipartFormDataContent + { + { new StringContent(query), "url" } + }; + req.Content = formData; + + using var res = await http.SendAsync(req); + var content = await res.Content.ReadAsStringAsync(); + var data = JsonConvert.DeserializeObject(content); + + if (!string.IsNullOrWhiteSpace(data?.ResultUrl)) + _cachedShortenedLinks.TryAdd(query, data.ResultUrl); + else + return; + + shortLink = data.ResultUrl; + } + catch (Exception ex) + { + Log.Error(ex, "Error shortening a link: {Message}", ex.Message); + return; + } + } + + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .AddField(GetText(strs.original_url), $"<{query}>") + .AddField(GetText(strs.short_url), $"<{shortLink}>")) + .SendAsync(); + } + + [Cmd] + public async Task MagicTheGathering([Leftover] string search) + { + if (!await ValidateQuery(search)) + return; + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetMtgCardAsync(search); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(card.Name) + .WithDescription(card.Description) + .WithImageUrl(card.ImageUrl) + .AddField(GetText(strs.store_url), card.StoreUrl, true) + .AddField(GetText(strs.cost), card.ManaCost, true) + .AddField(GetText(strs.types), card.Types, true); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task Hearthstone([Leftover] string name) + { + if (!await ValidateQuery(name)) + return; + + if (string.IsNullOrWhiteSpace(_creds.RapidApiKey)) + { + await Response().Error(strs.mashape_api_missing).SendAsync(); + return; + } + + await ctx.Channel.TriggerTypingAsync(); + var card = await _service.GetHearthstoneCardDataAsync(name); + + if (card is null) + { + await Response().Error(strs.card_not_found).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithOkColor().WithImageUrl(card.Img); + + if (!string.IsNullOrWhiteSpace(card.Flavor)) + embed.WithDescription(card.Flavor); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + public async Task UrbanDict([Leftover] string query = null) + { + if (!await ValidateQuery(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + using (var http = _httpFactory.CreateClient()) + { + var res = await http.GetStringAsync( + $"https://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(query)}"); + try + { + var allItems = JsonConvert.DeserializeObject(res).List; + if (allItems.Any()) + { + await Response() + .Paginated() + .Items(allItems) + .PageSize(1) + .CurrentPage(0) + .Page((items, _) => + { + var item = items[0]; + return _sender.CreateEmbed() + .WithOkColor() + .WithUrl(item.Permalink) + .WithTitle(item.Word) + .WithDescription(item.Definition); + }) + .SendAsync(); + return; + } + } + catch + { + } + } + + await Response().Error(strs.ud_error).SendAsync(); + } + + [Cmd] + public async Task Define([Leftover] string word) + { + if (!await ValidateQuery(word)) + return; + + using var http = _httpFactory.CreateClient(); + string res; + try + { + res = await _cache.GetOrCreateAsync($"define_{word}", + e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(12); + return http.GetStringAsync("https://api.pearson.com/v2/dictionaries/entries?headword=" + + WebUtility.UrlEncode(word)); + }); + + var responseModel = JsonConvert.DeserializeObject(res); + + var data = responseModel.Results + .Where(x => x.Senses is not null + && x.Senses.Count > 0 + && x.Senses[0].Definition is not null) + .Select(x => (Sense: x.Senses[0], x.PartOfSpeech)) + .ToList(); + + if (!data.Any()) + { + Log.Warning("Definition not found: {Word}", word); + await Response().Error(strs.define_unknown).SendAsync(); + } + + + var col = data.Select(x => ( + Definition: x.Sense.Definition is string + ? x.Sense.Definition.ToString() + : ((JArray)JToken.Parse(x.Sense.Definition.ToString())).First.ToString(), + Example: x.Sense.Examples is null || x.Sense.Examples.Count == 0 + ? string.Empty + : x.Sense.Examples[0].Text, Word: word, + WordType: string.IsNullOrWhiteSpace(x.PartOfSpeech) ? "-" : x.PartOfSpeech)) + .ToList(); + + Log.Information("Sending {Count} definition for: {Word}", col.Count, word); + + await Response() + .Paginated() + .Items(col) + .PageSize(1) + .Page((items, _) => + { + var model = items.First(); + var embed = _sender.CreateEmbed() + .WithDescription(ctx.User.Mention) + .AddField(GetText(strs.word), model.Word, true) + .AddField(GetText(strs._class), model.WordType, true) + .AddField(GetText(strs.definition), model.Definition) + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(model.Example)) + embed.AddField(GetText(strs.example), model.Example); + + return embed; + }) + .SendAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "Error retrieving definition data for: {Word}", word); + } + } + + [Cmd] + public async Task Catfact() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync("https://catfact.ninja/fact"); + + var fact = JObject.Parse(response)["fact"].ToString(); + await Response().Confirm("🐈" + GetText(strs.catfact), fact).SendAsync(); + } + + [Cmd] + public async Task Wiki([Leftover] string query = null) + { + query = query?.Trim(); + + if (!await ValidateQuery(query)) + return; + + using var http = _httpFactory.CreateClient(); + var result = await http.GetStringAsync( + "https://en.wikipedia.org//w/api.php?action=query&format=json&prop=info&redirects=1&formatversion=2&inprop=url&titles=" + + Uri.EscapeDataString(query)); + var data = JsonConvert.DeserializeObject(result); + if (data.Query.Pages[0].Missing || string.IsNullOrWhiteSpace(data.Query.Pages[0].FullUrl)) + await Response().Error(strs.wiki_page_not_found).SendAsync(); + else + await Response().Text(data.Query.Pages[0].FullUrl).SendAsync(); + } + + [Cmd] + public async Task Color(params Color[] colors) + { + if (!colors.Any()) + return; + + var colorObjects = colors.Take(10).ToArray(); + + using var img = new Image(colorObjects.Length * 50, 50); + for (var i = 0; i < colorObjects.Length; i++) + { + var x = i * 50; + img.Mutate(m => m.FillPolygon(colorObjects[i], new(x, 0), new(x + 50, 0), new(x + 50, 50), new(x, 50))); + } + + await using var ms = img.ToStream(); + await ctx.Channel.SendFileAsync(ms, "colors.png"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Avatar([Leftover] IGuildUser usr = null) + { + if (usr is null) + usr = (IGuildUser)ctx.User; + + var avatarUrl = usr.RealAvatarUrl(2048); + + await Response() + .Embed( + _sender.CreateEmbed() + .WithOkColor() + .AddField("Username", usr.ToString()) + .AddField("Avatar Url", avatarUrl) + .WithThumbnailUrl(avatarUrl.ToString())) + .SendAsync(); + } + + [Cmd] + public async Task Wikia(string target, [Leftover] string query) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query)) + { + await Response().Error(strs.wikia_input_error).SendAsync(); + return; + } + + await ctx.Channel.TriggerTypingAsync(); + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + try + { + var res = await http.GetStringAsync($"https://{Uri.EscapeDataString(target)}.fandom.com/api.php" + + "?action=query" + + "&format=json" + + "&list=search" + + $"&srsearch={Uri.EscapeDataString(query)}" + + "&srlimit=1"); + var items = JObject.Parse(res); + var title = items["query"]?["search"]?.FirstOrDefault()?["title"]?.ToString(); + + if (string.IsNullOrWhiteSpace(title)) + { + await Response().Error(strs.wikia_error).SendAsync(); + return; + } + + var url = Uri.EscapeDataString($"https://{target}.fandom.com/wiki/{title}"); + var response = $@"`{GetText(strs.title)}` {title.SanitizeMentions()} +`{GetText(strs.url)}:` {url}"; + await Response().Text(response).SendAsync(); + } + catch + { + await Response().Error(strs.wikia_error).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Bible(string book, string chapterAndVerse) + { + var obj = new BibleVerses(); + try + { + using var http = _httpFactory.CreateClient(); + obj = await http.GetFromJsonAsync($"https://bible-api.com/{book} {chapterAndVerse}"); + } + catch + { + } + + if (obj.Error is not null || obj.Verses is null || obj.Verses.Length == 0) + await Response().Error(obj.Error ?? "No verse found.").SendAsync(); + else + { + var v = obj.Verses[0]; + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{v.BookName} {v.Chapter}:{v.Verse}") + .WithDescription(v.Text)) + .SendAsync(); + } + } + + [Cmd] + public async Task Steam([Leftover] string query) + { + if (string.IsNullOrWhiteSpace(query)) + return; + + await ctx.Channel.TriggerTypingAsync(); + + var appId = await _service.GetSteamAppIdByName(query); + if (appId == -1) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + //var embed = _sender.CreateEmbed() + // .WithOkColor() + // .WithDescription(gameData.ShortDescription) + // .WithTitle(gameData.Name) + // .WithUrl(gameData.Link) + // .WithImageUrl(gameData.HeaderImage) + // .AddField(GetText(strs.genres), gameData.TotalEpisodes.ToString(), true) + // .AddField(GetText(strs.price), gameData.IsFree ? GetText(strs.FREE) : game, true) + // .AddField(GetText(strs.links), gameData.GetGenresString(), true) + // .WithFooter(GetText(strs.recommendations(gameData.TotalRecommendations))); + await Response().Text($"https://store.steampowered.com/app/{appId}").SendAsync(); + } + + private async Task ValidateQuery([MaybeNullWhen(false)] string query) + { + if (!string.IsNullOrWhiteSpace(query)) + return true; + + await Response().Error(strs.specify_search_params).SendAsync(); + return false; + } + + public class ShortenData + { + [JsonProperty("result_url")] + public string ResultUrl { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/SearchesService.cs b/src/EllieBot/Modules/Searches/SearchesService.cs new file mode 100644 index 0000000..f5e3be4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/SearchesService.cs @@ -0,0 +1,457 @@ +#nullable disable +using EllieBot.Modules.Searches.Common; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Color = SixLabors.ImageSharp.Color; +using Image = SixLabors.ImageSharp.Image; + +namespace EllieBot.Modules.Searches.Services; + +public class SearchesService : IEService +{ + public enum ImageTag + { + Food, + Dogs, + Cats, + Birds + } + + public List WowJokes { get; } = []; + public List MagicItems { get; } = []; + private readonly IHttpClientFactory _httpFactory; + private readonly IGoogleApiService _google; + private readonly IImageCache _imgs; + private readonly IBotCache _c; + private readonly FontProvider _fonts; + private readonly IBotCredsProvider _creds; + private readonly EllieRandom _rng; + private readonly List _yomamaJokes; + + private readonly object _yomamaLock = new(); + private int yomamaJokeIndex; + + public SearchesService( + IGoogleApiService google, + IImageCache images, + IBotCache c, + IHttpClientFactory factory, + FontProvider fonts, + IBotCredsProvider creds) + { + _httpFactory = factory; + _google = google; + _imgs = images; + _c = c; + _fonts = fonts; + _creds = creds; + _rng = new(); + + //joke commands + if (File.Exists("data/wowjokes.json")) + WowJokes = JsonConvert.DeserializeObject>(File.ReadAllText("data/wowjokes.json")); + else + Log.Warning("data/wowjokes.json is missing. WOW Jokes are not loaded"); + + if (File.Exists("data/magicitems.json")) + MagicItems = JsonConvert.DeserializeObject>(File.ReadAllText("data/magicitems.json")); + else + Log.Warning("data/magicitems.json is missing. Magic items are not loaded"); + + if (File.Exists("data/yomama.txt")) + _yomamaJokes = File.ReadAllLines("data/yomama.txt").Shuffle().ToList(); + else + { + _yomamaJokes = []; + Log.Warning("data/yomama.txt is missing. .yomama command won't work"); + } + } + + public async Task GetRipPictureAsync(string text, Uri imgUrl) + => (await GetRipPictureFactory(text, imgUrl)).ToStream(); + + private void DrawAvatar(Image bg, Image avatarImage) + => bg.Mutate(x => x.Grayscale().DrawImage(avatarImage, new(83, 139), new GraphicsOptions())); + + public async Task GetRipPictureFactory(string text, Uri avatarUrl) + { + using var bg = Image.Load(await _imgs.GetRipBgAsync()); + var result = await _c.GetImageDataAsync(avatarUrl); + if (!result.TryPickT0(out var data, out _)) + { + using var http = _httpFactory.CreateClient(); + data = await http.GetByteArrayAsync(avatarUrl); + using (var avatarImg = Image.Load(data)) + { + avatarImg.Mutate(x => x.Resize(85, 85).ApplyRoundedCorners(42)); + await using var avStream = await avatarImg.ToStreamAsync(); + data = avStream.ToArray(); + DrawAvatar(bg, avatarImg); + } + + await _c.SetImageDataAsync(avatarUrl, data); + } + else + { + using var avatarImg = Image.Load(data); + DrawAvatar(bg, avatarImg); + } + + bg.Mutate(x => x.DrawText( + new TextOptions(_fonts.RipFont) + { + HorizontalAlignment = HorizontalAlignment.Center, + FallbackFontFamilies = _fonts.FallBackFonts, + Origin = new(bg.Width / 2, 225), + }, + text, + Color.Black)); + + //flowa + using (var flowers = Image.Load(await _imgs.GetRipOverlayAsync())) + { + bg.Mutate(x => x.DrawImage(flowers, new(0, 0), new GraphicsOptions())); + } + + await using var stream = bg.ToStream(); + return stream.ToArray(); + } + + public async Task GetWeatherDataAsync(string query) + { + query = query.Trim().ToLowerInvariant(); + + return await _c.GetOrAddAsync(new($"ellie_weather_{query}"), + async () => await GetWeatherDataFactory(query), + TimeSpan.FromHours(3)); + } + + private async Task GetWeatherDataFactory(string query) + { + using var http = _httpFactory.CreateClient(); + try + { + var data = await http.GetStringAsync("https://api.openweathermap.org/data/2.5/weather?" + + $"q={query}&" + + "appid=42cd627dd60debf25a5739e50a217d74&" + + "units=metric"); + + if (string.IsNullOrWhiteSpace(data)) + return null; + + return JsonConvert.DeserializeObject(data); + } + catch (Exception ex) + { + Log.Warning(ex, "Error getting weather data"); + return null; + } + } + + public Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataAsync(string arg) + => GetTimeDataFactory(arg); + + //return _cache.GetOrAddCachedDataAsync($"ellie_time_{arg}", + // GetTimeDataFactory, + // arg, + // TimeSpan.FromMinutes(1)); + private async Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataFactory( + string query) + { + query = query.Trim(); + + if (string.IsNullOrEmpty(query)) + return (default, TimeErrors.InvalidInput); + + + var locIqKey = _creds.GetCreds().LocationIqApiKey; + var tzDbKey = _creds.GetCreds().TimezoneDbApiKey; + if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey)) + return (default, TimeErrors.ApiKeyMissing); + + try + { + using var http = _httpFactory.CreateClient(); + var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"), + async () => + { + var url = "https://eu1.locationiq.com/v1/search.php?" + + (string.IsNullOrWhiteSpace(locIqKey) + ? "key=" + : $"key={locIqKey}&") + + $"q={Uri.EscapeDataString(query)}&" + + "format=json"; + + var res = await http.GetStringAsync(url); + return res; + }, + TimeSpan.FromHours(1)); + + var responses = JsonConvert.DeserializeObject(res); + if (responses is null || responses.Length == 0) + { + Log.Warning("Geocode lookup failed for: {Query}", query); + return (default, TimeErrors.NotFound); + } + + var geoData = responses[0]; + + using var req = new HttpRequestMessage(HttpMethod.Get, + "http://api.timezonedb.com/v2.1/get-time-zone?" + + $"key={tzDbKey}" + + $"&format=json" + + $"&by=position" + + $"&lat={geoData.Lat}" + + $"&lng={geoData.Lon}"); + + using var geoRes = await http.SendAsync(req); + var resString = await geoRes.Content.ReadAsStringAsync(); + var timeObj = JsonConvert.DeserializeObject(resString); + + var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(timeObj.Timestamp); + + return ((Address: responses[0].DisplayName, Time: time, TimeZoneName: timeObj.TimezoneName), default); + } + catch (Exception ex) + { + Log.Error(ex, "Weather error: {Message}", ex.Message); + return (default, TimeErrors.NotFound); + } + } + + public string GetRandomImageUrl(ImageTag tag) + { + var subpath = tag.ToString().ToLowerInvariant(); + + var max = tag switch + { + ImageTag.Food => 773, + ImageTag.Dogs => 750, + ImageTag.Cats => 773, + ImageTag.Birds => 578, + _ => 100, + }; + + + return $"https://nadeko-pictures.nyc3.digitaloceanspaces.com/{subpath}/" + + _rng.Next(1, max).ToString("000") + + ".png"; + } + + public Task GetYomamaJoke() + { + string joke; + lock (_yomamaLock) + { + if (yomamaJokeIndex >= _yomamaJokes.Count) + { + yomamaJokeIndex = 0; + var newList = _yomamaJokes.ToList(); + _yomamaJokes.Clear(); + _yomamaJokes.AddRange(newList.Shuffle()); + } + + joke = _yomamaJokes[yomamaJokeIndex++]; + } + + return Task.FromResult(joke); + + // using (var http = _httpFactory.CreateClient()) + // { + // var response = await http.GetStringAsync(new Uri("http://api.yomomma.info/")); + // return JObject.Parse(response)["joke"].ToString() + " 😆"; + // } + } + + public async Task<(string Setup, string Punchline)> GetRandomJoke() + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://official-joke-api.appspot.com/random_joke"); + var resObj = JsonConvert.DeserializeAnonymousType(res, + new + { + setup = "", + punchline = "" + }); + return (resObj.setup, resObj.punchline); + } + + public async Task GetChuckNorrisJoke() + { + using var http = _httpFactory.CreateClient(); + var response = await http.GetStringAsync(new Uri("https://api.chucknorris.io/jokes/random")); + return JObject.Parse(response)["value"] + " 😆"; + } + + public async Task GetMtgCardAsync(string search) + { + search = search.Trim().ToLowerInvariant(); + var data = await _c.GetOrAddAsync(new($"mtg:{search}"), + async () => await GetMtgCardFactory(search), + TimeSpan.FromDays(1)); + + if (data is null || data.Length == 0) + return null; + + return data[_rng.Next(0, data.Length)]; + } + + private async Task GetMtgCardFactory(string search) + { + async Task GetMtgDataAsync(MtgResponse.Data card) + { + string storeUrl; + try + { + storeUrl = await _google.ShortenUrl("https://shop.tcgplayer.com/productcatalog/product/show?" + + "newSearch=false&" + + "ProductType=All&" + + "IsProductNameExact=false&" + + $"ProductName={Uri.EscapeDataString(card.Name)}"); + } + catch { storeUrl = ""; } + + return new() + { + Description = card.Text, + Name = card.Name, + ImageUrl = card.ImageUrl, + StoreUrl = storeUrl, + Types = string.Join(",\n", card.Types), + ManaCost = card.ManaCost + }; + } + + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + var response = + await http.GetStringAsync($"https://api.magicthegathering.io/v1/cards?name={Uri.EscapeDataString(search)}"); + + var responseObject = JsonConvert.DeserializeObject(response); + if (responseObject is null) + return Array.Empty(); + + var cards = responseObject.Cards.Take(5).ToArray(); + if (cards.Length == 0) + return Array.Empty(); + + return await cards.Select(GetMtgDataAsync).WhenAll(); + } + + public async Task GetHearthstoneCardDataAsync(string name) + { + name = name.ToLowerInvariant(); + return await _c.GetOrAddAsync($"hearthstone:{name}", + () => HearthstoneCardDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task HearthstoneCardDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + http.DefaultRequestHeaders.Clear(); + http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey); + try + { + var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/" + + $"cards/search/{Uri.EscapeDataString(name)}"); + var objs = JsonConvert.DeserializeObject(response); + if (objs is null || objs.Length == 0) + return null; + var data = objs.FirstOrDefault(x => x.Collectible) + ?? objs.FirstOrDefault(x => !string.IsNullOrEmpty(x.PlayerClass)) ?? objs.FirstOrDefault(); + if (data is null) + return null; + if (!string.IsNullOrWhiteSpace(data.Img)) + data.Img = await _google.ShortenUrl(data.Img); + // if (!string.IsNullOrWhiteSpace(data.Text)) + // { + // var converter = new Converter(); + // data.Text = converter.Convert(data.Text); + // } + + return data; + } + catch (Exception ex) + { + Log.Error(ex, "Error getting Hearthstone Card: {ErrorMessage}", ex.Message); + return null; + } + } + + public async Task GetMovieDataAsync(string name) + { + name = name.Trim().ToLowerInvariant(); + return await _c.GetOrAddAsync(new($"movie:{name}"), + () => GetMovieDataFactory(name), + TimeSpan.FromDays(1)); + } + + private async Task GetMovieDataFactory(string name) + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync(string.Format("https://omdbapi.nadeko.bot/" + + "?t={0}" + + "&y=" + + "&plot=full" + + "&r=json", + name.Trim().Replace(' ', '+'))); + var movie = JsonConvert.DeserializeObject(res); + if (movie?.Title is null) + return null; + movie.Poster = await _google.ShortenUrl(movie.Poster); + return movie; + } + + public async Task GetSteamAppIdByName(string query) + { + const string steamGameIdsKey = "steam_names_to_appid"; + + var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey), + async () => + { + using var http = _httpFactory.CreateClient(); + + // https://api.steampowered.com/ISteamApps/GetAppList/v2/ + var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/"); + var apps = JsonConvert + .DeserializeAnonymousType(gamesStr, + new + { + applist = new + { + apps = new List() + } + })! + .applist.apps; + + return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .GroupBy(x => x.Name) + .ToDictionary(x => x.Key, x => x.First().AppId); + }, + TimeSpan.FromHours(24)); + + if (gamesMap is null) + return -1; + + query = query.Trim(); + + var keyList = gamesMap.Keys.ToList(); + + var key = keyList.FirstOrDefault(x => x.Equals(query, StringComparison.OrdinalIgnoreCase)); + + if (key == default) + { + key = keyList.FirstOrDefault(x => x.StartsWith(query, StringComparison.OrdinalIgnoreCase)); + if (key == default) + return -1; + } + + return gamesMap[key]; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs new file mode 100644 index 0000000..46f9ed6 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs @@ -0,0 +1,211 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Services; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class StreamNotificationCommands : EllieModule + { + private readonly DbService _db; + + public StreamNotificationCommands(DbService db) + => _db = db; + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamAdd(string link) + { + var data = await _service.FollowStream(ctx.Guild.Id, ctx.Channel.Id, link); + if (data is null) + { + await Response().Error(strs.stream_not_added).SendAsync(); + return; + } + + var embed = _service.GetEmbed(ctx.Guild.Id, data); + await Response() + .Embed(embed) + .Text(strs.stream_tracked) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task StreamRemove(int index) + { + if (--index < 0) + return; + + var fs = await _service.UnfollowStreamAsync(ctx.Guild.Id, index); + if (fs is null) + { + await Response().Error(strs.stream_no).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_removed(Format.Bold(fs.Username), fs.Type)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task StreamsClear() + { + await _service.ClearAllStreams(ctx.Guild.Id); + await Response().Confirm(strs.streams_cleared).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamList(int page = 1) + { + if (page-- < 1) + return; + + var allStreams = new List(); + await using (var uow = _db.GetDbContext()) + { + var all = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(gc => gc.FollowedStreams)) + .FollowedStreams.OrderBy(x => x.Id) + .ToList(); + + for (var index = all.Count - 1; index >= 0; index--) + { + var fs = all[index]; + if (((SocketGuild)ctx.Guild).GetTextChannel(fs.ChannelId) is null) + await _service.UnfollowStreamAsync(fs.GuildId, index); + else + allStreams.Insert(0, fs); + } + } + + await Response() + .Paginated() + .Items(allStreams) + .PageSize(12) + .CurrentPage(page) + .Page((elements, cur) => + { + if (elements.Count == 0) + return _sender.CreateEmbed().WithDescription(GetText(strs.streams_none)).WithErrorColor(); + + var eb = _sender.CreateEmbed().WithTitle(GetText(strs.streams_follow_title)).WithOkColor(); + for (var index = 0; index < elements.Count; index++) + { + var elem = elements[index]; + eb.AddField($"**#{index + 1 + (12 * cur)}** {elem.Username.ToLower()}", + $"【{elem.Type}】\n<#{elem.ChannelId}>\n{elem.Message?.TrimTo(50)}", + true); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOffline() + { + var newValue = _service.ToggleStreamOffline(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_off_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_off_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamOnlineDelete() + { + var newValue = _service.ToggleStreamOnlineDelete(ctx.Guild.Id); + if (newValue) + await Response().Confirm(strs.stream_online_delete_enabled).SendAsync(); + else + await Response().Confirm(strs.stream_online_delete_disabled).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessage(int index, [Leftover] string message) + { + if (--index < 0) + return; + + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + if (!_service.SetStreamMessage(ctx.Guild.Id, index, message, out var fs)) + { + await Response().Confirm(strs.stream_not_following).SendAsync(); + return; + } + + if (string.IsNullOrWhiteSpace(message)) + await Response().Confirm(strs.stream_message_reset(Format.Bold(fs.Username))).SendAsync(); + else + await Response().Confirm(strs.stream_message_set(Format.Bold(fs.Username))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task StreamMessageAll([Leftover] string message) + { + var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true; + if (!canMentionEveryone) + message = message?.SanitizeAllMentions(); + + var count = _service.SetStreamMessageForAll(ctx.Guild.Id, message); + + if (count == 0) + { + await Response().Confirm(strs.stream_not_following_any).SendAsync(); + return; + } + + await Response().Confirm(strs.stream_message_set_all(count)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task StreamCheck(string url) + { + try + { + var data = await _service.GetStreamDataAsync(url); + if (data is null) + { + await Response().Error(strs.no_channel_found).SendAsync(); + return; + } + + if (data.IsLive) + { + await Response() + .Confirm(strs.streamer_online(Format.Bold(data.Name), + Format.Bold(data.Viewers.ToString()))) + .SendAsync(); + } + else + await Response().Confirm(strs.streamer_offline(data.Name)).SendAsync(); + } + catch + { + await Response().Error(strs.no_channel_found).SendAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs new file mode 100644 index 0000000..c003366 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs @@ -0,0 +1,651 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; +using EllieBot.Modules.Searches.Common.StreamNotifications; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamNotificationService : IEService, IReadyExecutor +{ + private readonly DbService _db; + private readonly IBotStrings _strings; + private readonly Random _rng = new EllieRandom(); + private readonly DiscordSocketClient _client; + private readonly NotifChecker _streamTracker; + + private readonly object _shardLock = new(); + + private readonly Dictionary> _trackCounter = new(); + + private readonly Dictionary>> _shardTrackedStreams; + private readonly ConcurrentHashSet _offlineNotificationServers; + private readonly ConcurrentHashSet _deleteOnOfflineServers; + + private readonly IPubSub _pubSub; + private readonly IMessageSenderService _sender; + private readonly SearchesConfigService _config; + private readonly IReplacementService _repSvc; + + public TypedKey> StreamsOnlineKey { get; } + public TypedKey> StreamsOfflineKey { get; } + + private readonly TypedKey _streamFollowKey; + private readonly TypedKey _streamUnfollowKey; + + public event Func< + FollowedStream.FType, + string, + IReadOnlyCollection<(ulong, ulong)>, + Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; }; + + public StreamNotificationService( + DbService db, + DiscordSocketClient client, + IBotStrings strings, + IBotCredsProvider creds, + IHttpClientFactory httpFactory, + IBot bot, + IPubSub pubSub, + IMessageSenderService sender, + SearchesConfigService config, + IReplacementService repSvc) + { + _db = db; + _client = client; + _strings = strings; + _pubSub = pubSub; + _sender = sender; + _config = config; + _repSvc = repSvc; + + _streamTracker = new(httpFactory, creds); + + StreamsOnlineKey = new("streams.online"); + StreamsOfflineKey = new("streams.offline"); + + _streamFollowKey = new("stream.follow"); + _streamUnfollowKey = new("stream.unfollow"); + + using (var uow = db.GetDbContext()) + { + var ids = client.GetGuildIds(); + var guildConfigs = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .Where(x => ids.Contains(x.GuildId)) + .ToList(); + + _offlineNotificationServers = new(guildConfigs + .Where(gc => gc.NotifyStreamOffline) + .Select(x => x.GuildId) + .ToList()); + + _deleteOnOfflineServers = new(guildConfigs + .Where(gc => gc.DeleteStreamOnlineMessage) + .Select(x => x.GuildId) + .ToList()); + + var followedStreams = guildConfigs.SelectMany(x => x.FollowedStreams).ToList(); + + _shardTrackedStreams = followedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToList() + .ToDictionary( + x => new StreamDataKey(x.Key.Type, x.Key.Name.ToLower()), + x => x.GroupBy(y => y.GuildId) + .ToDictionary(y => y.Key, + y => y.AsEnumerable().ToHashSet())); + + // shard 0 will keep track of when there are no more guilds which track a stream + if (client.ShardId == 0) + { + var allFollowedStreams = uow.Set().AsQueryable().ToList(); + + foreach (var fs in allFollowedStreams) + _streamTracker.AddLastData(fs.CreateKey(), null, false); + + _trackCounter = allFollowedStreams.GroupBy(x => new + { + x.Type, + Name = x.Username.ToLower() + }) + .ToDictionary(x => new StreamDataKey(x.Key.Type, x.Key.Name), + x => x.Select(fs => fs.GuildId).ToHashSet()); + } + } + + _pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline); + _pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline); + + if (client.ShardId == 0) + { + // only shard 0 will run the tracker, + // and then publish updates with redis to other shards + _streamTracker.OnStreamsOffline += OnStreamsOffline; + _streamTracker.OnStreamsOnline += OnStreamsOnline; + _ = _streamTracker.RunAsync(); + + _pubSub.Sub(_streamFollowKey, HandleFollowStream); + _pubSub.Sub(_streamUnfollowKey, HandleUnfollowStream); + } + + bot.JoinedGuild += ClientOnJoinedGuild; + client.LeftGuild += ClientOnLeftGuild; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30)); + while (await timer.WaitForNextTickAsync()) + { + try + { + var errorLimit = TimeSpan.FromHours(12); + var failingStreams = _streamTracker.GetFailingStreams(errorLimit, true).ToList(); + + if (!failingStreams.Any()) + continue; + + var deleteGroups = failingStreams.GroupBy(x => x.Type) + .ToDictionary(x => x.Key, x => x.Select(y => y.Name).ToList()); + + await using var uow = _db.GetDbContext(); + foreach (var kvp in deleteGroups) + { + Log.Information( + "Deleting {StreamCount} {Platform} streams because they've been erroring for more than {ErrorLimit}: {RemovedList}", + kvp.Value.Count, + kvp.Key, + errorLimit, + string.Join(", ", kvp.Value)); + + var toDelete = uow.Set() + .AsQueryable() + .Where(x => x.Type == kvp.Key && kvp.Value.Contains(x.Username)) + .ToList(); + + uow.RemoveRange(toDelete); + await uow.SaveChangesAsync(); + + foreach (var loginToDelete in kvp.Value) + _streamTracker.UntrackStreamByKey(new(kvp.Key, loginToDelete)); + } + } + catch (Exception ex) + { + Log.Error(ex, "Error cleaning up FollowedStreams"); + } + } + } + + /// + /// Handles follow stream pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleFollowStream(FollowStreamPubData info) + { + _streamTracker.AddLastData(info.Key, null, false); + lock (_shardLock) + { + var key = info.Key; + if (_trackCounter.ContainsKey(key)) + _trackCounter[key].Add(info.GuildId); + else + { + _trackCounter[key] = [info.GuildId]; + } + } + + return default; + } + + /// + /// Handles unfollow pubs to keep the counter up to date. + /// When counter reaches 0, stream is removed from tracking because + /// that means no guilds are subscribed to that stream anymore + /// + private ValueTask HandleUnfollowStream(FollowStreamPubData info) + { + lock (_shardLock) + { + var key = info.Key; + if (!_trackCounter.TryGetValue(key, out var set)) + { + // it should've been removed already? + _streamTracker.UntrackStreamByKey(in key); + return default; + } + + set.Remove(info.GuildId); + if (set.Count != 0) + return default; + + _trackCounter.Remove(key); + // if no other guilds are following this stream + // untrack the stream + _streamTracker.UntrackStreamByKey(in key); + } + + return default; + } + + private async ValueTask HandleStreamsOffline(List offlineStreams) + { + foreach (var stream in offlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + await fss + // send offline stream notifications only to guilds which enable it with .stoff + .SelectMany(x => x.Value) + .Where(x => _offlineNotificationServers.Contains(x.GuildId)) + .Select(fs => + { + var ch = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (ch is null) + return Task.CompletedTask; + + return _sender.Response(ch).Embed(GetEmbed(fs.GuildId, stream)).SendAsync(); + }) + .WhenAll(); + } + } + } + + + private async ValueTask HandleStreamsOnline(List onlineStreams) + { + foreach (var stream in onlineStreams) + { + var key = stream.CreateKey(); + if (_shardTrackedStreams.TryGetValue(key, out var fss)) + { + var messages = await fss.SelectMany(x => x.Value) + .Select(async fs => + { + var textChannel = _client.GetGuild(fs.GuildId) + ?.GetTextChannel(fs.ChannelId); + + if (textChannel is null) + return default; + + var repCtx = new ReplacementContext(guild: textChannel.Guild, + client: _client) + .WithOverride("%platform%", () => fs.Type.ToString()); + + + var message = string.IsNullOrWhiteSpace(fs.Message) + ? "" + : await _repSvc.ReplaceAsync(fs.Message, repCtx); + + var msg = await _sender.Response(textChannel) + .Embed(GetEmbed(fs.GuildId, stream, false)) + .Text(message) + .Sanitize(false) + .SendAsync(); + + // only cache the ids of channel/message pairs + if (_deleteOnOfflineServers.Contains(fs.GuildId)) + return (textChannel.Id, msg.Id); + else + return default; + }) + .WhenAll(); + + + // push online stream messages to redis + // when streams go offline, any server which + // has the online stream message deletion feature + // enabled will have the online messages deleted + try + { + var pairs = messages + .Where(x => x != default) + .Select(x => (x.Item1, x.Item2)) + .ToList(); + + if (pairs.Count > 0) + await OnlineMessagesSent(key.Type, key.Name, pairs); + } + catch + { + } + } + } + } + + private Task OnStreamsOnline(List data) + => _pubSub.Pub(StreamsOnlineKey, data); + + private Task OnStreamsOffline(List data) + => _pubSub.Pub(StreamsOfflineKey, data); + + private Task ClientOnJoinedGuild(GuildConfig guildConfig) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.Set() + .AsQueryable() + .Include(x => x.FollowedStreams) + .FirstOrDefault(x => x.GuildId == guildConfig.GuildId); + + if (gc is null) + return Task.CompletedTask; + + if (gc.NotifyStreamOffline) + _offlineNotificationServers.Add(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var key = followedStream.CreateKey(); + var streams = GetLocalGuildStreams(key, gc.GuildId); + streams.Add(followedStream); + PublishFollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + private Task ClientOnLeftGuild(SocketGuild guild) + { + using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.FollowedStreams)); + + _offlineNotificationServers.TryRemove(gc.GuildId); + + foreach (var followedStream in gc.FollowedStreams) + { + var streams = GetLocalGuildStreams(followedStream.CreateKey(), guild.Id); + streams.Remove(followedStream); + + PublishUnfollowStream(followedStream); + } + } + + return Task.CompletedTask; + } + + public async Task ClearAllStreams(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + uow.RemoveRange(gc.FollowedStreams); + + foreach (var s in gc.FollowedStreams) + await PublishUnfollowStream(s); + + uow.SaveChanges(); + + return gc.FollowedStreams.Count; + } + + public async Task UnfollowStreamAsync(ulong guildId, int index) + { + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var fss = uow.Set() + .AsQueryable() + .Where(x => x.GuildId == guildId) + .OrderBy(x => x.Id) + .ToList(); + + // out of range + if (fss.Count <= index) + return null; + + fs = fss[index]; + uow.Remove(fs); + + await uow.SaveChangesAsync(); + + // remove from local cache + lock (_shardLock) + { + var key = fs.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Remove(fs); + } + } + + await PublishUnfollowStream(fs); + + return fs; + } + + private void PublishFollowStream(FollowedStream fs) + => _pubSub.Pub(_streamFollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + private Task PublishUnfollowStream(FollowedStream fs) + => _pubSub.Pub(_streamUnfollowKey, + new() + { + Key = fs.CreateKey(), + GuildId = fs.GuildId + }); + + public async Task FollowStream(ulong guildId, ulong channelId, string url) + { + // this will + var data = await _streamTracker.GetStreamDataByUrlAsync(url); + + if (data is null) + return null; + + FollowedStream fs; + await using (var uow = _db.GetDbContext()) + { + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams)); + + // add it to the database + fs = new() + { + Type = data.StreamType, + Username = data.UniqueName, + ChannelId = channelId, + GuildId = guildId + }; + + var config = _config.Data; + if (config.FollowedStreams.MaxCount is not -1 + && gc.FollowedStreams.Count >= config.FollowedStreams.MaxCount) + return null; + + gc.FollowedStreams.Add(fs); + await uow.SaveChangesAsync(); + + // add it to the local cache of tracked streams + // this way this shard will know it needs to post a message to discord + // when shard 0 publishes stream status changes for this stream + lock (_shardLock) + { + var key = data.CreateKey(); + var streams = GetLocalGuildStreams(key, guildId); + streams.Add(fs); + } + } + + PublishFollowStream(fs); + + return data; + } + + public EmbedBuilder GetEmbed(ulong guildId, StreamData status, bool showViewers = true) + { + var embed = _sender.CreateEmbed() + .WithTitle(status.Name) + .WithUrl(status.StreamUrl) + .WithDescription(status.StreamUrl) + .AddField(GetText(guildId, strs.status), status.IsLive ? "🟢 Online" : "🔴 Offline", true); + + if (showViewers) + { + embed.AddField(GetText(guildId, strs.viewers), + status.Viewers == 0 && !status.IsLive + ? "-" + : status.Viewers, + true); + } + + if (status.IsLive) + embed = embed.WithOkColor(); + else + embed = embed.WithErrorColor(); + + if (!string.IsNullOrWhiteSpace(status.Title)) + embed.WithAuthor(status.Title); + + if (!string.IsNullOrWhiteSpace(status.Game)) + embed.AddField(GetText(guildId, strs.streaming), status.Game, true); + + if (!string.IsNullOrWhiteSpace(status.AvatarUrl)) + embed.WithThumbnailUrl(status.AvatarUrl); + + if (!string.IsNullOrWhiteSpace(status.Preview)) + embed.WithImageUrl(status.Preview + "?dv=" + _rng.Next()); + + return embed; + } + + private string GetText(ulong guildId, LocStr str) + => _strings.GetText(str, guildId); + + public bool ToggleStreamOffline(ulong guildId) + { + bool newValue; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + newValue = gc.NotifyStreamOffline = !gc.NotifyStreamOffline; + uow.SaveChanges(); + + if (newValue) + _offlineNotificationServers.Add(guildId); + else + _offlineNotificationServers.TryRemove(guildId); + + return newValue; + } + + public bool ToggleStreamOnlineDelete(ulong guildId) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + var newValue = gc.DeleteStreamOnlineMessage = !gc.DeleteStreamOnlineMessage; + uow.SaveChanges(); + + if (newValue) + _deleteOnOfflineServers.Add(guildId); + else + _deleteOnOfflineServers.TryRemove(guildId); + + return newValue; + } + + public Task GetStreamDataAsync(string url) + => _streamTracker.GetStreamDataByUrlAsync(url); + + private HashSet GetLocalGuildStreams(in StreamDataKey key, ulong guildId) + { + if (_shardTrackedStreams.TryGetValue(key, out var map)) + { + if (map.TryGetValue(guildId, out var set)) + return set; + return map[guildId] = []; + } + + _shardTrackedStreams[key] = new() + { + { guildId, [] } + }; + return _shardTrackedStreams[key][guildId]; + } + + public bool SetStreamMessage( + ulong guildId, + int index, + string message, + out FollowedStream fs) + { + using var uow = _db.GetDbContext(); + var fss = uow.Set().AsQueryable().Where(x => x.GuildId == guildId).OrderBy(x => x.Id).ToList(); + + if (fss.Count <= index) + { + fs = null; + return false; + } + + fs = fss[index]; + fs.Message = message; + lock (_shardLock) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + + uow.SaveChanges(); + + return true; + } + + public int SetStreamMessageForAll(ulong guildId, string message) + { + using var uow = _db.GetDbContext(); + + var all = uow.Set() + .Where(x => x.GuildId == guildId) + .ToList(); + + if (all.Count == 0) + return 0; + + all.ForEach(x => x.Message = message); + + uow.SaveChanges(); + + lock (_shardLock) + { + foreach (var fs in all) + { + var streams = GetLocalGuildStreams(fs.CreateKey(), guildId); + + // message doesn't participate in equality checking + // removing and adding = update + streams.Remove(fs); + streams.Add(fs); + } + } + + return all.Count; + } + + public sealed class FollowStreamPubData + { + public StreamDataKey Key { get; init; } + public ulong GuildId { get; init; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs new file mode 100644 index 0000000..ae51752 --- /dev/null +++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs @@ -0,0 +1,99 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using EllieBot.Modules.Searches.Common; + +namespace EllieBot.Modules.Searches.Services; + +public sealed class StreamOnlineMessageDeleterService : IEService, IReadyExecutor +{ + private readonly StreamNotificationService _notifService; + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly IPubSub _pubSub; + + public StreamOnlineMessageDeleterService( + StreamNotificationService notifService, + DbService db, + IPubSub pubSub, + DiscordSocketClient client) + { + _notifService = notifService; + _db = db; + _client = client; + _pubSub = pubSub; + } + + public async Task OnReadyAsync() + { + _notifService.OnlineMessagesSent += OnOnlineMessagesSent; + + if (_client.ShardId == 0) + await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline); + } + + private async Task OnOnlineMessagesSent( + FollowedStream.FType type, + string name, + IReadOnlyCollection<(ulong, ulong)> pairs) + { + await using var ctx = _db.GetDbContext(); + foreach (var (channelId, messageId) in pairs) + { + await ctx.GetTable() + .InsertAsync(() => new() + { + Name = name, + Type = type, + MessageId = messageId, + ChannelId = channelId, + DateAdded = DateTime.UtcNow, + }); + } + } + + private async ValueTask OnStreamsOffline(List streamDatas) + { + if (_client.ShardId != 0) + return; + + var pairs = await GetMessagesToDelete(streamDatas); + + foreach (var (channelId, messageId) in pairs) + { + try + { + var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel; + if (textChannel is null) + continue; + + await textChannel.DeleteMessageAsync(messageId); + } + catch + { + continue; + } + } + } + + private async Task> GetMessagesToDelete(List streamDatas) + { + await using var ctx = _db.GetDbContext(); + + var toReturn = new List<(ulong, ulong)>(); + foreach (var sd in streamDatas) + { + var key = sd.CreateKey(); + var toDelete = await ctx.GetTable() + .Where(x => (x.Type == key.Type && x.Name == key.Name) + || Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1) + .DeleteWithOutputAsync(); + + toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId))); + } + + return toReturn; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs new file mode 100644 index 0000000..6766b6f --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public interface ITranslateService +{ + public Task Translate(string source, string target, string text = null); + Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete); + IEnumerable GetLanguages(); + + Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to); + + Task UnregisterUser(ulong channelId, ulong userId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslateService.cs b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs new file mode 100644 index 0000000..9f50615 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs @@ -0,0 +1,224 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Net; + +namespace EllieBot.Modules.Searches; + +public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, IEService +{ + private readonly IGoogleApiService _google; + private readonly DbService _db; + private readonly IMessageSenderService _sender; + private readonly IBot _bot; + + private readonly ConcurrentDictionary _atcs = new(); + private readonly ConcurrentDictionary> _users = new(); + + public TranslateService( + IGoogleApiService google, + DbService db, + IMessageSenderService sender, + IBot bot) + { + _google = google; + _db = db; + _sender = sender; + _bot = bot; + } + + public async Task OnReadyAsync() + { + List cs; + await using (var ctx = _db.GetDbContext()) + { + var guilds = _bot.AllGuildConfigs.Select(x => x.GuildId).ToList(); + cs = await ctx.Set().Include(x => x.Users) + .Where(x => guilds.Contains(x.GuildId)) + .ToListAsyncEF(); + } + + foreach (var c in cs) + { + _atcs[c.ChannelId] = c.AutoDelete; + _users[c.ChannelId] = + new(c.Users.ToDictionary(x => x.UserId, x => (x.Source.ToLower(), x.Target.ToLower()))); + } + } + + + public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg) + { + if (string.IsNullOrWhiteSpace(msg.Content)) + return; + + if (msg is { Channel: ITextChannel tch } um) + { + if (!_atcs.TryGetValue(tch.Id, out var autoDelete)) + return; + + if (!_users.TryGetValue(tch.Id, out var users) || !users.TryGetValue(um.Author.Id, out var langs)) + return; + + var output = await _google.Translate(msg.Content, langs.From, langs.To); + + if (string.IsNullOrWhiteSpace(output) + || msg.Content.Equals(output, StringComparison.InvariantCultureIgnoreCase)) + return; + + var embed = _sender.CreateEmbed().WithOkColor(); + + if (autoDelete) + { + embed.WithAuthor(um.Author.ToString(), um.Author.GetAvatarUrl()) + .AddField(langs.From, um.Content) + .AddField(langs.To, output); + + await _sender.Response(tch).Embed(embed).SendAsync(); + + try + { + await um.DeleteAsync(); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + _atcs.TryUpdate(tch.Id, false, true); + } + + return; + } + + await um.ReplyAsync(embed: embed.AddField(langs.To, output).Build(), allowedMentions: AllowedMentions.None); + } + } + + public async Task Translate(string source, string target, string text = null) + { + if (string.IsNullOrWhiteSpace(text)) + throw new ArgumentException("Text is empty or null", nameof(text)); + + var res = await _google.Translate(text, source.ToLowerInvariant(), target.ToLowerInvariant()); + return res.SanitizeMentions(true); + } + + public async Task ToggleAtl(ulong guildId, ulong channelId, bool autoDelete) + { + await using var ctx = _db.GetDbContext(); + + var old = await ctx.Set().ToLinqToDBTable() + .FirstOrDefaultAsyncLinqToDB(x => x.ChannelId == channelId); + + if (old is null) + { + ctx.Set().Add(new() + { + GuildId = guildId, + ChannelId = channelId, + AutoDelete = autoDelete + }); + + await ctx.SaveChangesAsync(); + + _atcs[channelId] = autoDelete; + _users[channelId] = new(); + + return true; + } + + // if autodelete value is different, update the autodelete value + // instead of disabling + if (old.AutoDelete != autoDelete) + { + old.AutoDelete = autoDelete; + await ctx.SaveChangesAsync(); + _atcs[channelId] = autoDelete; + return true; + } + + await ctx.Set().ToLinqToDBTable().DeleteAsync(x => x.ChannelId == channelId); + + await ctx.SaveChangesAsync(); + _atcs.TryRemove(channelId, out _); + _users.TryRemove(channelId, out _); + + return false; + } + + + private void UpdateUser( + ulong channelId, + ulong userId, + string from, + string to) + { + var dict = _users.GetOrAdd(channelId, new ConcurrentDictionary()); + dict[userId] = (from, to); + } + + public async Task RegisterUserAsync( + ulong userId, + ulong channelId, + string from, + string to) + { + if (!_google.Languages.ContainsKey(from) || !_google.Languages.ContainsKey(to)) + return null; + + await using var ctx = _db.GetDbContext(); + var ch = await ctx.Set().GetByChannelId(channelId); + + if (ch is null) + return null; + + var user = ch.Users.FirstOrDefault(x => x.UserId == userId); + + if (user is null) + { + ch.Users.Add(user = new() + { + Source = from, + Target = to, + UserId = userId + }); + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + // if it's different from old settings, update + if (user.Source != from || user.Target != to) + { + user.Source = from; + user.Target = to; + + await ctx.SaveChangesAsync(); + + UpdateUser(channelId, userId, from, to); + + return true; + } + + return await UnregisterUser(channelId, userId); + } + + public async Task UnregisterUser(ulong channelId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var rows = await ctx.Set().ToLinqToDBTable() + .DeleteAsync(x => x.UserId == userId && x.Channel.ChannelId == channelId); + + if (_users.TryGetValue(channelId, out var inner)) + inner.TryRemove(userId, out _); + + return rows > 0; + } + + public IEnumerable GetLanguages() + => _google.Languages.GroupBy(x => x.Value).Select(x => $"{x.AsEnumerable().Select(y => y.Key).Join(", ")}"); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs new file mode 100644 index 0000000..348ca61 --- /dev/null +++ b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs @@ -0,0 +1,95 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class TranslateCommands : EllieModule + { + public enum AutoDeleteAutoTranslate + { + Del, + Nodel + } + + [Cmd] + public async Task Translate(string fromLang, string toLang, [Leftover] string text = null) + { + try + { + await ctx.Channel.TriggerTypingAsync(); + var translation = await _service.Translate(fromLang, toLang, text); + + var embed = _sender.CreateEmbed().WithOkColor().AddField(fromLang, text).AddField(toLang, translation); + + await Response().Embed(embed).SendAsync(); + } + catch + { + await Response().Error(strs.bad_input_format).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [BotPerm(ChannelPerm.ManageMessages)] + [OwnerOnly] + public async Task AutoTranslate(AutoDeleteAutoTranslate autoDelete = AutoDeleteAutoTranslate.Nodel) + { + var toggle = + await _service.ToggleAtl(ctx.Guild.Id, ctx.Channel.Id, autoDelete == AutoDeleteAutoTranslate.Del); + if (toggle) + await Response().Confirm(strs.atl_started).SendAsync(); + else + await Response().Confirm(strs.atl_stopped).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang() + { + if (await _service.UnregisterUser(ctx.Channel.Id, ctx.User.Id)) + await Response().Confirm(strs.atl_removed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AutoTransLang(string fromLang, string toLang) + { + var succ = await _service.RegisterUserAsync(ctx.User.Id, ctx.Channel.Id, fromLang.ToLower(), toLang.ToLower()); + + if (succ is null) + { + await Response().Error(strs.atl_not_enabled).SendAsync(); + return; + } + + if (succ is false) + { + await Response().Error(strs.invalid_lang).SendAsync(); + return; + } + + await Response().Confirm(strs.atl_set(fromLang, toLang)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Translangs() + { + var langs = _service.GetLanguages().ToList(); + + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.supported_languages)) + .WithOkColor(); + + foreach (var chunk in langs.Chunk(15)) + { + eb.AddField("󠀁", chunk.Join("\n"), inline: true); + } + + await Response().Embed(eb).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/XkcdCommands.cs b/src/EllieBot/Modules/Searches/XkcdCommands.cs new file mode 100644 index 0000000..d913a87 --- /dev/null +++ b/src/EllieBot/Modules/Searches/XkcdCommands.cs @@ -0,0 +1,97 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + [Group] + public partial class XkcdCommands : EllieModule + { + private const string XKCD_URL = "https://xkcd.com"; + private readonly IHttpClientFactory _httpFactory; + + public XkcdCommands(IHttpClientFactory factory) + => _httpFactory = factory; + + [Cmd] + [Priority(0)] + public async Task Xkcd(string arg = null) + { + if (arg?.ToLowerInvariant().Trim() == "latest") + { + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/info.0.json"); + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{comic.Num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + + return; + } + + await Xkcd(new EllieRandom().Next(1, 1750)); + } + + [Cmd] + [Priority(1)] + public async Task Xkcd(int num) + { + if (num < 1) + return; + try + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync($"{XKCD_URL}/{num}/info.0.json"); + + var comic = JsonConvert.DeserializeObject(res); + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithImageUrl(comic.ImageLink) + .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{num}") + .AddField(GetText(strs.comic_number), comic.Num.ToString(), true) + .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true); + + var sent = await Response().Embed(embed).SendAsync(); + + await Task.Delay(10000); + + await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build()); + } + catch (HttpRequestException) + { + await Response().Error(strs.comic_not_found).SendAsync(); + } + } + } + + public class XkcdComic + { + public int Num { get; set; } + public string Month { get; set; } + public string Year { get; set; } + + [JsonProperty("safe_title")] + public string Title { get; set; } + + [JsonProperty("img")] + public string ImageLink { get; set; } + + public string Alt { get; set; } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs new file mode 100644 index 0000000..6cbe065 --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs @@ -0,0 +1,134 @@ +#nullable disable + +// public class YtTrackService : IEService +// { +// private readonly IGoogleApiService _google; +// private readonly IHttpClientFactory httpClientFactory; +// private readonly DiscordSocketClient _client; +// private readonly DbService _db; +// private readonly ConcurrentDictionary>> followedChannels; +// private readonly ConcurrentDictionary _latestPublishes = new ConcurrentDictionary(); +// +// public YtTrackService(IGoogleApiService google, IHttpClientFactory httpClientFactory, DiscordSocketClient client, +// DbService db) +// { +// this._google = google; +// this.httpClientFactory = httpClientFactory; +// this._client = client; +// this._db = db; +// +// if (_client.ShardId == 0) +// { +// _ = CheckLoop(); +// } +// } +// +// public async Task CheckLoop() +// { +// while (true) +// { +// await Task.Delay(10000); +// using (var http = httpClientFactory.CreateClient()) +// { +// await followedChannels.Select(kvp => CheckChannel(kvp.Key, kvp.Value.SelectMany(x => x.Value).ToList())).WhenAll(); +// } +// } +// } +// +// /// +// /// Checks the specified youtube channel, and sends a message to all provided +// /// +// /// Id of the youtube channel +// /// Where to post updates if there is a new update +// private async Task CheckChannel(string youtubeChannelId, List followedChannels) +// { +// var latestVid = (await _google.GetLatestChannelVideosAsync(youtubeChannelId, 1)) +// .FirstOrDefault(); +// if (latestVid is null) +// { +// return; +// } +// +// if (_latestPublishes.TryGetValue(youtubeChannelId, out var latestPub) && latestPub >= latestVid.PublishedAt) +// { +// return; +// } +// _latestPublishes[youtubeChannelId] = latestVid.PublishedAt; +// +// foreach (var chObj in followedChannels) +// { +// var gCh = _client.GetChannel(chObj.ChannelId); +// if (gCh is ITextChannel ch) +// { +// var msg = latestVid.GetVideoUrl(); +// if (!string.IsNullOrWhiteSpace(chObj.UploadMessage)) +// msg = chObj.UploadMessage + Environment.NewLine + msg; +// +// await ch.SendMessageAsync(msg); +// } +// } +// } +// +// /// +// /// Starts posting updates on the specified discord channel when a new video is posted on the specified YouTube channel. +// /// +// /// Id of the discord guild +// /// Id of the discord channel +// /// Id of the youtube channel +// /// Message to post when a new video is uploaded, along with video URL +// /// Whether adding was successful +// public async Task ToggleChannelFollowAsync(ulong guildId, ulong channelId, string ytChannelId, string uploadMessage) +// { +// // to to see if we can get a video from that channel +// var vids = await _google.GetLatestChannelVideosAsync(ytChannelId, 1); +// if (vids.Count == 0) +// return false; +// +// using(var uow = _db.GetDbContext()) +// { +// var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.YtFollowedChannels)); +// +// // see if this yt channel was already followed on this discord channel +// var oldObj = gc.YtFollowedChannels +// .FirstOrDefault(x => x.ChannelId == channelId && x.YtChannelId == ytChannelId); +// +// if(oldObj is not null) +// { +// return false; +// } +// +// // can only add up to 10 tracked channels per server +// if (gc.YtFollowedChannels.Count >= 10) +// { +// return false; +// } +// +// var obj = new YtFollowedChannel +// { +// ChannelId = channelId, +// YtChannelId = ytChannelId, +// UploadMessage = uploadMessage +// }; +// +// // add to database +// gc.YtFollowedChannels.Add(obj); +// +// // add to the local cache: +// +// // get follows on all guilds +// var allGuildFollows = followedChannels.GetOrAdd(ytChannelId, new ConcurrentDictionary>()); +// // add to this guild's follows +// allGuildFollows.AddOrUpdate(guildId, +// new List(), +// (key, old) => +// { +// old.Add(obj); +// return old; +// }); +// +// await uow.SaveChangesAsync(); +// } +// +// return true; +// } +// } \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs new file mode 100644 index 0000000..2439ad4 --- /dev/null +++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs @@ -0,0 +1,54 @@ +#nullable disable +namespace EllieBot.Modules.Searches; + +public partial class Searches +{ + // [Group] + // public partial class YtTrackCommands : EllieModule + // { + // ; + // [RequireContext(ContextType.Guild)] + // public async Task YtFollow(string ytChannelId, [Leftover] string uploadMessage = null) + // { + // var succ = await _service.ToggleChannelFollowAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // if(succ) + // { + // await Response().Confirm(strs.yt_follow_added).SendAsync(); + // } + // else + // { + // await Response().Confirm(strs.yt_follow_fail).SendAsync(); + // } + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackRm(int index) + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // + // [EllieCommand, Usage, Description, Aliases] + // [RequireContext(ContextType.Guild)] + // public async Task YtTrackList() + // { + // //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage); + // //if (succ) + // //{ + // // await Response().Confirm(strs.yt_track_added).SendAsync(); + // //} + // //else + // //{ + // // await Response().Confirm(strs.yt_track_fail).SendAsync(); + // //} + // } + // } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs new file mode 100644 index 0000000..e8ab960 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs @@ -0,0 +1,12 @@ +#nullable disable +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Searches; + +public static class AtlExtensions +{ + public static Task GetByChannelId(this IQueryable set, ulong channelId) + => set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/BibleVerses.cs b/src/EllieBot/Modules/Searches/_common/BibleVerses.cs new file mode 100644 index 0000000..30dd045 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/BibleVerses.cs @@ -0,0 +1,20 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class BibleVerses +{ + public string Error { get; set; } + public BibleVerse[] Verses { get; set; } +} + +public class BibleVerse +{ + [JsonPropertyName("book_name")] + public string BookName { get; set; } + + public int Chapter { get; set; } + public int Verse { get; set; } + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs new file mode 100644 index 0000000..b34fb36 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Searches; + +public enum ImgSearchEngine +{ + Google, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs new file mode 100644 index 0000000..fb64849 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs @@ -0,0 +1,86 @@ +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Modules.Searches; + +[Cloneable] +public partial class SearchesConfig : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 0; + + [Comment(""" + Which engine should .search command + 'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys. + 'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml + 'searx' - requires at least one searx instance specified in the 'searxInstances' property below + """)] + public WebSearchEngine WebSearchEngine { get; set; } = WebSearchEngine.Google_Scrape; + + [Comment(""" + Which engine should .image command use + 'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml + 'searx' requires at least one searx instance specified in the 'searxInstances' property below + """)] + public ImgSearchEngine ImgSearchEngine { get; set; } = ImgSearchEngine.Google; + + + [Comment(""" + Which search provider will be used for the `.youtube` command. + + - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console + + - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow. + + - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables + + - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property + """)] + public YoutubeSearcher YtProvider { get; set; } = YoutubeSearcher.Ytdlp; + + [Comment(""" + Set the searx instance urls in case you want to use 'searx' for either img or web search. + Ellie will use a random one for each request. + Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com` + Instances specified must support 'format=json' query parameter. + - In case you're running your own searx instance, set + + search: + formats: + - json + + in 'searxng/settings.yml' on your server + + - If you're using a public instance, make sure that the instance you're using supports it (they usually don't) + """)] + public List SearxInstances { get; set; } = new List(); + + [Comment(""" + Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search + Ellie will use a random one for each request. + These instances may be used for music queue functionality in the future. + Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com + + Instances specified must have api available. + You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending + """)] + public List InvidiousInstances { get; set; } = new List(); + + [Comment("Maximum number of followed streams per server")] + public FollowedStreamConfig FollowedStreams { get; set; } = new FollowedStreamConfig(); +} + +public sealed class FollowedStreamConfig +{ + [Comment("Maximum number of streams that each server can follow. -1 for infinite")] + public int MaxCount { get; set; } = 10; +} + +public enum YoutubeSearcher +{ + YtDataApiv3, + Ytdl, + Ytdlp, + Invid, + Invidious = 3 +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs new file mode 100644 index 0000000..f656a5b --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs @@ -0,0 +1,58 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Modules.Searches; + +public class SearchesConfigService : ConfigServiceBase +{ + private static string FILE_PATH = "data/searches.yml"; + private static readonly TypedKey _changeKey = new("config.searches.updated"); + + public override string Name + => "searches"; + + public SearchesConfigService(IConfigSeria serializer, IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + AddParsedProp("webEngine", + sc => sc.WebSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("imgEngine", + sc => sc.ImgSearchEngine, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("ytProvider", + sc => sc.YtProvider, + ConfigParsers.InsensitiveEnum, + ConfigPrinters.ToString); + + AddParsedProp("followedStreams.maxCount", + sc => sc.FollowedStreams.MaxCount, + int.TryParse, + ConfigPrinters.ToString); + + Migrate(); + } + + private void Migrate() + { + if (data.Version < 1) + { + ModifyConfig(c => + { + c.Version = 1; + c.WebSearchEngine = WebSearchEngine.Google_Scrape; + }); + } + + if (data.Version < 2) + { + ModifyConfig(c => + { + c.Version = 2; + }); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs new file mode 100644 index 0000000..e924f03 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs @@ -0,0 +1,9 @@ +// ReSharper disable InconsistentNaming +namespace EllieBot.Modules.Searches; + +public enum WebSearchEngine +{ + Google, + Google_Scrape, + Searx, +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/CryptoData.cs b/src/EllieBot/Modules/Searches/_common/CryptoData.cs new file mode 100644 index 0000000..6600b59 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/CryptoData.cs @@ -0,0 +1,66 @@ +#nullable disable +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Searches.Common; + +public class CryptoResponse +{ + public List Data { get; set; } +} + +public class CmcQuote +{ + [JsonPropertyName("price")] + public double Price { get; set; } + + [JsonPropertyName("volume_24h")] + public double Volume24h { get; set; } + + // [JsonPropertyName("volume_change_24h")] + // public double VolumeChange24h { get; set; } + // + // [JsonPropertyName("percent_change_1h")] + // public double PercentChange1h { get; set; } + + [JsonPropertyName("percent_change_24h")] + public double PercentChange24h { get; set; } + + [JsonPropertyName("percent_change_7d")] + public double PercentChange7d { get; set; } + + [JsonPropertyName("market_cap")] + public double MarketCap { get; set; } + + [JsonPropertyName("market_cap_dominance")] + public double MarketCapDominance { get; set; } +} + +public class CmcResponseData +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("symbol")] + public string Symbol { get; set; } + + [JsonPropertyName("slug")] + public string Slug { get; set; } + + [JsonPropertyName("cmc_rank")] + public int CmcRank { get; set; } + + [JsonPropertyName("circulating_supply")] + public double? CirculatingSupply { get; set; } + + [JsonPropertyName("total_supply")] + public double? TotalSupply { get; set; } + + [JsonPropertyName("max_supply")] + public double? MaxSupply { get; set; } + + [JsonPropertyName("quote")] + public Dictionary Quote { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Searches/_common/DefineModel.cs b/src/EllieBot/Modules/Searches/_common/DefineModel.cs new file mode 100644 index 0000000..a0e2018 --- /dev/null +++ b/src/EllieBot/Modules/Searches/_common/DefineModel.cs @@ -0,0 +1,43 @@ +#nullable disable +using Newtonsoft.Json; + +namespace EllieBot.Modules.Searches.Common; + +public class Audio +{ + public string Url { get; set; } +} + +public class Example +{ + public List