diff --git a/src/EllieBot/Modules/Utility/AfkService.cs b/src/EllieBot/Modules/Utility/AfkService.cs new file mode 100644 index 0000000..7f8ede4 --- /dev/null +++ b/src/EllieBot/Modules/Utility/AfkService.cs @@ -0,0 +1,148 @@ +using EllieBot.Common.ModuleBehaviors; + +namespace EllieBot.Modules.Utility; + +public sealed class AfkService : IEService, IReadyExecutor +{ + private readonly IBotCache _cache; + private readonly DiscordSocketClient _client; + private readonly MessageSenderService _mss; + + private static readonly TimeSpan _maxAfkDuration = 8.Hours(); + public AfkService(IBotCache cache, DiscordSocketClient client, MessageSenderService mss) + { + _cache = cache; + _client = client; + _mss = mss; + } + + private static TypedKey GetKey(ulong userId) + => new($"afk:msg:{userId}"); + + public async Task SetAfkAsync(ulong userId, string text) + { + var added = await _cache.AddAsync(GetKey(userId), text, _maxAfkDuration, overwrite: true); + + async Task StopAfk(SocketMessage socketMessage) + { + try + { + if (socketMessage.Author?.Id == userId) + { + await _cache.RemoveAsync(GetKey(userId)); + _client.MessageReceived -= StopAfk; + + // write the message saying afk status cleared + + if (socketMessage.Channel is ITextChannel tc) + { + _ = Task.Run(async () => + { + var msg = await _mss.Response(tc).Confirm("AFK message cleared!").SendAsync(); + + msg.DeleteAfter(5); + }); + } + + } + + } + catch (Exception ex) + { + Log.Warning("Unexpected error occurred while trying to stop afk: {Message}", ex.Message); + } + } + + _client.MessageReceived += StopAfk; + + + _ = Task.Run(async () => + { + await Task.Delay(_maxAfkDuration); + _client.MessageReceived -= StopAfk; + }); + + return added; + } + + public Task OnReadyAsync() + { + _client.MessageReceived += TryTriggerAfkMessage; + + return Task.CompletedTask; + } + + private Task TryTriggerAfkMessage(SocketMessage arg) + { + if (arg.Author.IsBot || arg.Author.IsWebhook) + return Task.CompletedTask; + + if (arg is not IUserMessage uMsg || uMsg.Channel is not ITextChannel tc) + return Task.CompletedTask; + + if ((arg.MentionedUsers.Count is 0 or > 3) && uMsg.ReferencedMessage is null) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + var botUser = await tc.Guild.GetCurrentUserAsync(); + + var perms = botUser.GetPermissions(tc); + + if (!perms.SendMessages) + return; + + ulong mentionedUserId = 0; + + if (arg.MentionedUsers.Count <= 3) + { + foreach (var uid in uMsg.MentionedUserIds) + { + if (uid == arg.Author.Id) + continue; + + if (arg.Content.StartsWith($"<@{uid}>") || arg.Content.StartsWith($"<@!{uid}>")) + { + mentionedUserId = uid; + break; + } + } + } + + if (mentionedUserId == 0) + { + if (uMsg.ReferencedMessage?.Author?.Id is not ulong repliedUserId) + { + return; + } + + mentionedUserId = repliedUserId; + } + + try + { + var result = await _cache.GetAsync(GetKey(mentionedUserId)); + if (result.TryPickT0(out var msg, out _)) + { + var st = SmartText.CreateFrom(msg); + + st = $"The user you've pinged (<#{mentionedUserId}>) is AFK: " + st; + + var toDelete = await _mss.Response(arg.Channel) + .User(arg.Author) + .Message(uMsg) + .Text(st) + .SendAsync(); + + toDelete.DeleteAfter(30); + } + } + catch (HttpException ex) + { + Log.Warning("Error in afk service: {Message}", ex.Message); + } + }); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/AiAssistantService.cs b/src/EllieBot/Modules/Utility/Ai/AiAssistantService.cs new file mode 100644 index 0000000..d5ec546 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/AiAssistantService.cs @@ -0,0 +1,314 @@ +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Administration; +using EllieBot.Modules.Games.Services; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace EllieBot.Modules.Utility; + +public enum GetCommandErrorResult +{ + RateLimitHit, + NotAuthorized, + Disregard, + Unknown +} + +public sealed class AiAssistantService + : IAiAssistantService, IReadyExecutor, + IExecOnMessage, + IEService +{ + private IReadOnlyCollection _commands = []; + + private readonly IBotStrings _strings; + private readonly IHttpClientFactory _httpFactory; + private readonly CommandService _cmds; + private readonly IBotCredsProvider _credsProvider; + private readonly DiscordSocketClient _client; + private readonly ICommandHandler _cmdHandler; + private readonly BotConfigService _bcs; + private readonly IMessageSenderService _sender; + + private readonly JsonSerializerOptions _serializerOptions = new(); + private readonly IPermissionChecker _permChecker; + private readonly IBotCache _botCache; + private readonly ChatterBotService _cbs; + + public AiAssistantService( + DiscordSocketClient client, + IBotStrings strings, + IHttpClientFactory httpFactory, + CommandService cmds, + IBotCredsProvider credsProvider, + ICommandHandler cmdHandler, + BotConfigService bcs, + IPermissionChecker permChecker, + IBotCache botCache, + ChatterBotService cbs, + IMessageSenderService sender) + { + _client = client; + _strings = strings; + _httpFactory = httpFactory; + _cmds = cmds; + _credsProvider = credsProvider; + _cmdHandler = cmdHandler; + _bcs = bcs; + _sender = sender; + _permChecker = permChecker; + _botCache = botCache; + _cbs = cbs; + } + + public async Task> TryGetCommandAsync( + ulong userId, + string prompt, + IReadOnlyCollection commands, + string prefix) + { + using var content = new StringContent( + JsonSerializer.Serialize(new + { + query = prompt, + commands = commands.ToDictionary(x => x.Name, + x => new AiCommandModel() + { + Desc = string.Format(x.Desc ?? "", prefix), + Params = x.Params, + Name = x.Name + }), + }), + Encoding.UTF8, + "application/json" + ); + + using var request = new HttpRequestMessage(); + request.Method = HttpMethod.Post; + // request.RequestUri = new("https://eai.elliebot.net/get-command"); + request.RequestUri = new("https://eai.elliebot.net/get-command"); + request.Content = content; + + var creds = _credsProvider.GetCreds(); + + request.Headers.TryAddWithoutValidation("x-auth-token", creds.EllieAiToken); + request.Headers.TryAddWithoutValidation("x-auth-userid", userId.ToString()); + + + using var client = _httpFactory.CreateClient(); + + // todo customize according to the bot's config + // - CurrencyName + // - + + using var response = await client.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + return GetCommandErrorResult.RateLimitHit; + } + else if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return GetCommandErrorResult.NotAuthorized; + } + + var funcModel = await response.Content.ReadFromJsonAsync(); + + + if (funcModel?.Name == "disregard") + { + Log.Warning("Disregarding the prompt: {Prompt}", prompt); + return GetCommandErrorResult.Disregard; + } + + if (funcModel is null) + return GetCommandErrorResult.Unknown; + + var comModel = new EllieCommandCallModel() + { + Name = funcModel.Name, + Arguments = funcModel.Arguments + .OrderBy(param => _commands.FirstOrDefault(x => x.Name == funcModel.Name) + ?.Params + .Select((x, i) => (x, i)) + .Where(x => x.x.Name == param.Key) + .Select(x => x.i) + .FirstOrDefault()) + .Select(x => x.Value) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToArray(), + Remaining = funcModel.Remaining + }; + + return comModel; + } + + public IReadOnlyCollection GetCommands() + => _commands; + + public Task OnReadyAsync() + { + var cmds = _cmds.Commands + .Select(x => (MethodName: x.Summary, CommandName: x.Aliases[0])) + .Where(x => !x.MethodName.Contains("///")) + .Distinct() + .ToList(); + + var funcs = new List(); + foreach (var (method, cmd) in cmds) + { + var commandStrings = _strings.GetCommandStrings(method); + + if (commandStrings is null) + continue; + + funcs.Add(new() + { + Name = cmd, + Desc = commandStrings?.Desc?.Replace("currency", "flowers") ?? string.Empty, + Params = commandStrings?.Params.FirstOrDefault() + ?.Select(x => new AiCommandParamModel() + { + Desc = x.Value.Desc, + Name = x.Key, + }) + .ToArray() + ?? [] + }); + } + + _commands = funcs; + + return Task.CompletedTask; + } + + public int Priority + => 2; + + public async Task ExecOnMessageAsync(IGuild guild, IUserMessage msg) + { + if (string.IsNullOrWhiteSpace(_credsProvider.GetCreds().EllieAiToken)) + return false; + + if (guild is not SocketGuild sg) + return false; + + var ellieId = _client.CurrentUser.Id; + + var channel = msg.Channel as ITextChannel; + if (channel is null) + return false; + + var normalMention = $"<@{ellieId}> "; + var nickMention = $"<@!{ellieId}> "; + string query; + if (msg.Content.StartsWith(normalMention, StringComparison.InvariantCulture)) + query = msg.Content[normalMention.Length..].Trim(); + else if (msg.Content.StartsWith(nickMention, StringComparison.InvariantCulture)) + query = msg.Content[nickMention.Length..].Trim(); + else + return false; + + var success = await TryExecuteAiCommand(guild, msg, channel, query); + + return success; + } + + public async Task TryExecuteAiCommand( + IGuild guild, + IUserMessage msg, + ITextChannel channel, + string query) + { + // check permissions + var pcResult = await _permChecker.CheckPermsAsync( + guild, + msg.Channel, + msg.Author, + "Utility", + "prompt" + ); + + if (!pcResult.IsAllowed) + return false; + + using var _ = channel.EnterTypingState(); + + var result = await TryGetCommandAsync(msg.Author.Id, query, _commands, _cmdHandler.GetPrefix(guild.Id)); + + if (result.TryPickT0(out var model, out var error)) + { + if (model.Name == ".ai_chat") + { + if (guild is not SocketGuild sg) + return false; + + var sess = _cbs.GetOrCreateSession(guild.Id); + if (sess is null) + return false; + + await _cbs.RunChatterBot(sg, msg, channel, sess, query); + return true; + } + + var commandString = GetCommandString(model); + + var msgTask = _sender.Response(channel) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithAuthor(msg.Author.GlobalName, + msg.Author.RealAvatarUrl().ToString()) + .WithDescription(commandString)) + .SendAsync(); + + + await _cmdHandler.TryRunCommand( + (SocketGuild)guild, + (ISocketMessageChannel)channel, + new DoAsUserMessage((SocketUserMessage)msg, msg.Author, commandString)); + + var cmdMsg = await msgTask; + + cmdMsg.DeleteAfter(5); + + return true; + } + + if (error == GetCommandErrorResult.Disregard) + { + // await msg.ErrorAsync(); + return false; + } + + var key = new TypedKey($"sub_error:{msg.Author.Id}:{error}"); + + if (!await _botCache.AddAsync(key, true, TimeSpan.FromDays(1), overwrite: false)) + return false; + + var errorMsg = error switch + { + GetCommandErrorResult.RateLimitHit + => "You've spent your daily requests quota.", + GetCommandErrorResult.NotAuthorized + => "In order to use this command you have to have a 5$ or higher subscription at ", + GetCommandErrorResult.Unknown + => "The service is temporarily unavailable.", + _ => throw new ArgumentOutOfRangeException() + }; + + await _sender.Response(channel) + .Error(errorMsg) + .SendAsync(); + + return true; + } + + private string GetCommandString(EllieCommandCallModel res) + => $"{_bcs.Data.Prefix}{res.Name} {res.Arguments.Select((x, i) => GetParamString(x, i + 1 == res.Arguments.Count)).Join(" ")}"; + + private static string GetParamString(string val, bool isLast) + => isLast ? val : "\"" + val + "\""; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/AiCommandModel.cs b/src/EllieBot/Modules/Utility/Ai/AiCommandModel.cs new file mode 100644 index 0000000..eeaf38b --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/AiCommandModel.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Utility; + +public sealed class AiCommandModel +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("desc")] + public required string Desc { get; set; } + + [JsonPropertyName("params")] + public required IReadOnlyList Params { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/AiCommandParamModel.cs b/src/EllieBot/Modules/Utility/Ai/AiCommandParamModel.cs new file mode 100644 index 0000000..594d581 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/AiCommandParamModel.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Utility; + +public sealed class AiCommandParamModel +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("desc")] + public required string Desc { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/CommandPromptResultModel.cs b/src/EllieBot/Modules/Utility/Ai/CommandPromptResultModel.cs new file mode 100644 index 0000000..46b5f94 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/CommandPromptResultModel.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Utility; + +public sealed class CommandPromptResultModel +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("arguments")] + public Dictionary Arguments { get; set; } = new(); + + [JsonPropertyName("remaining")] + [JsonConverter(typeof(NumberToStringConverter))] + public string Remaining { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/EllieCommandCallModel.cs b/src/EllieBot/Modules/Utility/Ai/EllieCommandCallModel.cs new file mode 100644 index 0000000..4de388e --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/EllieCommandCallModel.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Utility; + +public sealed class EllieCommandCallModel +{ + public required string Name { get; set; } + public required IReadOnlyList Arguments { get; set; } + public required string Remaining { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/IAiAssistantService.cs b/src/EllieBot/Modules/Utility/Ai/IAiAssistantService.cs new file mode 100644 index 0000000..0fc17e2 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/IAiAssistantService.cs @@ -0,0 +1,20 @@ +using OneOf; + +namespace EllieBot.Modules.Utility; + +public interface IAiAssistantService +{ + Task> TryGetCommandAsync( + ulong userId, + string prompt, + IReadOnlyCollection commands, + string prefix); + + IReadOnlyCollection GetCommands(); + + Task TryExecuteAiCommand( + IGuild guild, + IUserMessage msg, + ITextChannel channel, + string query); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Ai/UtilityCommands.cs b/src/EllieBot/Modules/Utility/Ai/UtilityCommands.cs new file mode 100644 index 0000000..1fd6cfd --- /dev/null +++ b/src/EllieBot/Modules/Utility/Ai/UtilityCommands.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class PromptCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Prompt([Leftover] string query) + { + await ctx.Channel.TriggerTypingAsync(); + var res = await _service.TryExecuteAiCommand(ctx.Guild, ctx.Message, (ITextChannel)ctx.Channel, query); + } + + private string GetCommandString(EllieCommandCallModel res) + => $"{_bcs.Data.Prefix}{res.Name} {res.Arguments.Select((x, i) => GetParamString(x, i + 1 == res.Arguments.Count)).Join(" ")}"; + + private static string GetParamString(string val, bool isLast) + => isLast ? val : "\"" + val + "\""; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Alias/AliasCommands.cs b/src/EllieBot/Modules/Utility/Alias/AliasCommands.cs new file mode 100644 index 0000000..7a4641d --- /dev/null +++ b/src/EllieBot/Modules/Utility/Alias/AliasCommands.cs @@ -0,0 +1,138 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Modules.Utility.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class CommandMapCommands : EllieModule + { + private readonly DbService _db; + private readonly DiscordSocketClient _client; + + public CommandMapCommands(DbService db, DiscordSocketClient client) + { + _db = db; + _client = client; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task AliasesClear() + { + var count = _service.ClearAliases(ctx.Guild.Id); + await Response().Confirm(strs.aliases_cleared(count)).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.Administrator)] + [RequireContext(ContextType.Guild)] + public async Task Alias(string trigger, [Leftover] string mapping = null) + { + if (string.IsNullOrWhiteSpace(trigger)) + return; + + trigger = trigger.Trim().ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(mapping)) + { + if (!_service.AliasMaps.TryGetValue(ctx.Guild.Id, out var maps) || !maps.TryRemove(trigger, out _)) + { + await Response().Error(strs.alias_remove_fail(Format.Code(trigger))).SendAsync(); + return; + } + + await using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + var tr = config.CommandAliases.FirstOrDefault(x => x.Trigger == trigger); + if (tr is not null) + uow.Set().Remove(tr); + uow.SaveChanges(); + } + + await Response().Confirm(strs.alias_removed(Format.Code(trigger))).SendAsync(); + return; + } + + _service.AliasMaps.AddOrUpdate(ctx.Guild.Id, + _ => + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + config.CommandAliases.Add(new() + { + Mapping = mapping, + Trigger = trigger + }); + uow.SaveChanges(); + } + + return new(new Dictionary + { + { trigger.Trim().ToLowerInvariant(), mapping.ToLowerInvariant() } + }); + }, + (_, map) => + { + using (var uow = _db.GetDbContext()) + { + var config = uow.GuildConfigsForId(ctx.Guild.Id, set => set.Include(x => x.CommandAliases)); + var toAdd = new CommandAlias + { + Mapping = mapping, + Trigger = trigger + }; + var toRemove = config.CommandAliases.Where(x => x.Trigger == trigger).ToArray(); + if (toRemove.Any()) + uow.RemoveRange(toRemove); + config.CommandAliases.Add(toAdd); + uow.SaveChanges(); + } + + map.AddOrUpdate(trigger, mapping, (_, _) => mapping); + return map; + }); + + await Response().Confirm(strs.alias_added(Format.Code(trigger), Format.Code(mapping))).SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task AliasList(int page = 1) + { + page -= 1; + + if (page < 0) + return; + + if (!_service.AliasMaps.TryGetValue(ctx.Guild.Id, out var maps) || !maps.Any()) + { + await Response().Error(strs.aliases_none).SendAsync(); + return; + } + + var arr = maps.ToArray(); + + await Response() + .Paginated() + .Items(arr) + .PageSize(10) + .CurrentPage(page) + .Page((items, _) => + { + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.alias_list)) + .WithDescription(string.Join("\n", items.Select(x => $"`{x.Key}` => `{x.Value}`"))); + }) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Alias/AliasService.cs b/src/EllieBot/Modules/Utility/Alias/AliasService.cs new file mode 100644 index 0000000..876f155 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Alias/AliasService.cs @@ -0,0 +1,99 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.Services; + +public class AliasService : IInputTransformer, IEService +{ + public ConcurrentDictionary> AliasMaps { get; } = new(); + + private readonly DbService _db; + private readonly IMessageSenderService _sender; + + public AliasService( + DiscordSocketClient client, + DbService db, + IMessageSenderService sender) + { + _sender = sender; + + using var uow = db.GetDbContext(); + var guildIds = client.Guilds.Select(x => x.Id).ToList(); + var configs = uow.Set() + .Include(gc => gc.CommandAliases) + .Where(x => guildIds.Contains(x.GuildId)) + .ToList(); + + AliasMaps = new(configs.ToDictionary(x => x.GuildId, + x => new ConcurrentDictionary(x.CommandAliases.DistinctBy(ca => ca.Trigger) + .ToDictionary(ca => ca.Trigger, ca => ca.Mapping), + StringComparer.OrdinalIgnoreCase))); + + _db = db; + } + + public int ClearAliases(ulong guildId) + { + AliasMaps.TryRemove(guildId, out _); + + int count; + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.CommandAliases)); + count = gc.CommandAliases.Count; + gc.CommandAliases.Clear(); + uow.SaveChanges(); + return count; + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (guild is null || string.IsNullOrWhiteSpace(input)) + return null; + + if (AliasMaps.TryGetValue(guild.Id, out var maps)) + { + string newInput = null; + foreach (var (k, v) in maps) + { + if (string.Equals(input, k, StringComparison.OrdinalIgnoreCase)) + { + newInput = v; + } + else if (input.StartsWith(k + ' ', StringComparison.OrdinalIgnoreCase)) + { + if (v.Contains("%target%")) + newInput = v.Replace("%target%", input[k.Length..]); + else + newInput = v + ' ' + input[k.Length..]; + } + + if (newInput is not null) + { + try + { + var toDelete = await _sender.Response(channel) + .Confirm($"{input} => {newInput}") + .SendAsync(); + toDelete.DeleteAfter(1.5f); + } + catch + { + // ignored + } + + return newInput; + } + } + + return null; + } + + return null; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Calc/CalcCommands.cs b/src/EllieBot/Modules/Utility/Calc/CalcCommands.cs new file mode 100644 index 0000000..985e81b --- /dev/null +++ b/src/EllieBot/Modules/Utility/Calc/CalcCommands.cs @@ -0,0 +1,48 @@ +#nullable disable +using NCalc; +using System.Reflection; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class CalcCommands : EllieModule + { + [Cmd] + public async Task Calculate([Leftover] string expression) + { + var expr = new Expression(expression, EvaluateOptions.IgnoreCase | EvaluateOptions.NoCache); + expr.EvaluateParameter += Expr_EvaluateParameter; + var result = expr.Evaluate(); + if (!expr.HasErrors()) + await Response().Confirm("⚙ " + GetText(strs.result), result.ToString()).SendAsync(); + else + await Response().Error("⚙ " + GetText(strs.error), expr.Error).SendAsync(); + } + + private static void Expr_EvaluateParameter(string name, ParameterArgs args) + { + switch (name.ToLowerInvariant()) + { + case "pi": + args.Result = Math.PI; + break; + case "e": + args.Result = Math.E; + break; + } + } + + [Cmd] + public async Task CalcOps() + { + var selection = typeof(Math).GetTypeInfo() + .GetMethods() + .DistinctBy(x => x.Name) + .Select(x => x.Name) + .Except(new[] { "ToString", "Equals", "GetHashCode", "GetType" }); + await Response().Confirm(GetText(strs.calcops(prefix)), string.Join(", ", selection)).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/ConfigCommands.cs b/src/EllieBot/Modules/Utility/ConfigCommands.cs new file mode 100644 index 0000000..57b8f4c --- /dev/null +++ b/src/EllieBot/Modules/Utility/ConfigCommands.cs @@ -0,0 +1,154 @@ +#nullable disable +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + public partial class ConfigCommands : EllieModule + { + private readonly IEnumerable _settingServices; + + public ConfigCommands(IEnumerable settingServices) + => _settingServices = settingServices.Where(x => x.Name != "medusa"); + + [Cmd] + [OwnerOnly] + public async Task ConfigReload(string name) + { + var setting = _settingServices.FirstOrDefault(x + => x.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); + + if (setting is null) + { + var configNames = _settingServices.Select(x => x.Name); + var embed = _sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.config_not_found(Format.Code(name)))) + .AddField(GetText(strs.config_list), string.Join("\n", configNames)); + + await Response().Embed(embed).SendAsync(); + return; + } + + setting.Reload(); + await ctx.OkAsync(); + } + + [Cmd] + [OwnerOnly] + public async Task Config(string name = null, string prop = null, [Leftover] string value = null) + { + var configNames = _settingServices.Select(x => x.Name); + + // if name is not provided, print available configs + name = name?.ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(name)) + { + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.config_list)) + .WithDescription(string.Join("\n", configNames)); + + await Response().Embed(embed).SendAsync(); + return; + } + + var setting = _settingServices.FirstOrDefault(x + => x.Name.StartsWith(name, StringComparison.InvariantCultureIgnoreCase)); + + // if config name is not found, print error and the list of configs + if (setting is null) + { + var embed = _sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.config_not_found(Format.Code(name)))) + .AddField(GetText(strs.config_list), string.Join("\n", configNames)); + + await Response().Embed(embed).SendAsync(); + return; + } + + name = setting.Name; + + // if prop is not sent, then print the list of all props and values in that config + prop = prop?.ToLowerInvariant(); + var propNames = setting.GetSettableProps(); + if (string.IsNullOrWhiteSpace(prop)) + { + var propStrings = GetPropsAndValuesString(setting, propNames); + var embed = _sender.CreateEmbed().WithOkColor().WithTitle($"⚙️ {setting.Name}").WithDescription(propStrings); + + + await Response().Embed(embed).SendAsync(); + return; + } + // if the prop is invalid -> print error and list of + + var exists = propNames.Any(x => x == prop); + + if (!exists) + { + var propStrings = GetPropsAndValuesString(setting, propNames); + var propErrorEmbed = _sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText( + strs.config_prop_not_found(Format.Code(prop), Format.Code(name)))) + .AddField($"⚙️ {setting.Name}", propStrings); + + await Response().Embed(propErrorEmbed).SendAsync(); + return; + } + + // if prop is sent, but value is not, then we have to check + // if prop is valid -> + if (string.IsNullOrWhiteSpace(value)) + { + value = setting.GetSetting(prop); + + if (string.IsNullOrWhiteSpace(value)) + value = "-"; + + if (prop != "currency.sign") + value = Format.Code(Format.Sanitize(value.TrimTo(1000)), "json"); + + var embed = _sender.CreateEmbed() + .WithOkColor() + .AddField("Config", Format.Code(setting.Name), true) + .AddField("Prop", Format.Code(prop), true) + .AddField("Value", value); + + var comment = setting.GetComment(prop); + if (!string.IsNullOrWhiteSpace(comment)) + embed.AddField("Comment", comment); + + await Response().Embed(embed).SendAsync(); + return; + } + + var success = setting.SetSetting(prop, value); + + if (!success) + { + await Response().Error(strs.config_edit_fail(Format.Code(prop), Format.Code(value))).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + private string GetPropsAndValuesString(IConfigService config, IReadOnlyCollection names) + { + var propValues = names.Select(pr => + { + var val = config.GetSetting(pr); + if (pr != "currency.sign") + val = val?.TrimTo(28); + return val?.Replace("\n", "") ?? "-"; + }) + .ToList(); + + var strings = names.Zip(propValues, (name, value) => $"{name,-25} = {value}\n"); + + return Format.Code(string.Concat(strings), "hs"); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Giveaway/GiveawayCommands.cs b/src/EllieBot/Modules/Utility/Giveaway/GiveawayCommands.cs new file mode 100644 index 0000000..2109eef --- /dev/null +++ b/src/EllieBot/Modules/Utility/Giveaway/GiveawayCommands.cs @@ -0,0 +1,118 @@ +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Name("Giveaways")] + [Group("ga")] + public partial class GiveawayCommands : EllieModule + { + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + [BotPerm(ChannelPerm.ManageMessages | ChannelPerm.AddReactions)] + public async Task GiveawayStart(TimeSpan duration, [Leftover] string message) + { + if (duration > TimeSpan.FromDays(30)) + { + await Response().Error(strs.giveaway_duration_invalid).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithPendingColor() + .WithTitle(GetText(strs.giveaway_starting)) + .WithDescription(message); + + var startingMsg = await Response().Embed(eb).SendAsync(); + + var maybeId = + await _service.StartGiveawayAsync(ctx.Guild.Id, ctx.Channel.Id, startingMsg.Id, duration, message); + + + if (maybeId is not int id) + { + await startingMsg.DeleteAsync(); + await Response().Error(strs.giveaway_max_amount_reached).SendAsync(); + return; + } + + eb + .WithOkColor() + .WithTitle(GetText(strs.giveaway_started)) + .WithFooter($"id: {new kwum(id).ToString()}"); + + await startingMsg.AddReactionAsync(new Emoji(GiveawayService.GiveawayEmoji)); + await startingMsg.ModifyAsync(x => x.Embed = eb.Build()); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + public async Task GiveawayEnd(kwum id) + { + var success = await _service.EndGiveawayAsync(ctx.Guild.Id, id); + + if(!success) + { + await Response().Error(strs.giveaway_not_found).SendAsync(); + return; + } + + await ctx.OkAsync(); + _ = ctx.Message.DeleteAfter(5); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + public async Task GiveawayReroll(kwum id) + { + var success = await _service.RerollGiveawayAsync(ctx.Guild.Id, id); + if (!success) + { + await Response().Error(strs.giveaway_not_found).SendAsync(); + return; + } + + + await ctx.OkAsync(); + _ = ctx.Message.DeleteAfter(5); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + public async Task GiveawayCancel(kwum id) + { + var success = await _service.CancelGiveawayAsync(ctx.Guild.Id, id); + + if (!success) + { + await Response().Confirm(strs.giveaway_not_found).SendAsync(); + return; + } + + await Response().Confirm(strs.giveaway_cancelled).SendAsync(); + } + + [Cmd] + [UserPerm(GuildPerm.ManageMessages)] + public async Task GiveawayList() + { + var giveaways = await _service.GetGiveawaysAsync(ctx.Guild.Id); + + if (!giveaways.Any()) + { + await Response().Error(strs.no_givaways).SendAsync(); + return; + } + + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.giveaway_list)) + .WithOkColor(); + + foreach (var g in giveaways) + { + eb.AddField($"id: {new kwum(g.Id)}", g.Message, true); + } + + await Response().Embed(eb).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Giveaway/GiveawayService.cs b/src/EllieBot/Modules/Utility/Giveaway/GiveawayService.cs new file mode 100644 index 0000000..200c88a --- /dev/null +++ b/src/EllieBot/Modules/Utility/Giveaway/GiveawayService.cs @@ -0,0 +1,359 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class GiveawayService : IEService, IReadyExecutor +{ + public static string GiveawayEmoji = "🎉"; + + private readonly DbService _db; + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + private readonly IMessageSenderService _sender; + private readonly IBotStrings _strings; + private readonly ILocalization _localization; + private readonly IMemoryCache _cache; + private SortedSet _giveawayCache = new SortedSet(); + private readonly EllieRandom _rng; + + public GiveawayService(DbService db, IBotCredentials creds, DiscordSocketClient client, + IMessageSenderService sender, IBotStrings strings, ILocalization localization, IMemoryCache cache) + { + _db = db; + _creds = creds; + _client = client; + _sender = sender; + _strings = strings; + _localization = localization; + _cache = cache; + _rng = new EllieRandom(); + + + _client.ReactionAdded += OnReactionAdded; + _client.ReactionRemoved += OnReactionRemoved; + } + + private async Task OnReactionRemoved(Cacheable msg, + Cacheable arg2, + SocketReaction r) + { + if (!r.User.IsSpecified) + return; + + var user = r.User.Value; + + if (user.IsBot || user.IsWebhook) + return; + + if (r.Emote is Emoji e && e.Name == GiveawayEmoji) + { + await LeaveGivawayAsync(msg.Id, user.Id); + } + } + + private async Task OnReactionAdded(Cacheable msg, Cacheable ch, + SocketReaction r) + { + if (!r.User.IsSpecified) + return; + + var user = r.User.Value; + + if (user.IsBot || user.IsWebhook) + return; + + var textChannel = ch.Value as ITextChannel; + + if (textChannel is null) + return; + + + if (r.Emote is Emoji e && e.Name == GiveawayEmoji) + { + await JoinGivawayAsync(msg.Id, user.Id, user.Username); + } + } + + public async Task OnReadyAsync() + { + // load giveaways for this shard from the database + await using var ctx = _db.GetDbContext(); + + var gas = await ctx + .GetTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId)) + .ToArrayAsync(); + + lock (_giveawayCache) + { + _giveawayCache = new(gas, Comparer.Create((x, y) => x.EndsAt.CompareTo(y.EndsAt))); + } + + var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + + while (await timer.WaitForNextTickAsync()) + { + IEnumerable toEnd; + lock (_giveawayCache) + { + toEnd = _giveawayCache.TakeWhile( + x => x.EndsAt <= DateTime.UtcNow.AddSeconds(15)) + .ToArray(); + } + + foreach (var ga in toEnd) + { + try + { + await EndGiveawayAsync(ga.GuildId, ga.Id); + } + catch + { + Log.Warning("Failed to end the giveaway with id {Id}", ga.Id); + } + } + } + } + + public async Task StartGiveawayAsync(ulong guildId, ulong channelId, ulong messageId, TimeSpan duration, + string message) + { + await using var ctx = _db.GetDbContext(); + + // first check if there are more than 5 giveaways + var count = await ctx + .GetTable() + .CountAsync(x => x.GuildId == guildId); + + if (count >= 5) + return null; + + var endsAt = DateTime.UtcNow + duration; + var ga = await ctx.GetTable() + .InsertWithOutputAsync(() => new GiveawayModel + { + GuildId = guildId, + MessageId = messageId, + ChannelId = channelId, + Message = message, + EndsAt = endsAt, + }); + + lock (_giveawayCache) + { + _giveawayCache.Add(ga); + } + + return ga.Id; + } + + + public async Task EndGiveawayAsync(ulong guildId, int id) + { + await using var ctx = _db.GetDbContext(); + + var giveaway = await ctx + .GetTable() + .Where(x => x.GuildId == guildId && x.Id == id) + .LoadWith(x => x.Participants) + .FirstOrDefaultAsyncLinqToDB(); + + if (giveaway is null) + return false; + + await ctx + .GetTable() + .Where(x => x.Id == id) + .DeleteAsync(); + + lock (_giveawayCache) + { + _giveawayCache.Remove(giveaway); + } + + var winner = PickWinner(giveaway); + + await OnGiveawayEnded(giveaway, winner); + + return true; + } + + private GiveawayUser? PickWinner(GiveawayModel giveaway) + { + if (giveaway.Participants.Count == 0) + return default; + + if (giveaway.Participants.Count == 1) + { + // as this is the last participant, rerolls no longer possible + _cache.Remove($"reroll:{giveaway.Id}"); + return giveaway.Participants[0]; + } + + var winner = giveaway.Participants[_rng.Next(0, giveaway.Participants.Count - 1)]; + + HandleWinnerSelection(giveaway, winner); + return winner; + } + + public async Task RerollGiveawayAsync(ulong guildId, int giveawayId) + { + var rerollModel = _cache.Get("reroll:" + giveawayId); + + if (rerollModel is null) + return false; + + var winner = PickWinner(rerollModel.Giveaway); + + if (winner is not null) + { + await OnGiveawayEnded(rerollModel.Giveaway, winner); + } + + return true; + } + + public async Task CancelGiveawayAsync(ulong guildId, int id) + { + await using var ctx = _db.GetDbContext(); + + var ga = await ctx + .GetTable() + .Where(x => x.GuildId == guildId && x.Id == id) + .DeleteWithOutputAsync(); + + if (ga is not { Length: > 0 }) + return false; + + lock (_giveawayCache) + { + _giveawayCache.Remove(ga[0]); + } + + return true; + } + + public async Task> GetGiveawaysAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + + return await ctx + .GetTable() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + public async Task JoinGivawayAsync(ulong messageId, ulong userId, string userName) + { + await using var ctx = _db.GetDbContext(); + + var giveaway = await ctx + .GetTable() + .Where(x => x.MessageId == messageId) + .FirstOrDefaultAsyncLinqToDB(); + + if (giveaway is null) + return false; + + // add the user to the database + await ctx.GetTable() + .InsertAsync( + () => new GiveawayUser() + { + UserId = userId, + GiveawayId = giveaway.Id, + Name = userName, + } + ); + + return true; + } + + public async Task LeaveGivawayAsync(ulong messageId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + + var giveaway = await ctx + .GetTable() + .Where(x => x.MessageId == messageId) + .FirstOrDefaultAsyncLinqToDB(); + + if (giveaway is null) + return false; + + await ctx + .GetTable() + .Where(x => x.UserId == userId && x.GiveawayId == giveaway.Id) + .DeleteAsync(); + + return true; + } + + public async Task OnGiveawayEnded(GiveawayModel ga, GiveawayUser? winner) + { + var culture = _localization.GetCultureInfo(ga.GuildId); + + string GetText(LocStr str) + => _strings.GetText(str, culture); + + var ch = _client.GetChannel(ga.ChannelId) as ITextChannel; + if (ch is null) + return; + + var msg = await ch.GetMessageAsync(ga.MessageId) as IUserMessage; + if (msg is null) + return; + + var winnerStr = winner is null + ? "-" + : $""" + {winner.Name} + <@{winner.UserId}> + {Format.Code(winner.UserId.ToString())} + """; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.giveaway_ended)) + .WithDescription(ga.Message) + .WithFooter($"id: {new kwum(ga.Id).ToString()}") + .AddField(GetText(strs.winner), + winnerStr, + true); + + try + { + await msg.ModifyAsync(x => x.Embed = eb.Build()); + } + catch + { + _ = msg.DeleteAsync(); + await _sender.Response(ch).Embed(eb).SendAsync(); + } + } + + private void HandleWinnerSelection(GiveawayModel ga, GiveawayUser winner) + { + ga.Participants = ga.Participants.Where(x => x.UserId != winner.UserId).ToList(); + + var rerollData = new GiveawayRerollData(ga); + _cache.Set($"reroll:{ga.Id}", rerollData, TimeSpan.FromDays(1)); + } +} + +public sealed class GiveawayRerollData +{ + public GiveawayModel Giveaway { get; init; } + public DateTime ExpiresAt { get; init; } + + public GiveawayRerollData(GiveawayModel ga) + { + Giveaway = ga; + + ExpiresAt = DateTime.UtcNow.AddDays(1); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/GuildColors.cs b/src/EllieBot/Modules/Utility/GuildColors.cs new file mode 100644 index 0000000..7f787e6 --- /dev/null +++ b/src/EllieBot/Modules/Utility/GuildColors.cs @@ -0,0 +1,39 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public interface IGuildColorsService +{ + +} + +public sealed class GuildColorsService : IGuildColorsService, IEService +{ + private readonly DbService _db; + + public GuildColorsService(DbService db) + { + _db = db; + } + + public async Task GetGuildColors(ulong guildId) + { + // get from database and cache it with linq2db + + await using var ctx = _db.GetDbContext(); + + return null; + // return await ctx + // .GuildColors + // .FirstOrDefaultAsync(x => x.GuildId == guildId); + + } +} + +public partial class Utility +{ + public class GuildColorsCommands : EllieModule + { + + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Info/InfoCommands.cs b/src/EllieBot/Modules/Utility/Info/InfoCommands.cs new file mode 100644 index 0000000..c9ae861 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Info/InfoCommands.cs @@ -0,0 +1,180 @@ +#nullable disable +using System.Text; +using EllieBot.Modules.Patronage; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class InfoCommands : EllieModule + { + private readonly DiscordSocketClient _client; + private readonly IStatsService _stats; + private readonly IPatronageService _ps; + + public InfoCommands(DiscordSocketClient client, IStatsService stats, IPatronageService ps) + { + _client = client; + _stats = stats; + _ps = ps; + } + + [Cmd] + [OwnerOnly] + public Task ServerInfo(ulong guildId) + => InternalServerInfo(guildId); + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task ServerInfo() + => InternalServerInfo(ctx.Guild.Id); + + private async Task InternalServerInfo(ulong guildId) + { + var guild = (IGuild)_client.GetGuild(guildId) + ?? await _client.Rest.GetGuildAsync(guildId); + + if (guild is null) + return; + + var ownername = await guild.GetUserAsync(guild.OwnerId); + var textchn = (await guild.GetTextChannelsAsync()).Count; + var voicechn = (await guild.GetVoiceChannelsAsync()).Count; + var channels = $@"{GetText(strs.text_channels(textchn))} +{GetText(strs.voice_channels(voicechn))}"; + var createdAt = new DateTime(2015, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(guild.Id >> 22); + var features = guild.Features.Value.ToString(); + if (string.IsNullOrWhiteSpace(features)) + features = "-"; + + var embed = _sender.CreateEmbed() + .WithAuthor(GetText(strs.server_info)) + .WithTitle(guild.Name) + .AddField(GetText(strs.id), guild.Id.ToString(), true) + .AddField(GetText(strs.owner), ownername.ToString(), true) + .AddField(GetText(strs.members), (guild as SocketGuild)?.MemberCount ?? guild.ApproximateMemberCount, true) + .AddField(GetText(strs.channels), channels, true) + .AddField(GetText(strs.created_at), $"{createdAt:dd.MM.yyyy HH:mm}", true) + .AddField(GetText(strs.roles), (guild.Roles.Count - 1).ToString(), true) + .AddField(GetText(strs.features), features) + .WithOkColor(); + + if (Uri.IsWellFormedUriString(guild.IconUrl, UriKind.Absolute)) + embed.WithThumbnailUrl(guild.IconUrl); + + if (guild.Emotes.Any()) + { + embed.AddField(GetText(strs.custom_emojis) + $"({guild.Emotes.Count})", + string.Join(" ", guild.Emotes.Shuffle().Take(20).Select(e => $"{e.Name} {e}")).TrimTo(1020)); + } + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChannelInfo(ITextChannel channel = null) + { + var ch = channel ?? (ITextChannel)ctx.Channel; + if (ch is null) + return; + var createdAt = new DateTime(2015, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(ch.Id >> 22); + var usercount = (await ch.GetUsersAsync().FlattenAsync()).Count(); + var embed = _sender.CreateEmbed() + .WithTitle(ch.Name) + .WithDescription(ch.Topic?.SanitizeMentions(true)) + .AddField(GetText(strs.id), ch.Id.ToString(), true) + .AddField(GetText(strs.created_at), $"{createdAt:dd.MM.yyyy HH:mm}", true) + .AddField(GetText(strs.users), usercount.ToString(), true) + .WithOkColor(); + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireUserPermission(GuildPermission.ManageRoles)] + public async Task RoleInfo([Leftover] SocketRole role) + { + if (role.IsEveryone) + return; + + var createdAt = new DateTime(2015, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc) + .AddMilliseconds(role.Id >> 22); + var usercount = role.Members.LongCount(); + var embed = _sender.CreateEmbed() + .WithTitle(role.Name.TrimTo(128)) + .WithDescription(role.Permissions.ToList().Join(" | ")) + .AddField(GetText(strs.id), role.Id.ToString(), true) + .AddField(GetText(strs.created_at), $"{createdAt:dd.MM.yyyy HH:mm}", true) + .AddField(GetText(strs.users), usercount.ToString(), true) + .AddField(GetText(strs.color), + $"#{role.Color.R:X2}{role.Color.G:X2}{role.Color.B:X2}", + true) + .AddField(GetText(strs.mentionable), role.IsMentionable.ToString(), true) + .AddField(GetText(strs.hoisted), role.IsHoisted.ToString(), true) + .WithOkColor(); + + if (!string.IsNullOrWhiteSpace(role.GetIconUrl())) + embed = embed.WithThumbnailUrl(role.GetIconUrl()); + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UserInfo(IGuildUser usr = null) + { + var user = usr ?? ctx.User as IGuildUser; + + if (user is null) + return; + + var embed = _sender.CreateEmbed() + .AddField(GetText(strs.name), $"**{user.Username}**#{user.Discriminator}", true); + if (!string.IsNullOrWhiteSpace(user.Nickname)) + embed.AddField(GetText(strs.nickname), user.Nickname, true); + + var joinedAt = GetJoinedAt(user); + + embed.AddField(GetText(strs.id), user.Id.ToString(), true) + .AddField(GetText(strs.joined_server), $"{joinedAt?.ToString("dd.MM.yyyy HH:mm") ?? "?"}", true) + .AddField(GetText(strs.joined_discord), $"{user.CreatedAt:dd.MM.yyyy HH:mm}", true) + .AddField(GetText(strs.roles), + $"**({user.RoleIds.Count - 1})** - {string.Join("\n", user.GetRoles().Take(10).Where(r => r.Id != r.Guild.EveryoneRole.Id).Select(r => r.Name)).SanitizeMentions(true)}", + true) + .WithOkColor(); + + var mPatron = await _ps.GetPatronAsync(user.Id); + + if (mPatron is {} patron && patron.Tier != PatronTier.None) + { + embed.WithFooter(patron.Tier switch + { + PatronTier.V => "❤️❤️", + PatronTier.X => "❤️❤️❤️", + PatronTier.XX => "❤️❤️❤️❤️", + PatronTier.L => "❤️❤️❤️❤️❤️", + _ => "❤️", + }); + } + + var av = user.RealAvatarUrl(); + if (av.IsAbsoluteUri) + embed.WithThumbnailUrl(av.ToString()); + + await Response().Embed(embed).SendAsync(); + } + + private DateTimeOffset? GetJoinedAt(IGuildUser user) + { + var joinedAt = user.JoinedAt; + if (user.GuildId != 117523346618318850) + return joinedAt; + + if (user.Id == 351244576092192778) + return new DateTimeOffset(2019, 12, 25, 9, 33, 0, TimeSpan.Zero); + + return joinedAt; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Invite/InviteCommands.cs b/src/EllieBot/Modules/Utility/Invite/InviteCommands.cs new file mode 100644 index 0000000..f9174ae --- /dev/null +++ b/src/EllieBot/Modules/Utility/Invite/InviteCommands.cs @@ -0,0 +1,96 @@ +#nullable disable +using EllieBot.Modules.Utility.Services; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class InviteCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.CreateInstantInvite)] + [UserPerm(ChannelPerm.CreateInstantInvite)] + [EllieOptions] + public async Task InviteCreate(params string[] args) + { + var (opts, success) = OptionsParser.ParseFrom(new InviteService.Options(), args); + if (!success) + return; + + var ch = (ITextChannel)ctx.Channel; + var invite = await ch.CreateInviteAsync(opts.Expire, opts.MaxUses, opts.Temporary, opts.Unique); + + await Response().Confirm($"{ctx.User.Mention} https://discord.gg/{invite.Code}").SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.ManageChannels)] + [UserPerm(ChannelPerm.ManageChannels)] + public async Task InviteList(int page = 1, [Leftover] ITextChannel ch = null) + { + if (--page < 0) + return; + var channel = ch ?? (ITextChannel)ctx.Channel; + + var invites = await channel.GetInvitesAsync(); + + + await Response() + .Paginated() + .Items(invites) + .PageSize(9) + .Page((invs, _) => + { + var i = 1; + + if (!invs.Any()) + return _sender.CreateEmbed().WithErrorColor().WithDescription(GetText(strs.no_invites)); + + var embed = _sender.CreateEmbed().WithOkColor(); + foreach (var inv in invs) + { + var expiryString = inv.MaxAge is null or 0 || inv.CreatedAt is null + ? "∞" + : (inv.CreatedAt.Value.AddSeconds(inv.MaxAge.Value).UtcDateTime - DateTime.UtcNow) + .ToString( + """d\.hh\:mm\:ss"""); + var creator = inv.Inviter.ToString().TrimTo(25); + var usesString = $"{inv.Uses} / {(inv.MaxUses == 0 ? "∞" : inv.MaxUses?.ToString())}"; + + var desc = $@"`{GetText(strs.inv_uses)}` **{usesString}** +`{GetText(strs.inv_expire)}` **{expiryString}** + +{inv.Url} "; + embed.AddField($"#{i++} {creator}", desc); + } + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(ChannelPerm.ManageChannels)] + [UserPerm(ChannelPerm.ManageChannels)] + public async Task InviteDelete(int index) + { + if (--index < 0) + return; + + var ch = (ITextChannel)ctx.Channel; + + var invites = await ch.GetInvitesAsync(); + + if (invites.Count <= index) + return; + var inv = invites.ElementAt(index); + await inv.DeleteAsync(); + + await ReplyAsync(GetText(strs.invite_deleted(Format.Bold(inv.Code)))); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Invite/InviteService.cs b/src/EllieBot/Modules/Utility/Invite/InviteService.cs new file mode 100644 index 0000000..d89ec28 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Invite/InviteService.cs @@ -0,0 +1,48 @@ +#nullable disable +using CommandLine; + +namespace EllieBot.Modules.Utility.Services; + +public class InviteService : IEService +{ + public class Options : IEllieCommandOptions + { + [Option('m', + "max-uses", + Required = false, + Default = 0, + HelpText = "Maximum number of times the invite can be used. Default 0 (never).")] + public int MaxUses { get; set; } + + [Option('u', + "unique", + Required = false, + Default = false, + HelpText = + "Not setting this flag will result in bot getting the existing invite with the same settings if it exists, instead of creating a new one.")] + public bool Unique { get; set; } = false; + + [Option('t', + "temporary", + Required = false, + Default = false, + HelpText = "If this flag is set, the user will be kicked from the guild once they close their client.")] + public bool Temporary { get; set; } = false; + + [Option('e', + "expire", + Required = false, + Default = 0, + HelpText = "Time in seconds to expire the invite. Default 0 (no expiry).")] + public int Expire { get; set; } + + public void NormalizeOptions() + { + if (MaxUses < 0) + MaxUses = 0; + + if (Expire < 0) + Expire = 0; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Quote/IQuoteService.cs b/src/EllieBot/Modules/Utility/Quote/IQuoteService.cs new file mode 100644 index 0000000..acd6b19 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Quote/IQuoteService.cs @@ -0,0 +1,50 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public interface IQuoteService +{ + /// + /// Delete all quotes created by the author in a guild + /// + /// ID of the guild + /// ID of the user + /// Number of deleted qutoes + Task DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId); + + /// + /// Delete all quotes in a guild + /// + /// ID of the guild + /// Number of deleted qutoes + Task DeleteAllQuotesAsync(ulong guildId); + + Task> GetAllQuotesAsync(ulong guildId, int page, OrderType order); + Task GetQuoteByKeywordAsync(ulong guildId, string keyword); + + Task> SearchQuoteKeywordTextAsync( + ulong guildId, + string? keyword, + string text); + + Task> GetGuildQuotesAsync(ulong guildId); + Task RemoveAllByKeyword(ulong guildId, string keyword); + Task GetQuoteByIdAsync(ulong guildId, int quoteId); + + Task AddQuoteAsync( + ulong guildId, + ulong authorId, + string authorName, + string keyword, + string text); + + Task EditQuoteAsync(ulong authorId, int quoteId, string text); + + Task DeleteQuoteAsync( + ulong guildId, + ulong authorId, + bool isQuoteManager, + int quoteId); + + Task ImportQuotesAsync(ulong guildId, string input); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs new file mode 100644 index 0000000..a50cd19 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs @@ -0,0 +1,389 @@ +#nullable disable warnings +using EllieBot.Common.Yml; +using EllieBot.Db.Models; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class QuoteCommands : EllieModule + { + private const string PREPEND_EXPORT = + """ + # Keys are keywords, Each key has a LIST of quotes in the following format: + # - id: Alphanumeric id used for commands related to the quote. (Note, when using .quotesimport, a new id will be generated.) + # an: Author name + # aid: Author id + # txt: Quote text + + """; + + private static readonly ISerializer _exportSerializer = new SerializerBuilder() + .WithEventEmitter(args + => new MultilineScalarFlowStyleEmitter(args)) + .WithNamingConvention( + CamelCaseNamingConvention.Instance) + .WithIndentedSequences() + .ConfigureDefaultValuesHandling(DefaultValuesHandling + .OmitDefaults) + .DisableAliases() + .Build(); + + private readonly DbService _db; + private readonly IHttpClientFactory _http; + private readonly IQuoteService _qs; + + public QuoteCommands(DbService db, IQuoteService qs, IHttpClientFactory http) + { + _db = db; + _http = http; + _qs = qs; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task QuoteList(OrderType order = OrderType.Keyword) + => QuoteList(1, order); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task QuoteList(int page = 1, OrderType order = OrderType.Keyword) + { + page -= 1; + if (page < 0) + return; + + var quotes = await _qs.GetAllQuotesAsync(ctx.Guild.Id, page, order); + + if (quotes.Count == 0) + { + await Response().Error(strs.quotes_page_none).SendAsync(); + return; + } + + var list = quotes.Select(q => $"`{new kwum(q.Id)}` {Format.Bold(q.Keyword),-20} by {q.AuthorName}") + .Join("\n"); + + await Response() + .Confirm(GetText(strs.quotes_page(page + 1)), list) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuotePrint([Leftover] string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) + return; + + keyword = keyword.ToUpperInvariant(); + + var quote = await _qs.GetQuoteByKeywordAsync(ctx.Guild.Id, keyword); + + if (quote is null) + return; + + var repCtx = new ReplacementContext(Context); + + var text = SmartText.CreateFrom(quote.Text); + text = await repSvc.ReplaceAsync(text, repCtx); + + await Response() + .Text($"`{new kwum(quote.Id)}` 📣 " + text) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteShow(kwum quoteId) + { + var quote = await _qs.GetQuoteByIdAsync(ctx.Guild.Id, quoteId); + + if (quote is null) + { + await Response().Error(strs.quotes_notfound).SendAsync(); + return; + } + + await ShowQuoteData(quote); + } + + private EllieInteractionBase CreateEditInteraction(kwum id, Quote found) + { + var modal = new ModalBuilder() + .WithCustomId("quote:edit_modal") + .WithTitle($"Edit expression {id}") + .AddTextInput(new TextInputBuilder() + .WithLabel(GetText(strs.response)) + .WithValue(found.Text) + .WithMinLength(1) + .WithCustomId("quote: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; + + if (!string.IsNullOrWhiteSpace(msg)) + await QuoteEdit(id, msg); + } + ); + return inter; + } + + private async Task ShowQuoteData(Quote quote) + { + var inter = CreateEditInteraction(quote.Id, quote); + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{GetText(strs.quote_id($"`{new kwum(quote.Id)}"))}`") + .WithDescription(Format.Sanitize(quote.Text).Replace("](", "]\\(").TrimTo(4096)) + .AddField(GetText(strs.trigger), quote.Keyword) + .WithFooter( + GetText(strs.created_by($"{quote.AuthorName} ({quote.AuthorId})"))); + + if (!(quote.Text.Length > 4096)) + { + await Response().Embed(eb).Interaction(quote.AuthorId == ctx.User.Id ? inter : null).SendAsync(); + return; + } + + await using var textStream = await quote.Text.ToStream(); + + await Response() + .Embed(eb) + .File(textStream, "quote.txt") + .SendAsync(); + } + + private async Task QuoteSearchInternalAsync(string? keyword, string textOrAuthor) + { + if (string.IsNullOrWhiteSpace(textOrAuthor)) + return; + + keyword = keyword?.ToUpperInvariant(); + + var quotes = await _qs.SearchQuoteKeywordTextAsync(ctx.Guild.Id, keyword, textOrAuthor); + + await Response() + .Paginated() + .Items(quotes) + .PageSize(1) + .Page((pageQuotes, _) => + { + var quote = pageQuotes[0]; + + var text = quote.Keyword.ToLowerInvariant() + ": " + quote.Text; + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle($"{new kwum(quote.Id)} 💬 ") + .WithDescription(text); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public Task QuoteSearch(string textOrAuthor) + => QuoteSearchInternalAsync(null, textOrAuthor); + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task QuoteSearch(string keyword, [Leftover] string textOrAuthor) + => QuoteSearchInternalAsync(keyword, textOrAuthor); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteId(kwum quoteId) + { + if (quoteId < 0) + return; + + var quote = await _qs.GetQuoteByIdAsync(ctx.Guild.Id, quoteId); + + if (quote is null) + { + await Response().Error(strs.quotes_notfound).SendAsync(); + return; + } + + var infoText = $"*`{new kwum(quote.Id)}` added by {quote.AuthorName}* 🗯️ " + + quote.Keyword.ToLowerInvariant() + + ":\n"; + + + var repCtx = new ReplacementContext(Context); + var text = SmartText.CreateFrom(quote.Text); + text = await repSvc.ReplaceAsync(text, repCtx); + await Response() + .Text(infoText + text) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteAdd(string keyword, [Leftover] string text) + { + if (string.IsNullOrWhiteSpace(keyword) || string.IsNullOrWhiteSpace(text)) + return; + + var quote = await _qs.AddQuoteAsync(ctx.Guild.Id, ctx.User.Id, ctx.User.Username, keyword, text); + + await Response() + .Confirm(strs.quote_added_new(Format.Code(new kwum(quote.Id).ToString()))) + .SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteEdit(kwum quoteId, [Leftover] string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + var q = await _qs.EditQuoteAsync(ctx.User.Id, quoteId, text); + + if (q is not null) + { + await Response() + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.quote_edited)) + .WithDescription($"#{quoteId}") + .AddField(GetText(strs.trigger), q.Keyword) + .AddField(GetText(strs.response), + text.Length > 1024 ? GetText(strs.redacted_too_long) : text)) + .SendAsync(); + } + else + { + await Response().Error(strs.expr_no_found_id).SendAsync(); + } + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteDelete(kwum quoteId) + { + var hasManageMessages = ((IGuildUser)ctx.Message.Author).GuildPermissions.ManageMessages; + + var success = await _qs.DeleteQuoteAsync(ctx.Guild.Id, ctx.User.Id, hasManageMessages, quoteId); + + if (success) + await Response().Confirm(strs.quote_deleted(quoteId)).SendAsync(); + else + await Response().Error(strs.quotes_remove_none).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task QuoteDeleteAuthor(IUser user) + => QuoteDeleteAuthor(user.Id); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task QuoteDeleteAuthor(ulong userId) + { + var hasManageMessages = ((IGuildUser)ctx.Message.Author).GuildPermissions.ManageMessages; + + if (userId == ctx.User.Id || hasManageMessages) + { + var deleted = await _qs.DeleteAllAuthorQuotesAsync(ctx.Guild.Id, userId); + await Response().Confirm(strs.quotes_deleted_count(deleted)).SendAsync(); + } + else + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task QuotesDeleteAll([Leftover] string keyword) + { + if (string.IsNullOrWhiteSpace(keyword)) + return; + + await _qs.RemoveAllByKeyword(ctx.Guild.Id, keyword.ToUpperInvariant()); + + await Response().Confirm(strs.quotes_deleted(Format.Bold(keyword))).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + public async Task QuotesExport() + { + var quotes = await _qs.GetGuildQuotesAsync(ctx.Guild.Id); + + var exprsDict = quotes.GroupBy(x => x.Keyword) + .ToDictionary(x => x.Key, x => x.Select(ExportedQuote.FromModel)); + + var text = PREPEND_EXPORT + _exportSerializer.Serialize(exprsDict).UnescapeUnicodeCodePoints(); + + await using var stream = await text.ToStream(); + await ctx.Channel.SendFileAsync(stream, "quote-export.yml"); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Ratelimit(300)] + public async Task QuotesImport([Leftover] string? input = null) + { + 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 = _http.CreateClient(); + input = await client.GetStringAsync(attachment.Url); + + if (string.IsNullOrWhiteSpace(input)) + { + await Response().Error(strs.expr_import_no_input).SendAsync(); + return; + } + } + + var succ = await _qs.ImportQuotesAsync(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/Utility/Quote/QuoteService.cs b/src/EllieBot/Modules/Utility/Quote/QuoteService.cs new file mode 100644 index 0000000..a518a01 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Quote/QuoteService.cs @@ -0,0 +1,222 @@ +#nullable disable warnings +using LinqToDB; +using LinqToDB.Data; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.Yml; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class QuoteService : IQuoteService, IEService +{ + private readonly DbService _db; + + public QuoteService(DbService db) + { + _db = db; + } + + /// + /// Delete all quotes created by the author in a guild + /// + /// ID of the guild + /// ID of the user + /// Number of deleted qutoes + public async Task DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .Where(x => x.GuildId == guildId && x.AuthorId == userId) + .DeleteAsync(); + + return deleted; + } + + /// + /// Delete all quotes in a guild + /// + /// ID of the guild + /// Number of deleted qutoes + public async Task DeleteAllQuotesAsync(ulong guildId) + { + await using var ctx = _db.GetDbContext(); + var deleted = await ctx.GetTable() + .Where(x => x.GuildId == guildId) + .DeleteAsync(); + + return deleted; + } + + public async Task> GetAllQuotesAsync(ulong guildId, int page, OrderType order) + { + await using var uow = _db.GetDbContext(); + var q = uow.Set() + .ToLinqToDBTable() + .Where(x => x.GuildId == guildId); + + if (order == OrderType.Keyword) + q = q.OrderBy(x => x.Keyword); + else + q = q.OrderBy(x => x.Id); + + return await q.Skip(15 * page).Take(15).ToArrayAsyncLinqToDB(); + } + + public async Task GetQuoteByKeywordAsync(ulong guildId, string keyword) + { + await using var uow = _db.GetDbContext(); + var quotes = await uow.GetTable() + .Where(q => q.GuildId == guildId && q.Keyword == keyword) + .ToArrayAsyncLinqToDB(); + + return quotes.RandomOrDefault(); + } + + public async Task> SearchQuoteKeywordTextAsync( + ulong guildId, + string? keyword, + string text) + { + keyword = keyword?.ToUpperInvariant(); + await using var uow = _db.GetDbContext(); + + var quotes = await uow.GetTable() + .Where(q => q.GuildId == guildId + && (keyword == null || q.Keyword == keyword)) + .ToArrayAsync(); + + var toReturn = new List(quotes.Length); + + foreach (var q in quotes) + { + if (q.AuthorName.Contains(text, StringComparison.InvariantCultureIgnoreCase) + || q.Text.Contains(text, StringComparison.InvariantCultureIgnoreCase)) + { + toReturn.Add(q); + } + } + + return toReturn; + } + + public async Task> GetGuildQuotesAsync(ulong guildId) + { + await using var uow = _db.GetDbContext(); + var quotes = await uow.GetTable() + .Where(x => x.GuildId == guildId) + .ToListAsyncLinqToDB(); + return quotes; + } + + public Task RemoveAllByKeyword(ulong guildId, string keyword) + { + keyword = keyword.ToUpperInvariant(); + + using var uow = _db.GetDbContext(); + + var count = uow.GetTable() + .Where(x => x.GuildId == guildId && x.Keyword == keyword) + .DeleteAsync(); + + return count; + } + + public async Task GetQuoteByIdAsync(ulong guildId, int quoteId) + { + await using var uow = _db.GetDbContext(); + + var quote = await uow.GetTable() + .Where(x => x.Id == quoteId && x.GuildId == guildId) + .FirstOrDefaultAsyncLinqToDB(); + + return quote; + } + + public async Task AddQuoteAsync( + ulong guildId, + ulong authorId, + string authorName, + string keyword, + string text) + { + keyword = keyword.ToUpperInvariant(); + + Quote q; + await using var uow = _db.GetDbContext(); + uow.Set() + .Add(q = new() + { + AuthorId = authorId, + AuthorName = authorName, + GuildId = guildId, + Keyword = keyword, + Text = text + }); + await uow.SaveChangesAsync(); + + return q; + } + + public async Task EditQuoteAsync(ulong authorId, int quoteId, string text) + { + await using var uow = _db.GetDbContext(); + var result = await uow.GetTable() + .Where(x => x.Id == quoteId && x.AuthorId == authorId) + .Set(x => x.Text, text) + .UpdateWithOutputAsync((del, ins) => ins); + + var q = result.FirstOrDefault(); + return q; + } + + public async Task DeleteQuoteAsync( + ulong guildId, + ulong authorId, + bool isQuoteManager, + int quoteId) + { + await using var uow = _db.GetDbContext(); + var q = uow.Set().GetById(quoteId); + + + var count = await uow.GetTable() + .Where(x => x.GuildId == guildId && x.Id == quoteId) + .Where(x => isQuoteManager || (x.AuthorId == authorId)) + .DeleteAsync(); + + + return count > 0; + } + + public async Task ImportQuotesAsync(ulong guildId, string input) + { + Dictionary> data; + try + { + data = Yaml.Deserializer.Deserialize>>(input); + } + catch (Exception ex) + { + Log.Warning(ex, "Quote import failed: {Message}", ex.Message); + return false; + } + + + var toImport = data.SelectMany(x => x.Value.Select(v => (Key: x.Key, Value: v))) + .Where(x => !string.IsNullOrWhiteSpace(x.Key) && !string.IsNullOrWhiteSpace(x.Value?.Txt)); + + await using var uow = _db.GetDbContext(); + await uow.GetTable() + .BulkCopyAsync(toImport + .Select(q => new Quote + { + GuildId = guildId, + Keyword = q.Key, + Text = q.Value.Txt, + AuthorId = q.Value.Aid, + AuthorName = q.Value.An + })); + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Quote/_common/ExportedQuote.cs b/src/EllieBot/Modules/Utility/Quote/_common/ExportedQuote.cs new file mode 100644 index 0000000..c0ee68d --- /dev/null +++ b/src/EllieBot/Modules/Utility/Quote/_common/ExportedQuote.cs @@ -0,0 +1,20 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public class ExportedQuote +{ + public required string Id { get; init; } + public required string An { get; init; } + public required ulong Aid { get; init; } + public required string Txt { get; init; } + + public static ExportedQuote FromModel(Quote quote) + => new() + { + Id = ((kwum)quote.Id).ToString(), + An = quote.AuthorName, + Aid = quote.AuthorId, + Txt = quote.Text + }; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Remind/RemindCommands.cs b/src/EllieBot/Modules/Utility/Remind/RemindCommands.cs new file mode 100644 index 0000000..b568ada --- /dev/null +++ b/src/EllieBot/Modules/Utility/Remind/RemindCommands.cs @@ -0,0 +1,224 @@ +#nullable disable +using EllieBot.Modules.Utility.Services; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class RemindCommands : EllieModule + { + public enum MeOrHere + { + Me, + Here + } + + public enum Server + { + Server = int.MinValue, + Srvr = int.MinValue, + Serv = int.MinValue, + S = int.MinValue + } + + private readonly DbService _db; + private readonly ITimezoneService _tz; + + public RemindCommands(DbService db, ITimezoneService tz) + { + _db = db; + _tz = tz; + } + + [Cmd] + [Priority(1)] + public async Task Remind(MeOrHere meorhere, [Leftover] string remindString) + { + if (!_service.TryParseRemindMessage(remindString, out var remindData)) + { + await Response().Error(strs.remind_invalid).SendAsync(); + return; + } + + ulong target; + target = meorhere == MeOrHere.Me ? ctx.User.Id : ctx.Channel.Id; + + var success = await RemindInternal(target, + meorhere == MeOrHere.Me || ctx.Guild is null, + remindData.Time, + remindData.What, + ReminderType.User); + + if (!success) + await Response().Error(strs.remind_too_long).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public async Task Remind(ITextChannel channel, [Leftover] string remindString) + { + var perms = ((IGuildUser)ctx.User).GetPermissions(channel); + if (!perms.SendMessages || !perms.ViewChannel) + { + await Response().Error(strs.cant_read_or_send).SendAsync(); + return; + } + + if (!_service.TryParseRemindMessage(remindString, out var remindData)) + { + await Response().Error(strs.remind_invalid).SendAsync(); + return; + } + + + var success = await RemindInternal(channel.Id, false, remindData.Time, remindData.What, ReminderType.User); + if (!success) + await Response().Error(strs.remind_too_long).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task RemindList(Server _, int page = 1) + => RemindListInternal(page, ctx.Guild.Id); + + [Cmd] + [Priority(1)] + public Task RemindList(int page = 1) + => RemindListInternal(page, null); + + private async Task RemindListInternal(int page, ulong? guildId) + { + if (--page < 0) + return; + + var embed = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(guildId is not null + ? strs.reminder_server_list + : strs.reminder_list)); + + List rems; + if (guildId is { } gid) + rems = await _service.GetServerReminders(page, gid); + else + rems = await _service.GetUserReminders(page, ctx.User.Id); + + + if (rems.Count > 0) + { + var i = 0; + foreach (var rem in rems) + { + var when = rem.When; + embed.AddField( + $"#{++i + (page * 10)}", + $""" + `When:` {TimestampTag.FromDateTime(when, TimestampTagStyles.ShortDateTime)} + `Target:` {(rem.IsPrivate ? "DM" : "Channel")} [`{rem.ChannelId}`] + `Message:` {rem.Message?.TrimTo(50)} + """); + } + } + else + { + embed.WithDescription(GetText(strs.reminders_none)); + } + + embed.AddPaginatedFooter(page + 1, null); + await Response().Embed(embed).SendAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.Administrator)] + [Priority(0)] + public Task RemindDelete(Server _, int index) + => RemindDelete(index, true); + + [Cmd] + [Priority(1)] + public Task RemindDelete(int index) + => RemindDelete(index, false); + + private async Task RemindDelete(int index, bool isServer) + { + if (--index < 0) + return; + + Reminder rem = null; + await using (var uow = _db.GetDbContext()) + { + var rems = isServer + ? uow.Set().RemindersForServer(ctx.Guild.Id, index / 10).ToList() + : uow.Set().RemindersFor(ctx.User.Id, index / 10).ToList(); + + var pageIndex = index % 10; + if (rems.Count > pageIndex) + { + rem = rems[pageIndex]; + uow.Set().Remove(rem); + uow.SaveChanges(); + } + } + + if (rem is null) + await Response().Error(strs.reminder_not_exist).SendAsync(); + else + await Response().Confirm(strs.reminder_deleted(index + 1)).SendAsync(); + } + + private async Task RemindInternal( + ulong targetId, + bool isPrivate, + TimeSpan ts, + string message, + ReminderType reminderType) + { + var time = DateTime.UtcNow + ts; + + if (ts > TimeSpan.FromDays(60)) + return false; + + if (ctx.Guild is not null) + { + var perms = ((IGuildUser)ctx.User).GetPermissions((IGuildChannel)ctx.Channel); + if (!perms.MentionEveryone) + message = message.SanitizeAllMentions(); + } + + var rem = new Reminder + { + ChannelId = targetId, + IsPrivate = isPrivate, + When = time, + Message = message, + UserId = ctx.User.Id, + ServerId = ctx.Guild?.Id ?? 0 + }; + + await using (var uow = _db.GetDbContext()) + { + uow.Set().Add(rem); + await uow.SaveChangesAsync(); + } + + // var gTime = ctx.Guild is null ? time : TimeZoneInfo.ConvertTime(time, _tz.GetTimeZoneOrUtc(ctx.Guild.Id)); + await Response() + .Confirm($"\u23f0 {GetText(strs.remind2( + Format.Bold(!isPrivate ? $"<#{targetId}>" : ctx.User.Username), + Format.Bold(message), + TimestampTag.FromDateTime(DateTime.UtcNow.Add(ts), TimestampTagStyles.Relative), + TimestampTag.FormatFromDateTime(time, TimestampTagStyles.ShortDateTime)))}") + .SendAsync(); + + return true; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Remind/RemindService.cs b/src/EllieBot/Modules/Utility/Remind/RemindService.cs new file mode 100644 index 0000000..29913c0 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Remind/RemindService.cs @@ -0,0 +1,271 @@ +#nullable disable +using System.Globalization; +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; +using System.Text.RegularExpressions; + +namespace EllieBot.Modules.Utility.Services; + +public class RemindService : IEService, IReadyExecutor, IRemindService +{ + private readonly Regex _regex = + new( + @"^(?:(?:at|on(?:\sthe)?)?\s*(?(?:\d{2}:\d{2}\s)?\d{1,2}\.\d{1,2}(?:\.\d{2,4})?)|(?:in\s?)?\s*(?:(?\d+)(?:\s?(?:months?|mos?),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:weeks?|w),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:days?|d),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:hours?|h),?))?(?:(?:\sand\s|\s*)?(?\d+)(?:\s?(?:minutes?|mins?|m),?))?)\s+(?:to:?\s+)?(?(?:\r\n|[\r\n]|.)+)", + RegexOptions.Compiled | RegexOptions.Multiline); + + private readonly DiscordSocketClient _client; + private readonly DbService _db; + private readonly IBotCredentials _creds; + private readonly IMessageSenderService _sender; + private readonly CultureInfo _culture; + + public RemindService( + DiscordSocketClient client, + DbService db, + IBotCredentials creds, + IMessageSenderService sender) + { + _client = client; + _db = db; + _creds = creds; + _sender = sender; + + try + { + _culture = new CultureInfo("en-GB"); + } + catch + { + _culture = CultureInfo.InvariantCulture; + } + } + + public async Task OnReadyAsync() + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + while (await timer.WaitForNextTickAsync()) + { + await OnReminderLoopTickInternalAsync(); + } + } + + private async Task OnReminderLoopTickInternalAsync() + { + try + { + var now = DateTime.UtcNow; + var reminders = await GetRemindersBeforeAsync(now); + if (reminders.Count == 0) + return; + + Log.Information("Executing {ReminderCount} reminders", reminders.Count); + + // make groups of 5, with 1.5 second in between each one to ensure against ratelimits + foreach (var group in reminders.Chunk(5)) + { + var executedReminders = group.ToList(); + await executedReminders.Select(ReminderTimerAction).WhenAll(); + await RemoveReminders(executedReminders.Select(x => x.Id)); + await Task.Delay(1500); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error in reminder loop: {ErrorMessage}", ex.Message); + } + } + + private async Task RemoveReminders(IEnumerable reminders) + { + await using var uow = _db.GetDbContext(); + await uow.Set() + .ToLinqToDBTable() + .DeleteAsync(x => reminders.Contains(x.Id)); + + await uow.SaveChangesAsync(); + } + + private async Task> GetRemindersBeforeAsync(DateTime now) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .ToLinqToDBTable() + .Where(x => Linq2DbExpressions.GuildOnShard(x.ServerId, _creds.TotalShards, _client.ShardId) + && x.When < now) + .ToListAsyncLinqToDB(); + } + + public bool TryParseRemindMessage(string input, out RemindObject obj) + { + var m = _regex.Match(input); + + obj = default; + if (m.Length == 0) + return false; + + var values = new Dictionary(); + + var what = m.Groups["what"].Value; + + if (string.IsNullOrWhiteSpace(what)) + { + Log.Warning("No message provided for the reminder"); + return false; + } + + TimeSpan ts; + + var dateString = m.Groups["date"].Value; + if (!string.IsNullOrWhiteSpace(dateString)) + { + var now = DateTime.UtcNow; + + if (!DateTime.TryParse(dateString, _culture, DateTimeStyles.None, out var dt)) + { + Log.Warning("Invalid remind datetime format"); + return false; + } + + if (now >= dt) + { + Log.Warning("That remind time has already passed"); + return false; + } + + ts = dt - now; + } + else + { + foreach (var groupName in _regex.GetGroupNames()) + { + if (groupName is "0" or "what") + continue; + + if (string.IsNullOrWhiteSpace(m.Groups[groupName].Value)) + { + values[groupName] = 0; + continue; + } + + if (!int.TryParse(m.Groups[groupName].Value, out var value)) + { + Log.Warning("Reminder regex group {GroupName} has invalid value", groupName); + return false; + } + + if (value < 1) + { + Log.Warning("Reminder time value has to be an integer greater than 0"); + return false; + } + + values[groupName] = value; + } + + ts = new TimeSpan((30 * values["mo"]) + (7 * values["w"]) + values["d"], values["h"], values["m"], 0); + } + + + obj = new() + { + Time = ts, + What = what + }; + + return true; + } + + private async Task ReminderTimerAction(Reminder r) + { + try + { + IMessageChannel ch; + if (r.IsPrivate) + { + var user = _client.GetUser(r.ChannelId); + if (user is null) + return; + ch = await user.CreateDMChannelAsync(); + } + else + ch = _client.GetGuild(r.ServerId)?.GetTextChannel(r.ChannelId); + + if (ch is null) + return; + + var st = SmartText.CreateFrom(r.Message); + + if (st is SmartEmbedText set) + { + await _sender.Response(ch).Embed(set.GetEmbed()).SendAsync(); + } + else if (st is SmartEmbedTextArray seta) + { + await _sender.Response(ch).Embeds(seta.GetEmbedBuilders()).SendAsync(); + } + else + { + await _sender.Response(ch) + .Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle("Reminder") + .AddField("Created At", + r.DateAdded.HasValue ? r.DateAdded.Value.ToLongDateString() : "?") + .AddField("By", + (await ch.GetUserAsync(r.UserId))?.ToString() ?? r.UserId.ToString())) + .Text(r.Message) + .SendAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Error executing reminder {ReminderId}: {ErrorMessage}", r.Id, ex.Message); + } + } + + public struct RemindObject + { + public string What { get; set; } + public TimeSpan Time { get; set; } + } + + public async Task AddReminderAsync( + ulong userId, + ulong targetId, + ulong? guildId, + bool isPrivate, + DateTime time, + string message, + ReminderType reminderType) + { + var rem = new Reminder + { + UserId = userId, + ChannelId = targetId, + ServerId = guildId ?? 0, + IsPrivate = isPrivate, + When = time, + Message = message, + Type = reminderType + }; + + await using var ctx = _db.GetDbContext(); + await ctx.Set() + .AddAsync(rem); + await ctx.SaveChangesAsync(); + } + + public async Task> GetServerReminders(int page, ulong guildId) + { + await using var uow = _db.GetDbContext(); + return uow.Set().RemindersForServer(guildId, page).ToList(); + } + + public async Task> GetUserReminders(int page, ulong userId) + { + await using var uow = _db.GetDbContext(); + return uow.Set().RemindersFor(userId, page).ToList(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Repeater/RepeatCommands.cs b/src/EllieBot/Modules/Utility/Repeater/RepeatCommands.cs new file mode 100644 index 0000000..e2060d4 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Repeater/RepeatCommands.cs @@ -0,0 +1,240 @@ +using EllieBot.Common.TypeReaders; +using EllieBot.Common.TypeReaders.Models; +using EllieBot.Modules.Utility.Services; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class RepeatCommands : EllieModule + { + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatSkip(int index) + { + if (--index < 0) + return; + + var result = await _service.ToggleSkipNextAsync(ctx.Guild.Id, index); + + if (result is null) + { + await Response().Error(strs.index_out_of_range).SendAsync(); + return; + } + + if (result is true) + { + await Response().Confirm(strs.repeater_skip_next).SendAsync(); + } + else + { + await Response().Confirm(strs.repeater_dont_skip_next).SendAsync(); + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatInvoke(int index) + { + if (--index < 0) + return; + + var success = await _service.TriggerExternal(ctx.Guild.Id, index); + if (!success) + await Response().Error(strs.repeat_invoke_none).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatRemove(int index) + { + if (--index < 0) + return; + + var removed = await _service.RemoveByIndexAsync(ctx.Guild.Id, index); + if (removed is null) + { + await Response().Error(strs.repeater_remove_fail).SendAsync(); + return; + } + + var description = GetRepeaterInfoString(removed); + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.repeater_removed(index + 1))) + .WithDescription(description)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatRedundant(int index) + { + if (--index < 0) + return; + + var result = await _service.ToggleRedundantAsync(ctx.Guild.Id, index); + + if (result is null) + { + await Response().Error(strs.index_out_of_range).SendAsync(); + return; + } + + if (result.Value) + await Response().Error(strs.repeater_redundant_no(index + 1)).SendAsync(); + else + await Response().Confirm(strs.repeater_redundant_yes(index + 1)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(-2)] + public Task Repeat([Leftover] string message) + => Repeat(ctx.Channel, null, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(-1)] + public Task Repeat(ITextChannel channel, [Leftover] string message) + => Repeat(channel, null, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Repeat(StoopidTime interval, [Leftover] string message) + => Repeat(ctx.Channel, null, interval, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Repeat(ITextChannel channel, StoopidTime interval, [Leftover] string message) + => Repeat(channel, null, interval, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task Repeat(GuildDateTime timeOfDay, [Leftover] string message) + => Repeat(timeOfDay, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public Task Repeat(ITextChannel channel, GuildDateTime timeOfDay, [Leftover] string message) + => Repeat(channel, timeOfDay, null, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(2)] + public Task Repeat(GuildDateTime? timeOfDay, StoopidTime? interval, [Leftover] string message) + => Repeat(ctx.Channel, timeOfDay, interval, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(3)] + public async Task Repeat(IMessageChannel channel, GuildDateTime? timeOfDay, StoopidTime? interval, + [Leftover] string message) + { + if (channel is not ITextChannel txtCh || txtCh.GuildId != ctx.Guild.Id) + return; + + var perms = ((IGuildUser)ctx.User).GetPermissions(txtCh); + if (!perms.SendMessages) + return; + + var startTimeOfDay = timeOfDay?.InputTimeUtc.TimeOfDay; + // if interval not null, that means user specified it (don't change it) + + // if interval is null set the default to: + // if time of day is specified: 1 day + // else 5 minutes + var realInterval = + interval?.Time ?? (startTimeOfDay is null ? TimeSpan.FromMinutes(5) : TimeSpan.FromDays(1)); + + if (string.IsNullOrWhiteSpace(message) + || (interval is not null + && (interval.Time > TimeSpan.FromMinutes(25000) || interval.Time < TimeSpan.FromMinutes(1)))) + return; + + message = ((IGuildUser)ctx.User).GuildPermissions.MentionEveryone + ? message + : message.SanitizeMentions(true); + + var runner = await _service.AddRepeaterAsync(channel.Id, + ctx.Guild.Id, + realInterval, + message, + false, + startTimeOfDay); + + if (runner is null) + { + await Response().Error(strs.repeater_exceed_limit(5)).SendAsync(); + return; + } + + var description = GetRepeaterInfoString(runner); + await Response().Embed(_sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.repeater_created)) + .WithDescription(description)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task RepeatList() + { + var repeaters = _service.GetRepeaters(ctx.Guild.Id); + if (repeaters.Count == 0) + { + await Response().Confirm(strs.repeaters_none).SendAsync(); + return; + } + + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.list_of_repeaters)).WithOkColor(); + + var i = 0; + foreach (var runner in repeaters.OrderBy(r => r.Repeater.Id)) + { + var description = GetRepeaterInfoString(runner); + var name = $"#`{++i}` {(_service.IsRepeaterSkipped(runner.Repeater.Id) ? "🦘" : "")}"; + embed.AddField(name, description); + } + + await Response().Embed(embed).SendAsync(); + } + + private string GetRepeaterInfoString(RunningRepeater runner) + { + var intervalString = Format.Bold(runner.Repeater.Interval.ToPrettyStringHm()); + var executesIn = runner.NextTime < DateTime.UtcNow ? TimeSpan.Zero : runner.NextTime - DateTime.UtcNow; + var executesInString = Format.Bold(executesIn.ToPrettyStringHm()); + var message = Format.Sanitize(runner.Repeater.Message.TrimTo(50)); + + var description = string.Empty; + if (_service.IsNoRedundant(runner.Repeater.Id)) + description = Format.Underline(Format.Bold(GetText(strs.no_redundant))) + "\n\n"; + + description += $"<#{runner.Repeater.ChannelId}>\n" + + $"`{GetText(strs.interval_colon)}` {intervalString}\n" + + $"`{GetText(strs.executes_in_colon)}` {executesInString}\n" + + $"`{GetText(strs.message_colon)}` {message}"; + + return description; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Repeater/RepeaterService.cs b/src/EllieBot/Modules/Utility/Repeater/RepeaterService.cs new file mode 100644 index 0000000..8d513cb --- /dev/null +++ b/src/EllieBot/Modules/Utility/Repeater/RepeaterService.cs @@ -0,0 +1,443 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.Services; + +public sealed class RepeaterService : IReadyExecutor, IEService +{ + private const int MAX_REPEATERS = 5; + + private readonly DbService _db; + private readonly IReplacementService _repSvc; + private readonly IBotCredentials _creds; + private readonly DiscordSocketClient _client; + private readonly LinkedList _repeaterQueue; + private readonly ConcurrentHashSet _noRedundant; + private readonly ConcurrentHashSet _skipNext = new(); + + private readonly object _queueLocker = new(); + private readonly IMessageSenderService _sender; + + public RepeaterService( + DiscordSocketClient client, + DbService db, + IReplacementService repSvc, + IBotCredentials creds, + IMessageSenderService sender) + { + _db = db; + _repSvc = repSvc; + _creds = creds; + _client = client; + _sender = sender; + + using var uow = _db.GetDbContext(); + var shardRepeaters = uow.Set() + .Where(x => (int)(x.GuildId / Math.Pow(2, 22)) % _creds.TotalShards + == _client.ShardId) + .AsNoTracking() + .ToList(); + + _noRedundant = new(shardRepeaters.Where(x => x.NoRedundant).Select(x => x.Id)); + + _repeaterQueue = new(shardRepeaters.Select(rep => new RunningRepeater(rep)).OrderBy(x => x.NextTime)); + } + + public Task OnReadyAsync() + { + _ = Task.Run(RunRepeatersLoop); + return Task.CompletedTask; + } + + private async Task RunRepeatersLoop() + { + while (true) + { + try + { + // calculate timeout for the first item + var timeout = GetNextTimeout(); + + // wait it out, and recalculate afterwards + // because repeaters might've been modified meanwhile + if (timeout > TimeSpan.Zero) + { + await Task.Delay(timeout > TimeSpan.FromMinutes(1) ? TimeSpan.FromMinutes(1) : timeout); + continue; + } + + // collect (remove) all repeaters which need to run (3 seconds tolerance) + var now = DateTime.UtcNow + TimeSpan.FromSeconds(3); + + var toExecute = new List(); + lock (_repeaterQueue) + { + var current = _repeaterQueue.First; + while (true) + { + if (current is null || current.Value.NextTime > now) + break; + + toExecute.Add(current.Value); + current = current.Next; + } + } + + // execute + foreach (var chunk in toExecute.Chunk(5)) + await chunk.Where(x => !_skipNext.TryRemove(x.Repeater.Id)).Select(Trigger).WhenAll(); + + // reinsert + foreach (var rep in toExecute) + await HandlePostExecute(rep); + } + catch (Exception ex) + { + Log.Error(ex, "Critical error in repeater queue: {ErrorMessage}", ex.Message); + await Task.Delay(5000); + } + } + } + + private async Task HandlePostExecute(RunningRepeater rep) + { + if (rep.ErrorCount >= 10) + { + RemoveFromQueue(rep.Repeater.Id); + await RemoveRepeaterInternal(rep.Repeater); + return; + } + + UpdatePosition(rep); + } + + private void UpdatePosition(RunningRepeater rep) + { + lock (_queueLocker) + { + rep.UpdateNextTime(); + _repeaterQueue.Remove(rep); + AddToQueue(rep); + } + } + + public async Task TriggerExternal(ulong guildId, int index) + { + await using var uow = _db.GetDbContext(); + + var toTrigger = await uow.Set() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toTrigger is null) + return false; + + LinkedListNode? node; + lock (_queueLocker) + { + node = _repeaterQueue.FindNode(x => x.Repeater.Id == toTrigger.Id); + if (node is null) + return false; + + _repeaterQueue.Remove(node); + } + + await Trigger(node.Value); + await HandlePostExecute(node.Value); + return true; + } + + private void AddToQueue(RunningRepeater rep) + { + lock (_queueLocker) + { + var current = _repeaterQueue.First; + if (current is null) + { + _repeaterQueue.AddFirst(rep); + return; + } + + while (current is not null && current.Value.NextTime < rep.NextTime) + current = current.Next; + + if (current is null) + _repeaterQueue.AddLast(rep); + else + _repeaterQueue.AddBefore(current, rep); + } + } + + private TimeSpan GetNextTimeout() + { + lock (_queueLocker) + { + var first = _repeaterQueue.First; + + // if there are no items in the queue, just wait out the minimum duration (1 minute) and try again + if (first is null) + return TimeSpan.FromMinutes(1); + + return first.Value.NextTime - DateTime.UtcNow; + } + } + + private async Task Trigger(RunningRepeater rr) + { + var repeater = rr.Repeater; + + void ChannelMissingError() + { + rr.ErrorCount = int.MaxValue; + Log.Warning("[Repeater] Channel [{Channelid}] for not found or insufficient permissions. " + + "Repeater will be removed. ", + repeater.ChannelId); + } + + var channel = _client.GetChannel(repeater.ChannelId) as ITextChannel; + if (channel is null) + { + try + { + channel = await _client.Rest.GetChannelAsync(repeater.ChannelId) as ITextChannel; + } + catch + { + } + } + + if (channel is null) + { + ChannelMissingError(); + return; + } + + var guild = _client.GetGuild(channel.GuildId); + if (guild is null) + { + ChannelMissingError(); + return; + } + + if (_noRedundant.Contains(repeater.Id)) + { + try + { + var lastMsgInChannel = await channel.GetMessagesAsync(2).Flatten().FirstAsync(); + if (lastMsgInChannel is not null && lastMsgInChannel.Id == repeater.LastMessageId) + return; + } + catch (Exception ex) + { + Log.Warning(ex, + "[Repeater] Error while getting last channel message in {GuildId}/{ChannelId} " + + "Bot probably doesn't have the permission to read message history", + guild.Id, + channel.Id); + } + } + + if (repeater.LastMessageId is { } lastMessageId) + { + try + { + var oldMsg = await channel.GetMessageAsync(lastMessageId); + if (oldMsg is not null) + await oldMsg.DeleteAsync(); + } + catch (Exception ex) + { + Log.Warning(ex, + "[Repeater] Error while deleting previous message in {GuildId}/{ChannelId}", + guild.Id, + channel.Id); + } + } + + var repCtx = new ReplacementContext(client: _client, + guild: guild, + channel: channel, + user: guild.CurrentUser); + + try + { + var text = SmartText.CreateFrom(repeater.Message); + text = await _repSvc.ReplaceAsync(text, repCtx); + + var newMsg = await _sender.Response(channel) + .Text(text) + .Sanitize(false) + .SendAsync(); + + _ = newMsg.AddReactionAsync(new Emoji("🔄")); + + if (_noRedundant.Contains(repeater.Id)) + { + await SetRepeaterLastMessageInternal(repeater.Id, newMsg.Id); + repeater.LastMessageId = newMsg.Id; + } + + rr.ErrorCount = 0; + } + catch (Exception ex) + { + Log.Error(ex, "[Repeater] Error sending repeat message ({ErrorCount})", rr.ErrorCount++); + } + } + + private async Task RemoveRepeaterInternal(Repeater r) + { + _noRedundant.TryRemove(r.Id); + + await using var uow = _db.GetDbContext(); + await uow.Set().DeleteAsync(x => x.Id == r.Id); + + await uow.SaveChangesAsync(); + } + + private RunningRepeater? RemoveFromQueue(int id) + { + lock (_queueLocker) + { + var node = _repeaterQueue.FindNode(x => x.Repeater.Id == id); + if (node is null) + return null; + + _repeaterQueue.Remove(node); + return node.Value; + } + } + + private async Task SetRepeaterLastMessageInternal(int repeaterId, ulong lastMsgId) + { + await using var uow = _db.GetDbContext(); + await uow.Set() + .AsQueryable() + .Where(x => x.Id == repeaterId) + .UpdateAsync(rep => new() + { + LastMessageId = lastMsgId + }); + } + + public async Task AddRepeaterAsync( + ulong channelId, + ulong guildId, + TimeSpan interval, + string message, + bool isNoRedundant, + TimeSpan? startTimeOfDay) + { + var rep = new Repeater + { + ChannelId = channelId, + GuildId = guildId, + Interval = interval, + Message = message, + NoRedundant = isNoRedundant, + LastMessageId = null, + StartTimeOfDay = startTimeOfDay, + DateAdded = DateTime.UtcNow + }; + + await using var uow = _db.GetDbContext(); + + if (await uow.Set().CountAsyncEF(x => x.GuildId == guildId) < MAX_REPEATERS) + uow.Set().Add(rep); + else + return null; + + await uow.SaveChangesAsync(); + + if (isNoRedundant) + _noRedundant.Add(rep.Id); + var runner = new RunningRepeater(rep); + AddToQueue(runner); + return runner; + } + + public async Task RemoveByIndexAsync(ulong guildId, int index) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(index, MAX_REPEATERS * 2); + + await using var uow = _db.GetDbContext(); + var toRemove = await uow.Set() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toRemove is null) + return null; + + // first try removing from queue because it can fail + // while triggering. Instruct user to try again + var removed = RemoveFromQueue(toRemove.Id); + if (removed is null) + return null; + + _noRedundant.TryRemove(toRemove.Id); + uow.Set().Remove(toRemove); + await uow.SaveChangesAsync(); + return removed; + } + + public IReadOnlyCollection GetRepeaters(ulong guildId) + { + lock (_queueLocker) + { + return _repeaterQueue.Where(x => x.Repeater.GuildId == guildId).ToList(); + } + } + + public async Task ToggleRedundantAsync(ulong guildId, int index) + { + await using var uow = _db.GetDbContext(); + var toToggle = await uow.Set() + .AsQueryable() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toToggle is null) + return null; + + var newValue = toToggle.NoRedundant = !toToggle.NoRedundant; + if (newValue) + _noRedundant.Add(toToggle.Id); + else + _noRedundant.TryRemove(toToggle.Id); + + await uow.SaveChangesAsync(); + return newValue; + } + + public async Task ToggleSkipNextAsync(ulong guildId, int index) + { + await using var ctx = _db.GetDbContext(); + var toSkip = await ctx.Set() + .Where(x => x.GuildId == guildId) + .Skip(index) + .FirstOrDefaultAsyncEF(); + + if (toSkip is null) + return null; + + if (_skipNext.Add(toSkip.Id)) + return true; + + _skipNext.TryRemove(toSkip.Id); + return false; + } + + public bool IsNoRedundant(int repeaterId) + => _noRedundant.Contains(repeaterId); + + public bool IsRepeaterSkipped(int repeaterId) + => _skipNext.Contains(repeaterId); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Repeater/RunningRepeater.cs b/src/EllieBot/Modules/Utility/Repeater/RunningRepeater.cs new file mode 100644 index 0000000..509347f --- /dev/null +++ b/src/EllieBot/Modules/Utility/Repeater/RunningRepeater.cs @@ -0,0 +1,92 @@ +#nullable disable +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility.Services; + +public sealed class RunningRepeater +{ + public DateTime NextTime { get; private set; } + + public Repeater Repeater { get; } + public int ErrorCount { get; set; } + + public RunningRepeater(Repeater repeater) + { + Repeater = repeater; + NextTime = CalculateInitialExecution(); + } + + public void UpdateNextTime() + => NextTime = DateTime.UtcNow + Repeater.Interval; + + private DateTime CalculateInitialExecution() + { + if (Repeater.StartTimeOfDay is not null) + { + // if there was a start time of day + // calculate whats the next time of day repeat should trigger at + // based on teh dateadded + + // i know this is not null because of the check in the query + var added = Repeater.DateAdded; + + // initial trigger was the time of day specified by the command. + var initialTriggerTimeOfDay = Repeater.StartTimeOfDay.Value; + + DateTime initialDateTime; + + // if added timeofday is less than specified timeofday for initial trigger + // that means the repeater first ran that same day at that exact specified time + if (added.TimeOfDay <= initialTriggerTimeOfDay) + // in that case, just add the difference to make sure the timeofday is the same + initialDateTime = added + (initialTriggerTimeOfDay - added.TimeOfDay); + else + // if not, then it ran at that time the following day + // in other words; Add one day, and subtract how much time passed since that time of day + initialDateTime = added + TimeSpan.FromDays(1) - (added.TimeOfDay - initialTriggerTimeOfDay); + + return CalculateInitialInterval(initialDateTime); + } + + // if repeater is not running daily, its initial time is the time it was Added at, plus the interval + return CalculateInitialInterval(Repeater.DateAdded + Repeater.Interval); + } + + /// + /// Calculate when is the proper time to run the repeater again based on initial time repeater ran. + /// + /// Initial time repeater ran at (or should run at). + private DateTime CalculateInitialInterval(DateTime initialDateTime) + { + // if the initial time is greater than now, that means the repeater didn't still execute a single time. + // just schedule it + if (initialDateTime > DateTime.UtcNow) + return initialDateTime; + + // else calculate based on minutes difference + + // get the difference + var diff = DateTime.UtcNow - initialDateTime; + + // see how many times the repeater theoretically ran already + var triggerCount = diff / Repeater.Interval; + + // ok lets say repeater was scheduled to run 10h ago. + // we have an interval of 2.4h + // repeater should've ran 4 times- that's 9.6h + // next time should be in 2h from now exactly + // 10/2.4 is 4.166 + // 4.166 - Math.Truncate(4.166) is 0.166 + // initial interval multiplier is 1 - 0.166 = 0.834 + // interval (2.4h) * 0.834 is 2.0016 and that is the initial interval + + var initialIntervalMultiplier = 1 - (triggerCount - Math.Truncate(triggerCount)); + return DateTime.UtcNow + (Repeater.Interval * initialIntervalMultiplier); + } + + public override bool Equals(object obj) + => obj is RunningRepeater rr && rr.Repeater.Id == Repeater.Id; + + public override int GetHashCode() + => Repeater.Id; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/StreamRole/StreamRoleCommands.cs b/src/EllieBot/Modules/Utility/StreamRole/StreamRoleCommands.cs new file mode 100644 index 0000000..399c37e --- /dev/null +++ b/src/EllieBot/Modules/Utility/StreamRole/StreamRoleCommands.cs @@ -0,0 +1,97 @@ +#nullable disable +using EllieBot.Modules.Utility.Common; +using EllieBot.Modules.Utility.Services; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + public partial class StreamRoleCommands : EllieModule + { + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRole(IRole fromRole, IRole addRole) + { + await _service.SetStreamRole(fromRole, addRole); + + await Response().Confirm(strs.stream_role_enabled(Format.Bold(fromRole.ToString()), + Format.Bold(addRole.ToString()))).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRole() + { + await _service.StopStreamRole(ctx.Guild); + await Response().Confirm(strs.stream_role_disabled).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleKeyword([Leftover] string keyword = null) + { + var kw = await _service.SetKeyword(ctx.Guild, keyword); + + if (string.IsNullOrWhiteSpace(keyword)) + await Response().Confirm(strs.stream_role_kw_reset).SendAsync(); + else + await Response().Confirm(strs.stream_role_kw_set(Format.Bold(kw))).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleBlacklist(AddRemove action, [Leftover] IGuildUser user) + { + var success = await _service.ApplyListAction(StreamRoleListType.Blacklist, + ctx.Guild, + action, + user.Id, + user.ToString()); + + if (action == AddRemove.Add) + { + if (success) + await Response().Confirm(strs.stream_role_bl_add(Format.Bold(user.ToString()))).SendAsync(); + else + await Response().Confirm(strs.stream_role_bl_add_fail(Format.Bold(user.ToString()))).SendAsync(); + } + else if (success) + await Response().Confirm(strs.stream_role_bl_rem(Format.Bold(user.ToString()))).SendAsync(); + else + await Response().Error(strs.stream_role_bl_rem_fail(Format.Bold(user.ToString()))).SendAsync(); + } + + [Cmd] + [BotPerm(GuildPerm.ManageRoles)] + [UserPerm(GuildPerm.ManageRoles)] + [RequireContext(ContextType.Guild)] + public async Task StreamRoleWhitelist(AddRemove action, [Leftover] IGuildUser user) + { + var success = await _service.ApplyListAction(StreamRoleListType.Whitelist, + ctx.Guild, + action, + user.Id, + user.ToString()); + + if (action == AddRemove.Add) + { + if (success) + await Response().Confirm(strs.stream_role_wl_add(Format.Bold(user.ToString()))).SendAsync(); + else + await Response().Confirm(strs.stream_role_wl_add_fail(Format.Bold(user.ToString()))).SendAsync(); + } + else if (success) + await Response().Confirm(strs.stream_role_wl_rem(Format.Bold(user.ToString()))).SendAsync(); + else + await Response().Error(strs.stream_role_wl_rem_fail(Format.Bold(user.ToString()))).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/StreamRole/StreamRoleService.cs b/src/EllieBot/Modules/Utility/StreamRole/StreamRoleService.cs new file mode 100644 index 0000000..eee20a1 --- /dev/null +++ b/src/EllieBot/Modules/Utility/StreamRole/StreamRoleService.cs @@ -0,0 +1,338 @@ +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Utility.Common; +using EllieBot.Modules.Utility.Common.Exceptions; +using EllieBot.Db.Models; +using System.Net; + +namespace EllieBot.Modules.Utility.Services; + +public class StreamRoleService : IReadyExecutor, IEService +{ + private readonly DbService _db; + private readonly DiscordSocketClient _client; + private readonly ConcurrentDictionary _guildSettings; + private readonly QueueRunner _queueRunner; + + public StreamRoleService(DiscordSocketClient client, DbService db, IBot bot) + { + _db = db; + _client = client; + + _guildSettings = bot.AllGuildConfigs.ToDictionary(x => x.GuildId, x => x.StreamRole) + .Where(x => x.Value is { Enabled: true }) + .ToConcurrent(); + + _client.PresenceUpdated += OnPresenceUpdate; + + _queueRunner = new QueueRunner(); + } + + private Task OnPresenceUpdate(SocketUser user, SocketPresence? oldPresence, SocketPresence? newPresence) + { + + _ = Task.Run(async () => + { + if (oldPresence?.Activities?.Count != newPresence?.Activities?.Count) + { + var guildUsers = _client.Guilds + .Select(x => x.GetUser(user.Id)) + .Where(x => x is not null); + + foreach (var guildUser in guildUsers) + { + if (_guildSettings.TryGetValue(guildUser.Guild.Id, out var s)) + await RescanUser(guildUser, s); + } + } + }); + + return Task.CompletedTask; + } + + public Task OnReadyAsync() + => Task.WhenAll(_client.Guilds.Select(RescanUsers).WhenAll(), _queueRunner.RunAsync()); + + /// + /// Adds or removes a user from a blacklist or a whitelist in the specified guild. + /// + /// List type + /// Guild + /// Add or rem action + /// User's Id + /// User's name + /// Whether the operation was successful + public async Task ApplyListAction( + StreamRoleListType listType, + IGuild guild, + AddRemove action, + ulong userId, + string userName) + { + ArgumentNullException.ThrowIfNull(userName, nameof(userName)); + + var success = false; + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + + if (listType == StreamRoleListType.Whitelist) + { + var userObj = new StreamRoleWhitelistedUser + { + UserId = userId, + Username = userName + }; + + if (action == AddRemove.Rem) + { + var toDelete = streamRoleSettings.Whitelist.FirstOrDefault(x => x.Equals(userObj)); + if (toDelete is not null) + { + uow.Remove(toDelete); + success = true; + } + } + else + success = streamRoleSettings.Whitelist.Add(userObj); + } + else + { + var userObj = new StreamRoleBlacklistedUser + { + UserId = userId, + Username = userName + }; + + if (action == AddRemove.Rem) + { + var toRemove = streamRoleSettings.Blacklist.FirstOrDefault(x => x.Equals(userObj)); + if (toRemove is not null) + success = streamRoleSettings.Blacklist.Remove(toRemove); + } + else + success = streamRoleSettings.Blacklist.Add(userObj); + } + + await uow.SaveChangesAsync(); + UpdateCache(guild.Id, streamRoleSettings); + } + + if (success) + await RescanUsers(guild); + return success; + } + + /// + /// Sets keyword on a guild and updates the cache. + /// + /// Guild Id + /// Keyword to set + /// The keyword set + public async Task SetKeyword(IGuild guild, string? keyword) + { + keyword = keyword?.Trim().ToLowerInvariant(); + + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + + streamRoleSettings.Keyword = keyword; + UpdateCache(guild.Id, streamRoleSettings); + await uow.SaveChangesAsync(); + } + + await RescanUsers(guild); + return keyword; + } + + /// + /// Gets the currently set keyword on a guild. + /// + /// Guild Id + /// The keyword set + public string GetKeyword(ulong guildId) + { + if (_guildSettings.TryGetValue(guildId, out var outSetting)) + return outSetting.Keyword; + + StreamRoleSettings setting; + using (var uow = _db.GetDbContext()) + { + setting = uow.GetStreamRoleSettings(guildId); + } + + UpdateCache(guildId, setting); + + return setting.Keyword; + } + + /// + /// Sets the role to monitor, and a role to which to add to + /// the user who starts streaming in the monitored role. + /// + /// Role to monitor + /// Role to add to the user + public async Task SetStreamRole(IRole fromRole, IRole addRole) + { + ArgumentNullException.ThrowIfNull(fromRole, nameof(fromRole)); + ArgumentNullException.ThrowIfNull(addRole, nameof(addRole)); + + StreamRoleSettings setting; + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(fromRole.Guild.Id); + + streamRoleSettings.Enabled = true; + streamRoleSettings.AddRoleId = addRole.Id; + streamRoleSettings.FromRoleId = fromRole.Id; + + setting = streamRoleSettings; + await uow.SaveChangesAsync(); + } + + UpdateCache(fromRole.Guild.Id, setting); + + foreach (var usr in await fromRole.GetMembersAsync()) + { + await RescanUser(usr, setting, addRole); + } + } + + /// + /// Stops the stream role feature on the specified guild. + /// + /// Guild + /// Whether to rescan users + public async Task StopStreamRole(IGuild guild, bool cleanup = false) + { + await using (var uow = _db.GetDbContext()) + { + var streamRoleSettings = uow.GetStreamRoleSettings(guild.Id); + streamRoleSettings.Enabled = false; + streamRoleSettings.AddRoleId = 0; + streamRoleSettings.FromRoleId = 0; + await uow.SaveChangesAsync(); + } + + if (_guildSettings.TryRemove(guild.Id, out _) && cleanup) + await RescanUsers(guild); + } + + private async ValueTask RescanUser(IGuildUser user, StreamRoleSettings setting, IRole? addRole = null) + => await _queueRunner.EnqueueAsync(() => RescanUserInternal(user, setting, addRole)); + + private async Task RescanUserInternal(IGuildUser user, StreamRoleSettings setting, IRole? addRole = null) + { + if (user.IsBot) + return; + + var g = (StreamingGame?)user.Activities.FirstOrDefault(a + => a is StreamingGame + && (string.IsNullOrWhiteSpace(setting.Keyword) + || a.Name.ToUpperInvariant().Contains(setting.Keyword.ToUpperInvariant()) + || setting.Whitelist.Any(x => x.UserId == user.Id))); + + if (g is not null + && setting.Enabled + && setting.Blacklist.All(x => x.UserId != user.Id) + && user.RoleIds.Contains(setting.FromRoleId)) + { + await _queueRunner.EnqueueAsync(async () => + { + try + { + addRole ??= user.Guild.GetRole(setting.AddRoleId); + if (addRole is null) + { + await StopStreamRole(user.Guild); + Log.Warning("Stream role in server {RoleId} no longer exists. Stopping", setting.AddRoleId); + return; + } + + //check if he doesn't have addrole already, to avoid errors + if (!user.RoleIds.Contains(addRole.Id)) + { + await user.AddRoleAsync(addRole); + Log.Information("Added stream role to user {User} in {Server} server", + user.ToString(), + user.Guild.ToString()); + } + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden) + { + await StopStreamRole(user.Guild); + Log.Warning(ex, "Error adding stream role(s). Forcibly disabling stream role feature"); + throw new StreamRolePermissionException(); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed adding stream role"); + } + }); + } + else + { + //check if user is in the addrole + if (user.RoleIds.Contains(setting.AddRoleId)) + { + await _queueRunner.EnqueueAsync(async () => + { + try + { + addRole ??= user.Guild.GetRole(setting.AddRoleId); + if (addRole is null) + { + await StopStreamRole(user.Guild); + Log.Warning( + "Addrole doesn't exist in {GuildId} server. Forcibly disabling stream role feature", + user.Guild.Id); + return; + } + + // need to check again in case queuer is taking too long to execute + if (user.RoleIds.Contains(setting.AddRoleId)) + { + await user.RemoveRoleAsync(addRole); + } + + Log.Information("Removed stream role from the user {User} in {Server} server", + user.ToString(), + user.Guild.ToString()); + } + catch (HttpException ex) + { + if (ex.HttpCode == HttpStatusCode.Forbidden) + { + await StopStreamRole(user.Guild); + Log.Warning(ex, "Error removing stream role(s). Forcibly disabling stream role feature"); + } + } + }); + } + } + } + + private async Task RescanUsers(IGuild guild) + { + if (!_guildSettings.TryGetValue(guild.Id, out var setting)) + return; + + var addRole = guild.GetRole(setting.AddRoleId); + if (addRole is null) + return; + + if (setting.Enabled) + { + var users = await guild.GetUsersAsync(CacheMode.CacheOnly); + foreach (var usr in users.Where(x + => x.RoleIds.Contains(setting.FromRoleId) || x.RoleIds.Contains(addRole.Id))) + { + if (usr is { } x) + await RescanUser(x, setting, addRole); + } + } + } + + private void UpdateCache(ulong guildId, StreamRoleSettings setting) + => _guildSettings.AddOrUpdate(guildId, _ => setting, (_, _) => setting); +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Todo/ArchiveTodoResult.cs b/src/EllieBot/Modules/Utility/Todo/ArchiveTodoResult.cs new file mode 100644 index 0000000..d03f59e --- /dev/null +++ b/src/EllieBot/Modules/Utility/Todo/ArchiveTodoResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Utility; + +public enum ArchiveTodoResult +{ + MaxLimitReached, + NoTodos, + Success +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Todo/TodoAddResult.cs b/src/EllieBot/Modules/Utility/Todo/TodoAddResult.cs new file mode 100644 index 0000000..36901e1 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Todo/TodoAddResult.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Utility; + +public enum TodoAddResult +{ + MaxLimitReached, + Success +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Todo/TodoCommands.cs b/src/EllieBot/Modules/Utility/Todo/TodoCommands.cs new file mode 100644 index 0000000..874fc87 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Todo/TodoCommands.cs @@ -0,0 +1,237 @@ +using EllieBot.Db.Models; +using System.Text; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group("todo")] + public partial class Todo : EllieModule + { + [Cmd] + public async Task TodoAdd([Leftover] string todo) + { + var result = await _service.AddAsync(ctx.User.Id, todo); + if (result == TodoAddResult.MaxLimitReached) + { + await Response().Error(strs.todo_add_max_limit).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + public async Task TodoEdit(kwum todoId, [Leftover] string newMessage) + { + if (!await _service.EditAsync(ctx.User.Id, todoId, newMessage)) + { + await Response().Error(strs.todo_not_found).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + public async Task TodoList() + { + var todos = await _service.GetAllTodosAsync(ctx.User.Id); + + if (todos.Length == 0) + { + await Response().Error(strs.todo_list_empty).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(todos) + .PageSize(9) + .AddFooter(false) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.todo_list)); + + ShowTodoItem(items, eb); + + eb.WithFooter(GetText(strs.todo_stats(todos.Length, + todos.Count(x => x.IsDone), + todos.Count(x => !x.IsDone)))); + + return eb; + }) + .SendAsync(); + } + + [Cmd] + public async Task TodoShow(kwum todoId) + { + var todo = await _service.GetTodoAsync(ctx.User.Id, todoId); + + if (todo is null) + { + await Response().Error(strs.todo_not_found).SendAsync(); + return; + } + + await Response() + .Confirm($"`{new kwum(todo.Id)}` {todo.Todo}") + .SendAsync(); + } + + + [Cmd] + public async Task TodoComplete(kwum todoId) + { + if (!await _service.CompleteTodoAsync(ctx.User.Id, todoId)) + { + await Response().Error(strs.todo_not_found).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + public async Task TodoDelete(kwum todoId) + { + if (!await _service.DeleteTodoAsync(ctx.User.Id, todoId)) + { + await Response().Error(strs.todo_not_found).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + public async Task TodoClear() + { + await _service.ClearTodosAsync(ctx.User.Id); + + await Response().Confirm(strs.todo_cleared).SendAsync(); + } + + + private static void ShowTodoItem(IReadOnlyCollection todos, EmbedBuilder eb) + { + var sb = new StringBuilder(); + foreach (var todo in todos) + { + sb.AppendLine(InternalItemShow(todo)); + + sb.AppendLine("---"); + } + + eb.WithDescription(sb.ToString()); + } + + private static string InternalItemShow(TodoModel todo) + => $"{(todo.IsDone ? "✔" : "□")} {Format.Code(new kwum(todo.Id).ToString())} {todo.Todo}"; + + [Group("archive")] + public partial class ArchiveCommands : EllieModule + { + [Cmd] + public async Task TodoArchiveAdd([Leftover] string name) + { + var result = await _service.ArchiveTodosAsync(ctx.User.Id, name); + if (result == ArchiveTodoResult.NoTodos) + { + await Response().Error(strs.todo_no_todos).SendAsync(); + return; + } + + if (result == ArchiveTodoResult.MaxLimitReached) + { + await Response().Error(strs.todo_archive_max_limit).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + [Cmd] + public async Task TodoArchiveList(int page = 1) + { + if (--page < 0) + return; + + var archivedTodoLists = await _service.GetArchivedTodosAsync(ctx.User.Id); + + if (archivedTodoLists.Count == 0) + { + await Response().Error(strs.todo_archive_empty).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(archivedTodoLists) + .PageSize(9) + .CurrentPage(page) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithTitle(GetText(strs.todo_archive_list)) + .WithOkColor(); + + foreach (var archivedList in items) + { + eb.AddField($"id: {archivedList.Id.ToString()}", archivedList.Name, true); + } + + return eb; + }) + .SendAsync(); + } + + [Cmd] + public async Task TodoArchiveShow(int id) + { + var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id); + if (list == null || list.Items.Count == 0) + { + await Response().Error(strs.todo_archive_not_found).SendAsync(); + return; + } + + await Response() + .Paginated() + .Items(list.Items) + .PageSize(9) + .AddFooter(false) + .Page((items, _) => + { + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.todo_archived_list)); + + ShowTodoItem(items, eb); + + eb.WithFooter(GetText(strs.todo_stats(list.Items.Count, + list.Items.Count(x => x.IsDone), + list.Items.Count(x => !x.IsDone)))); + + return eb; + }) + .SendAsync(); + } + + [Cmd] + public async Task TodoArchiveDelete(int id) + { + if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id)) + { + await ctx.ErrorAsync(); + return; + } + + await ctx.OkAsync(); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Todo/TodoService.cs b/src/EllieBot/Modules/Utility/Todo/TodoService.cs new file mode 100644 index 0000000..38ea848 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Todo/TodoService.cs @@ -0,0 +1,192 @@ +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Utility; + +public sealed class TodoService : IEService +{ + private const int ARCHIVE_MAX_COUNT = 9; + private const int TODO_MAX_COUNT = 27; + + private readonly DbService _db; + + public TodoService(DbService db) + { + _db = db; + } + + public async Task AddAsync(ulong userId, string todo) + { + await using var ctx = _db.GetDbContext(); + + if (await ctx + .GetTable() + .Where(x => x.UserId == userId && x.ArchiveId == null) + .CountAsync() >= TODO_MAX_COUNT) + { + return TodoAddResult.MaxLimitReached; + } + + await ctx + .GetTable() + .InsertAsync(() => new TodoModel() + { + UserId = userId, + Todo = todo, + DateAdded = DateTime.UtcNow, + IsDone = false, + }); + + return TodoAddResult.Success; + } + + public async Task EditAsync(ulong userId, int todoId, string newMessage) + { + await using var ctx = _db.GetDbContext(); + return await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == todoId) + .Set(x => x.Todo, newMessage) + .UpdateAsync() > 0; + } + + public async Task GetAllTodosAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + + return await ctx + .GetTable() + .Where(x => x.UserId == userId && x.ArchiveId == null) + .ToArrayAsyncLinqToDB(); + } + + public async Task CompleteTodoAsync(ulong userId, int todoId) + { + await using var ctx = _db.GetDbContext(); + + var count = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == todoId) + .Set(x => x.IsDone, true) + .UpdateAsync(); + + return count > 0; + } + + public async Task DeleteTodoAsync(ulong userId, int todoId) + { + await using var ctx = _db.GetDbContext(); + + var count = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == todoId) + .DeleteAsync(); + + return count > 0; + } + + public async Task ClearTodosAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + + await ctx + .GetTable() + .Where(x => x.UserId == userId && x.ArchiveId == null) + .DeleteAsync(); + } + + public async Task ArchiveTodosAsync(ulong userId, string name) + { + // create a new archive + + await using var ctx = _db.GetDbContext(); + + await using var tr = await ctx.Database.BeginTransactionAsync(); + + // check if the user reached the limit + var count = await ctx + .GetTable() + .Where(x => x.UserId == userId) + .CountAsync(); + + if (count >= ARCHIVE_MAX_COUNT) + return ArchiveTodoResult.MaxLimitReached; + + var inserted = await ctx + .GetTable() + .InsertWithOutputAsync(() => new ArchivedTodoListModel() + { + UserId = userId, + Name = name, + }); + + // mark all existing todos as archived + + var updated = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.ArchiveId == null) + .Set(x => x.ArchiveId, inserted.Id) + .UpdateAsync(); + + if (updated == 0) + { + await tr.RollbackAsync(); + // // delete the empty archive + // await ctx + // .GetTable() + // .Where(x => x.Id == inserted.Id) + // .DeleteAsync(); + + return ArchiveTodoResult.NoTodos; + } + + await tr.CommitAsync(); + + return ArchiveTodoResult.Success; + } + + + public async Task> GetArchivedTodosAsync(ulong userId) + { + await using var ctx = _db.GetDbContext(); + + return await ctx + .GetTable() + .Where(x => x.UserId == userId) + .ToArrayAsyncLinqToDB(); + } + + public async Task GetArchivedTodoListAsync(ulong userId, int archiveId) + { + await using var ctx = _db.GetDbContext(); + + return await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == archiveId) + .LoadWith(x => x.Items) + .FirstOrDefaultAsyncLinqToDB(); + } + + public async Task ArchiveDeleteAsync(ulong userId, int archiveId) + { + await using var ctx = _db.GetDbContext(); + + var count = await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == archiveId) + .DeleteAsync(); + + return count > 0; + } + + public async Task GetTodoAsync(ulong userId, int todoId) + { + await using var ctx = _db.GetDbContext(); + + return await ctx + .GetTable() + .Where(x => x.UserId == userId && x.Id == todoId) + .FirstOrDefaultAsyncLinqToDB(); + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UnitConversion/ConverterService.cs b/src/EllieBot/Modules/Utility/UnitConversion/ConverterService.cs new file mode 100644 index 0000000..12d9f8e --- /dev/null +++ b/src/EllieBot/Modules/Utility/UnitConversion/ConverterService.cs @@ -0,0 +1,99 @@ +#nullable disable +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Modules.Utility.Common; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EllieBot.Modules.Utility.Services; + +public class ConverterService : IEService, IReadyExecutor +{ + private static readonly TypedKey> _convertKey = + new("convert:units"); + + private readonly TimeSpan _updateInterval = new(12, 0, 0); + private readonly DiscordSocketClient _client; + private readonly IBotCache _cache; + private readonly IHttpClientFactory _httpFactory; + + public ConverterService( + DiscordSocketClient client, + IBotCache cache, + IHttpClientFactory factory) + { + _client = client; + _cache = cache; + _httpFactory = factory; + } + + public async Task OnReadyAsync() + { + if (_client.ShardId != 0) + return; + + using var timer = new PeriodicTimer(_updateInterval); + do + { + try + { + await UpdateCurrency(); + } + catch + { + // ignored + } + } while (await timer.WaitForNextTickAsync()); + } + + private async Task GetCurrencyRates() + { + using var http = _httpFactory.CreateClient(); + var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest"); + return JsonSerializer.Deserialize(res); + } + + private async Task UpdateCurrency() + { + var unitTypeString = "currency"; + var currencyRates = await GetCurrencyRates(); + var baseType = new ConvertUnit + { + Triggers = [currencyRates.Base], + Modifier = decimal.One, + UnitType = unitTypeString + }; + var units = currencyRates.ConversionRates.Select(u => new ConvertUnit + { + Triggers = [u.Key], + Modifier = u.Value, + UnitType = unitTypeString + }) + .ToList(); + + var stream = File.OpenRead("data/units.json"); + var defaultUnits = await JsonSerializer.DeserializeAsync(stream); + if(defaultUnits is not null) + units.AddRange(defaultUnits); + + units.Add(baseType); + + await _cache.AddAsync(_convertKey, units); + } + + public async Task> GetUnitsAsync() + => (await _cache.GetAsync(_convertKey)).TryGetValue(out var list) + ? list + : Array.Empty(); +} + +public class Rates +{ + [JsonPropertyName("base")] + public string Base { get; set; } + + [JsonPropertyName("date")] + public DateTime Date { get; set; } + + [JsonPropertyName("rates")] + public Dictionary ConversionRates { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/UnitConversion/UnitConversionCommands.cs b/src/EllieBot/Modules/Utility/UnitConversion/UnitConversionCommands.cs new file mode 100644 index 0000000..61ffc54 --- /dev/null +++ b/src/EllieBot/Modules/Utility/UnitConversion/UnitConversionCommands.cs @@ -0,0 +1,100 @@ +#nullable disable +using EllieBot.Modules.Utility.Services; + +namespace EllieBot.Modules.Utility; + +public partial class Utility +{ + [Group] + public partial class UnitConverterCommands : EllieModule + { + [Cmd] + public async Task ConvertList() + { + var units = await _service.GetUnitsAsync(); + + var embed = _sender.CreateEmbed().WithTitle(GetText(strs.convertlist)).WithOkColor(); + + + foreach (var g in units.GroupBy(x => x.UnitType)) + { + embed.AddField(g.Key.ToTitleCase(), + string.Join(", ", g.Select(x => x.Triggers.FirstOrDefault()).OrderBy(x => x))); + } + + await Response().Embed(embed).SendAsync(); + } + + [Cmd] + [Priority(0)] + public async Task Convert(string origin, string target, decimal value) + { + var units = await _service.GetUnitsAsync(); + var originUnit = units.FirstOrDefault(x + => x.Triggers.Select(y => y.ToUpperInvariant()).Contains(origin.ToUpperInvariant())); + var targetUnit = units.FirstOrDefault(x + => x.Triggers.Select(y => y.ToUpperInvariant()).Contains(target.ToUpperInvariant())); + if (originUnit is null || targetUnit is null) + { + await Response().Error(strs.convert_not_found(Format.Bold(origin), Format.Bold(target))).SendAsync(); + return; + } + + if (originUnit.UnitType != targetUnit.UnitType) + { + await Response() + .Error(strs.convert_type_error(Format.Bold(originUnit.Triggers.First()), + Format.Bold(targetUnit.Triggers.First()))) + .SendAsync(); + return; + } + + decimal res; + if (originUnit.Triggers == targetUnit.Triggers) + res = value; + else if (originUnit.UnitType == "temperature") + { + //don't really care too much about efficiency, so just convert to Kelvin, then to target + switch (originUnit.Triggers.First().ToUpperInvariant()) + { + case "C": + res = value + 273.15m; //celcius! + break; + case "F": + res = (value + 459.67m) * (5m / 9m); + break; + default: + res = value; + break; + } + + //from Kelvin to target + switch (targetUnit.Triggers.First().ToUpperInvariant()) + { + case "C": + res -= 273.15m; //celcius! + break; + case "F": + res = (res * (9m / 5m)) - 459.67m; + break; + } + } + else + { + if (originUnit.UnitType == "currency") + res = value * targetUnit.Modifier / originUnit.Modifier; + else + res = value * originUnit.Modifier / targetUnit.Modifier; + } + + res = Math.Round(res, 4); + + await Response() + .Confirm(strs.convert(value, + originUnit.Triggers.Last(), + res, + targetUnit.Triggers.Last())) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/Utility.cs b/src/EllieBot/Modules/Utility/Utility.cs new file mode 100644 index 0000000..89fee19 --- /dev/null +++ b/src/EllieBot/Modules/Utility/Utility.cs @@ -0,0 +1,786 @@ +using EllieBot.Modules.Utility.Services; +using Newtonsoft.Json; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using EllieBot.Modules.Searches.Common; + +namespace EllieBot.Modules.Utility; + +public partial class Utility : EllieModule +{ + public enum CreateInviteType + { + Any, + New + } + + public enum MeOrBot + { + Me, + Bot + } + + private static readonly JsonSerializerOptions _showEmbedSerializerOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = LowerCaseNamingPolicy.Default + }; + + private readonly DiscordSocketClient _client; + private readonly ICoordinator _coord; + private readonly IStatsService _stats; + private readonly IBotCredentials _creds; + private readonly DownloadTracker _tracker; + private readonly IHttpClientFactory _httpFactory; + private readonly VerboseErrorsService _veService; + private readonly IServiceProvider _services; + private readonly AfkService _afkService; + + public Utility( + DiscordSocketClient client, + ICoordinator coord, + IStatsService stats, + IBotCredentials creds, + DownloadTracker tracker, + IHttpClientFactory httpFactory, + VerboseErrorsService veService, + IServiceProvider services, + AfkService afkService) + { + _client = client; + _coord = coord; + _stats = stats; + _creds = creds; + _tracker = tracker; + _httpFactory = httpFactory; + _veService = veService; + _services = services; + _afkService = afkService; + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(1)] + public async Task Say(ITextChannel channel, [Leftover] SmartText message) + { + if (!((IGuildUser)ctx.User).GetPermissions(channel).SendMessages) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + if (!((ctx.Guild as SocketGuild)?.CurrentUser.GetPermissions(channel).SendMessages ?? false)) + { + await Response().Error(strs.insuf_perms_i).SendAsync(); + return; + } + + var repCtx = new ReplacementContext(Context); + message = await repSvc.ReplaceAsync(message, repCtx); + + await Response() + .Text(message) + .Channel(channel) + .UserBasedMentions() + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + [Priority(0)] + public Task Say([Leftover] SmartText message) + => Say((ITextChannel)ctx.Channel, message); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task WhosPlaying([Leftover] string? game) + { + game = game?.Trim().ToUpperInvariant(); + if (string.IsNullOrWhiteSpace(game)) + return; + + if (ctx.Guild is not SocketGuild socketGuild) + { + Log.Warning("Can't cast guild to socket guild"); + return; + } + + + var userNames = new List(socketGuild.Users.Count / 100); + foreach (var user in socketGuild.Users) + { + if (user.Activities.Any(x => x.Name is not null && x.Name.ToUpperInvariant() == game)) + { + userNames.Add(user); + } + } + + await Response() + .Sanitize() + .Paginated() + .Items(userNames) + .PageSize(20) + .Page((names, _) => + { + if (names.Count == 0) + { + return _sender.CreateEmbed() + .WithErrorColor() + .WithDescription(GetText(strs.nobody_playing_game)); + } + + var eb = _sender.CreateEmbed() + .WithOkColor(); + + var users = names.Join('\n'); + + return eb.WithDescription(users); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(0)] + public async Task InRole(int page, [Leftover] IRole? role = null) + { + if (--page < 0) + return; + + await ctx.Channel.TriggerTypingAsync(); + await _tracker.EnsureUsersDownloadedAsync(ctx.Guild); + + var users = await ctx.Guild.GetUsersAsync( + CacheMode.CacheOnly + ); + + users = role is null + ? users + : users.Where(u => u.RoleIds.Contains(role.Id)).ToList(); + + + var roleUsers = new List(users.Count); + foreach (var u in users) + { + roleUsers.Add($"{u.Mention} {Format.Spoiler(Format.Code(u.Username))}"); + } + + await Response() + .Paginated() + .Items(roleUsers) + .PageSize(20) + .CurrentPage(page) + .Page((pageUsers, _) => + { + if (pageUsers.Count == 0) + return _sender.CreateEmbed().WithOkColor().WithDescription(GetText(strs.no_user_on_this_page)); + + var roleName = Format.Bold(role?.Name ?? "No Role"); + + return _sender.CreateEmbed() + .WithOkColor() + .WithTitle(GetText(strs.inrole_list(roleName, roleUsers.Count))) + .WithDescription(string.Join("\n", pageUsers)); + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [Priority(1)] + public Task InRole([Leftover] IRole? role = null) + => InRole(1, role); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task CheckPerms(MeOrBot who = MeOrBot.Me) + { + var user = who == MeOrBot.Me ? (IGuildUser)ctx.User : ((SocketGuild)ctx.Guild).CurrentUser; + var perms = user.GetPermissions((ITextChannel)ctx.Channel); + await SendPerms(perms); + } + + private async Task SendPerms(ChannelPermissions perms) + { + var builder = new StringBuilder(); + foreach (var p in perms.GetType() + .GetProperties() + .Where(static p => + { + var method = p.GetGetMethod(); + if (method is null) + return false; + return !method.GetParameters().Any(); + })) + builder.AppendLine($"{p.Name} : {p.GetValue(perms, null)}"); + await Response().Confirm(builder.ToString()).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task UserId([Leftover] IGuildUser? target = null) + { + var usr = target ?? ctx.User; + await Response() + .Confirm(strs.userid("🆔", + Format.Bold(usr.ToString()), + Format.Code(usr.Id.ToString()))) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task RoleId([Leftover] IRole role) + => await Response() + .Confirm(strs.roleid("🆔", + Format.Bold(role.ToString()), + Format.Code(role.Id.ToString()))) + .SendAsync(); + + [Cmd] + public async Task ChannelId() + => await Response().Confirm(strs.channelid("🆔", Format.Code(ctx.Channel.Id.ToString()))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ServerId() + => await Response().Confirm(strs.serverid("🆔", Format.Code(ctx.Guild.Id.ToString()))).SendAsync(); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task Roles(IGuildUser? target, int page = 1) + { + var guild = ctx.Guild; + + const int rolesPerPage = 20; + + if (page is < 1 or > 100) + return; + + if (target is not null) + { + var roles = target.GetRoles() + .Except(new[] { guild.EveryoneRole }) + .OrderBy(r => -r.Position) + .Skip((page - 1) * rolesPerPage) + .Take(rolesPerPage) + .ToArray(); + if (!roles.Any()) + await Response().Error(strs.no_roles_on_page).SendAsync(); + else + { + await Response() + .Confirm(GetText(strs.roles_page(page, Format.Bold(target.ToString()))), + "\n• " + string.Join("\n• ", (IEnumerable)roles)) + .SendAsync(); + } + } + else + { + var roles = guild.Roles.Except(new[] { guild.EveryoneRole }) + .OrderBy(r => -r.Position) + .Skip((page - 1) * rolesPerPage) + .Take(rolesPerPage) + .ToArray(); + if (!roles.Any()) + await Response().Error(strs.no_roles_on_page).SendAsync(); + else + { + await Response() + .Confirm(GetText(strs.roles_all_page(page)), + "\n• " + string.Join("\n• ", (IEnumerable)roles).SanitizeMentions(true)) + .SendAsync(); + } + } + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task Roles(int page = 1) + => Roles(null, page); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ChannelTopic([Leftover] ITextChannel? channel = null) + { + if (channel is null) + channel = (ITextChannel)ctx.Channel; + + var topic = channel.Topic; + if (string.IsNullOrWhiteSpace(topic)) + await Response().Error(strs.no_topic_set).SendAsync(); + else + await Response().Confirm(GetText(strs.channel_topic), topic).SendAsync(); + } + + [Cmd] + public async Task Stats() + { + var ownerIds = string.Join("\n", _creds.OwnerIds); + if (string.IsNullOrWhiteSpace(ownerIds)) + ownerIds = "-"; + + var eb = _sender.CreateEmbed() + .WithOkColor() + .WithAuthor($"EllieBot v{StatsService.BotVersion}", + "https://cdn.elliebot.net/Ellie.png", + "https://docs.elliebot.net") + .AddField(GetText(strs.author), _stats.Author, true) + .AddField(GetText(strs.botid), _client.CurrentUser.Id.ToString(), true) + .AddField(GetText(strs.shard), + $"#{_client.ShardId} / {_creds.TotalShards}", + true) + .AddField(GetText(strs.commands_ran), _stats.CommandsRan.ToString(), true) + .AddField(GetText(strs.messages), + $"{_stats.MessageCounter} ({_stats.MessagesPerSecond:F2}/sec)", + true) + .AddField(GetText(strs.memory), + FormattableString.Invariant($"{_stats.GetPrivateMemoryMegabytes():F2} MB"), + true) + .AddField(GetText(strs.owner_ids), ownerIds, true) + .AddField(GetText(strs.uptime), _stats.GetUptimeString("\n"), true) + .AddField(GetText(strs.presence), + GetText(strs.presence_txt(_coord.GetGuildCount(), + _stats.TextChannels, + _stats.VoiceChannels)), + true); + + await Response() + .Embed(eb) + .SendAsync(); + } + + [Cmd] + public async Task Showemojis([Leftover] string _) + { + var tags = ctx.Message.Tags.Where(t => t.Type == TagType.Emoji).Select(t => (Emote)t.Value); + + var result = string.Join("\n", tags.Select(m => GetText(strs.showemojis(m, m.Url)))); + + if (string.IsNullOrWhiteSpace(result)) + await Response().Error(strs.showemojis_none).SendAsync(); + else + await Response().Text(result.TrimTo(2000)).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(2)] + public Task EmojiAdd(string name, Emote emote) + => EmojiAdd(name, emote.Url); + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(1)] + public Task EmojiAdd(Emote emote) + => EmojiAdd(emote.Name, emote.Url); + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(0)] + public async Task EmojiAdd(string name, string? url = null) + { + name = name.Trim(':'); + + url ??= ctx.Message.Attachments.FirstOrDefault()?.Url; + + if (url is null) + return; + + using var http = _httpFactory.CreateClient(); + using var res = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + if (!res.IsImage() || res.GetContentLength() > 262_144) + { + await Response().Error(strs.invalid_emoji_link).SendAsync(); + return; + } + + await using var imgStream = await res.Content.ReadAsStreamAsync(); + Emote em; + try + { + em = await ctx.Guild.CreateEmoteAsync(name, new(imgStream)); + } + catch (Exception ex) + { + Log.Warning(ex, "Error adding emoji on server {GuildId}", ctx.Guild.Id); + + await Response().Error(strs.emoji_add_error).SendAsync(); + return; + } + + await Response().Confirm(strs.emoji_added(em.ToString())).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + [Priority(0)] + public async Task EmojiRemove(params Emote[] emotes) + { + if (emotes.Length == 0) + return; + + var g = (SocketGuild)ctx.Guild; + + var fails = new List(); + foreach (var emote in emotes) + { + var guildEmote = g.Emotes.FirstOrDefault(x => x.Id == emote.Id); + if (guildEmote is null) + { + fails.Add(emote); + } + else + { + await ctx.Guild.DeleteEmoteAsync(guildEmote); + } + } + + if (fails.Count > 0) + { + await Response().Pending(strs.emoji_not_removed(fails.Select(x => x.ToString()).Join(" "))).SendAsync(); + return; + } + + await ctx.OkAsync(); + } + + + [Cmd] + [RequireContext(ContextType.Guild)] + [BotPerm(GuildPerm.ManageEmojisAndStickers)] + [UserPerm(GuildPerm.ManageEmojisAndStickers)] + public async Task StickerAdd(string? name = null, string? description = null, params string[] tags) + { + string format; + Stream? stream = null; + + try + { + if (ctx.Message.Stickers.Count is 1 && ctx.Message.Stickers.First() is SocketSticker ss) + { + name ??= ss.Name; + description = ss.Description; + tags = tags is null or { Length: 0 } ? ss.Tags.ToArray() : tags; + format = FormatToExtension(ss.Format); + + using var http = _httpFactory.CreateClient(); + stream = await http.GetStreamAsync(ss.GetStickerUrl()); + } + else if (ctx.Message.Attachments.Count is 1 && name is not null) + { + if (tags.Length == 0) + tags = [name]; + + if (ctx.Message.Attachments.Count != 1) + { + await Response().Error(strs.sticker_error).SendAsync(); + return; + } + + var attach = ctx.Message.Attachments.First(); + + + if (attach.Size > 512_000 || attach.Width != 300 || attach.Height != 300) + { + await Response().Error(strs.sticker_error).SendAsync(); + return; + } + + format = attach.Filename + .Split('.') + .Last() + .ToLowerInvariant(); + + if (string.IsNullOrWhiteSpace(format) || (format != "png" && format != "apng")) + { + await Response().Error(strs.sticker_error).SendAsync(); + return; + } + + using var http = _httpFactory.CreateClient(); + stream = await http.GetStreamAsync(attach.Url); + } + else + { + await Response().Error(strs.sticker_error).SendAsync(); + return; + } + + try + { + await ctx.Guild.CreateStickerAsync( + name, + stream, + $"{name}.{format}", + tags, + string.IsNullOrWhiteSpace(description) ? "Missing description" : description + ); + + await ctx.OkAsync(); + } + catch + (Exception ex) + { + Log.Warning(ex, "Error occurred while adding a sticker: {Message}", ex.Message); + await Response().Error(strs.error_occured).SendAsync(); + } + } + finally + { + await (stream?.DisposeAsync() ?? ValueTask.CompletedTask); + } + } + + private static string FormatToExtension(StickerFormatType format) + { + switch (format) + { + case StickerFormatType.None: + case StickerFormatType.Png: + case StickerFormatType.Apng: + return "png"; + case StickerFormatType.Lottie: + return "lottie"; + default: + throw new ArgumentException(nameof(format)); + } + } + + [Cmd] + [OwnerOnly] + public async Task ServerList(int page = 1) + { + page -= 1; + + if (page < 0) + return; + + var allGuilds = _client.Guilds + .OrderBy(g => g.Name) + .ToList(); + + await Response() + .Paginated() + .Items(allGuilds) + .PageSize(9) + .Page((guilds, _) => + { + if (!guilds.Any()) + { + return _sender.CreateEmbed() + .WithDescription(GetText(strs.listservers_none)) + .WithErrorColor(); + } + + var embed = _sender.CreateEmbed() + .WithOkColor(); + foreach (var guild in guilds) + embed.AddField(guild.Name, GetText(strs.listservers(guild.Id, guild.MemberCount, guild.OwnerId))); + + return embed; + }) + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + public Task ShowEmbed(ulong messageId) + => ShowEmbed((ITextChannel)ctx.Channel, messageId); + + [Cmd] + [RequireContext(ContextType.Guild)] + public async Task ShowEmbed(ITextChannel ch, ulong messageId) + { + var user = (IGuildUser)ctx.User; + var perms = user.GetPermissions(ch); + if (!perms.ReadMessageHistory || !perms.ViewChannel) + { + await Response().Error(strs.insuf_perms_u).SendAsync(); + return; + } + + var msg = await ch.GetMessageAsync(messageId); + if (msg is null) + { + await Response().Error(strs.msg_not_found).SendAsync(); + return; + } + + if (!msg.Embeds.Any()) + { + await Response().Error(strs.not_found).SendAsync(); + return; + } + + var json = new SmartEmbedTextArray() + { + Content = msg.Content, + Embeds = msg.Embeds + .Map(x => new SmartEmbedArrayElementText(x)) + }.ToJson(_showEmbedSerializerOptions); + + await Response().Confirm(Format.Code(json, "json").Replace("](", "]\\(")).SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [OwnerOnly] + public async Task SaveChat(int cnt) + { + var msgs = new List(cnt); + await ctx.Channel.GetMessagesAsync(cnt).ForEachAsync(dled => msgs.AddRange(dled)); + + var title = $"Chatlog-{ctx.Guild.Name}/#{ctx.Channel.Name}-{DateTime.Now}.txt"; + var grouping = msgs.GroupBy(x => $"{x.CreatedAt.Date:dd.MM.yyyy}") + .Select(g => new + { + date = g.Key, + messages = g.OrderBy(x => x.CreatedAt) + .Select(s => + { + var msg = $"【{s.Timestamp:HH:mm:ss}】{s.Author}:"; + if (string.IsNullOrWhiteSpace(s.ToString())) + { + if (s.Attachments.Any()) + { + msg += "FILES_UPLOADED: " + + string.Join("\n", s.Attachments.Select(x => x.Url)); + } + else if (s.Embeds.Any()) + { + msg += "EMBEDS: " + + string.Join("\n--------\n", + s.Embeds.Select(x + => $"Description: {x.Description}")); + } + } + else + msg += s.ToString(); + + return msg; + }) + }); + await using var stream = await JsonConvert.SerializeObject(grouping, Formatting.Indented).ToStream(); + await ctx.User.SendFileAsync(stream, title, title); + } + + [Cmd] + [Ratelimit(3)] + public async Task Ping() + { + var sw = Stopwatch.StartNew(); + var msg = await Response().Text("🏓").SendAsync(); + sw.Stop(); + msg.DeleteAfter(0); + + await Response() + .Confirm($"{Format.Bold(ctx.User.ToString())} 🏓 {(int)sw.Elapsed.TotalMilliseconds}ms") + .SendAsync(); + } + + [Cmd] + [RequireContext(ContextType.Guild)] + [UserPerm(GuildPerm.ManageMessages)] + public async Task VerboseError(bool? newstate = null) + { + var state = _veService.ToggleVerboseErrors(ctx.Guild.Id, newstate); + + if (state) + await Response().Confirm(strs.verbose_errors_enabled).SendAsync(); + else + await Response().Confirm(strs.verbose_errors_disabled).SendAsync(); + } + + [Cmd] + public async Task Afk([Leftover] string text = "No reason specified.") + { + var succ = await _afkService.SetAfkAsync(ctx.User.Id, text); + + if (succ) + { + await Response() + .Confirm(strs.afk_set) + .SendAsync(); + } + } + + [Cmd] + [NoPublicBot] + [OwnerOnly] + public async Task Eval([Leftover] string scriptText) + { + _ = ctx.Channel.TriggerTypingAsync(); + + if (scriptText.StartsWith("```cs")) + scriptText = scriptText[5..]; + else if (scriptText.StartsWith("```")) + scriptText = scriptText[3..]; + + if (scriptText.EndsWith("```")) + scriptText = scriptText[..^3]; + + var script = CSharpScript.Create(scriptText, + ScriptOptions.Default + .WithReferences(this.GetType().Assembly) + .WithImports( + "System", + "System.Collections.Generic", + "System.IO", + "System.Linq", + "System.Net.Http", + "System.Threading", + "System.Threading.Tasks", + "EllieBot", + "EllieBot.Extensions", + "Microsoft.Extensions.DependencyInjection", + "EllieBot.Common", + "EllieBot.Modules", + "System.Text", + "System.Text.Json"), + globalsType: typeof(EvalGlobals)); + + try + { + var result = await script.RunAsync(new EvalGlobals() + { + ctx = this.ctx, + guild = this.ctx.Guild, + channel = this.ctx.Channel, + user = this.ctx.User, + self = this, + services = _services + }); + + var output = result.ReturnValue?.ToString(); + if (!string.IsNullOrWhiteSpace(output)) + { + var eb = _sender.CreateEmbed() + .WithOkColor() + .AddField("Code", scriptText) + .AddField("Output", output.TrimTo(512)!); + + _ = Response().Embed(eb).SendAsync(); + } + } + catch (Exception ex) + { + await Response().Error(ex.Message).SendAsync(); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/VerboseErrorsService.cs b/src/EllieBot/Modules/Utility/VerboseErrorsService.cs new file mode 100644 index 0000000..e8e8c43 --- /dev/null +++ b/src/EllieBot/Modules/Utility/VerboseErrorsService.cs @@ -0,0 +1,70 @@ +#nullable disable +namespace EllieBot.Modules.Utility.Services; + +public class VerboseErrorsService : IEService +{ + private readonly ConcurrentHashSet _guildsDisabled; + private readonly DbService _db; + private readonly CommandHandler _ch; + private readonly ICommandsUtilityService _hs; + private readonly IMessageSenderService _sender; + + public VerboseErrorsService( + IBot bot, + DbService db, + CommandHandler ch, + IMessageSenderService sender, + ICommandsUtilityService hs) + { + _db = db; + _ch = ch; + _hs = hs; + _sender = sender; + + _ch.CommandErrored += LogVerboseError; + + _guildsDisabled = new(bot.AllGuildConfigs.Where(x => !x.VerboseErrors).Select(x => x.GuildId)); + } + + private async Task LogVerboseError(CommandInfo cmd, ITextChannel channel, string reason) + { + if (channel is null || _guildsDisabled.Contains(channel.GuildId)) + return; + + try + { + var embed = _hs.GetCommandHelp(cmd, channel.Guild) + .WithTitle("Command Error") + .WithDescription(reason) + .WithFooter("Admin may disable verbose errors via `.ve` command") + .WithErrorColor(); + + await _sender.Response(channel).Embed(embed).SendAsync(); + } + catch + { + Log.Information("Verbose error wasn't able to be sent to the server: {GuildId}", + channel.GuildId); + } + } + + public bool ToggleVerboseErrors(ulong guildId, bool? maybeEnabled = null) + { + using var uow = _db.GetDbContext(); + var gc = uow.GuildConfigsForId(guildId, set => set); + + if (maybeEnabled is bool isEnabled) // set it + gc.VerboseErrors = isEnabled; + else // toggle it + isEnabled = gc.VerboseErrors = !gc.VerboseErrors; + + uow.SaveChanges(); + + if (isEnabled) // This doesn't need to be duplicated inside the using block + _guildsDisabled.TryRemove(guildId); + else + _guildsDisabled.Add(guildId); + + return isEnabled; + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/_common/ConvertUnit.cs b/src/EllieBot/Modules/Utility/_common/ConvertUnit.cs new file mode 100644 index 0000000..b65fdea --- /dev/null +++ b/src/EllieBot/Modules/Utility/_common/ConvertUnit.cs @@ -0,0 +1,12 @@ +#nullable disable +using System.Diagnostics; + +namespace EllieBot.Modules.Utility.Common; + +[DebuggerDisplay("Type: {UnitType} Trigger: {Triggers[0]} Mod: {Modifier}")] +public class ConvertUnit +{ + public string[] Triggers { get; set; } + public string UnitType { get; set; } + public decimal Modifier { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/_common/EvalGlobals.cs b/src/EllieBot/Modules/Utility/_common/EvalGlobals.cs new file mode 100644 index 0000000..a31c056 --- /dev/null +++ b/src/EllieBot/Modules/Utility/_common/EvalGlobals.cs @@ -0,0 +1,13 @@ +// ReSharper disable InconsistentNaming +#nullable disable +namespace EllieBot.Modules.Utility; + +public class EvalGlobals +{ + public ICommandContext ctx; + public Utility self; + public IUser user; + public IMessageChannel channel; + public IGuild guild; + public IServiceProvider services; +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRoleNotFoundException.cs b/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRoleNotFoundException.cs new file mode 100644 index 0000000..d1880b2 --- /dev/null +++ b/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRoleNotFoundException.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Modules.Utility.Common.Exceptions; + +public class StreamRoleNotFoundException : Exception +{ + public StreamRoleNotFoundException() + : base("Stream role wasn't found.") + { + } + + public StreamRoleNotFoundException(string message) + : base(message) + { + } + + public StreamRoleNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRolePermissionException.cs b/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRolePermissionException.cs new file mode 100644 index 0000000..d75c53c --- /dev/null +++ b/src/EllieBot/Modules/Utility/_common/Exceptions/StreamRolePermissionException.cs @@ -0,0 +1,20 @@ +#nullable disable +namespace EllieBot.Modules.Utility.Common.Exceptions; + +public class StreamRolePermissionException : Exception +{ + public StreamRolePermissionException() + : base("Stream role was unable to be applied.") + { + } + + public StreamRolePermissionException(string message) + : base(message) + { + } + + public StreamRolePermissionException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/EllieBot/Modules/Utility/_common/StreamRoleListType.cs b/src/EllieBot/Modules/Utility/_common/StreamRoleListType.cs new file mode 100644 index 0000000..aca487b --- /dev/null +++ b/src/EllieBot/Modules/Utility/_common/StreamRoleListType.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Modules.Utility.Common; + +public enum StreamRoleListType +{ + Whitelist, + Blacklist +} \ No newline at end of file