Added Utility module
This commit is contained in:
parent
aee8d32f61
commit
4b5bfed33a
42 changed files with 5792 additions and 0 deletions
148
src/EllieBot/Modules/Utility/AfkService.cs
Normal file
148
src/EllieBot/Modules/Utility/AfkService.cs
Normal file
|
@ -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<string> GetKey(ulong userId)
|
||||
=> new($"afk:msg:{userId}");
|
||||
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
314
src/EllieBot/Modules/Utility/Ai/AiAssistantService.cs
Normal file
314
src/EllieBot/Modules/Utility/Ai/AiAssistantService.cs
Normal file
|
@ -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<AiCommandModel> _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<OneOf.OneOf<EllieCommandCallModel, GetCommandErrorResult>> TryGetCommandAsync(
|
||||
ulong userId,
|
||||
string prompt,
|
||||
IReadOnlyCollection<AiCommandModel> 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<CommandPromptResultModel>();
|
||||
|
||||
|
||||
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<AiCommandModel> 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<AiCommandModel>();
|
||||
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<bool> 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<bool> 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<bool>($"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 <https://patreon.com/elliebot>",
|
||||
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 + "\"";
|
||||
}
|
15
src/EllieBot/Modules/Utility/Ai/AiCommandModel.cs
Normal file
15
src/EllieBot/Modules/Utility/Ai/AiCommandModel.cs
Normal file
|
@ -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<AiCommandParamModel> Params { get; set; }
|
||||
}
|
12
src/EllieBot/Modules/Utility/Ai/AiCommandParamModel.cs
Normal file
12
src/EllieBot/Modules/Utility/Ai/AiCommandParamModel.cs
Normal file
|
@ -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; }
|
||||
}
|
16
src/EllieBot/Modules/Utility/Ai/CommandPromptResultModel.cs
Normal file
16
src/EllieBot/Modules/Utility/Ai/CommandPromptResultModel.cs
Normal file
|
@ -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<string, string> Arguments { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("remaining")]
|
||||
[JsonConverter(typeof(NumberToStringConverter))]
|
||||
public string Remaining { get; set; } = string.Empty;
|
||||
}
|
8
src/EllieBot/Modules/Utility/Ai/EllieCommandCallModel.cs
Normal file
8
src/EllieBot/Modules/Utility/Ai/EllieCommandCallModel.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public sealed class EllieCommandCallModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required IReadOnlyList<string> Arguments { get; set; }
|
||||
public required string Remaining { get; set; }
|
||||
}
|
20
src/EllieBot/Modules/Utility/Ai/IAiAssistantService.cs
Normal file
20
src/EllieBot/Modules/Utility/Ai/IAiAssistantService.cs
Normal file
|
@ -0,0 +1,20 @@
|
|||
using OneOf;
|
||||
|
||||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public interface IAiAssistantService
|
||||
{
|
||||
Task<OneOf<EllieCommandCallModel, GetCommandErrorResult>> TryGetCommandAsync(
|
||||
ulong userId,
|
||||
string prompt,
|
||||
IReadOnlyCollection<AiCommandModel> commands,
|
||||
string prefix);
|
||||
|
||||
IReadOnlyCollection<AiCommandModel> GetCommands();
|
||||
|
||||
Task<bool> TryExecuteAiCommand(
|
||||
IGuild guild,
|
||||
IUserMessage msg,
|
||||
ITextChannel channel,
|
||||
string query);
|
||||
}
|
22
src/EllieBot/Modules/Utility/Ai/UtilityCommands.cs
Normal file
22
src/EllieBot/Modules/Utility/Ai/UtilityCommands.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public partial class Utility
|
||||
{
|
||||
[Group]
|
||||
public partial class PromptCommands : EllieModule<IAiAssistantService>
|
||||
{
|
||||
[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 + "\"";
|
||||
}
|
||||
}
|
138
src/EllieBot/Modules/Utility/Alias/AliasCommands.cs
Normal file
138
src/EllieBot/Modules/Utility/Alias/AliasCommands.cs
Normal file
|
@ -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<AliasService>
|
||||
{
|
||||
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<CommandAlias>().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<string, string>
|
||||
{
|
||||
{ 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();
|
||||
}
|
||||
}
|
||||
}
|
99
src/EllieBot/Modules/Utility/Alias/AliasService.cs
Normal file
99
src/EllieBot/Modules/Utility/Alias/AliasService.cs
Normal file
|
@ -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<ulong, ConcurrentDictionary<string, string>> 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<GuildConfig>()
|
||||
.Include(gc => gc.CommandAliases)
|
||||
.Where(x => guildIds.Contains(x.GuildId))
|
||||
.ToList();
|
||||
|
||||
AliasMaps = new(configs.ToDictionary(x => x.GuildId,
|
||||
x => new ConcurrentDictionary<string, string>(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<string> 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;
|
||||
}
|
||||
}
|
48
src/EllieBot/Modules/Utility/Calc/CalcCommands.cs
Normal file
48
src/EllieBot/Modules/Utility/Calc/CalcCommands.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
154
src/EllieBot/Modules/Utility/ConfigCommands.cs
Normal file
154
src/EllieBot/Modules/Utility/ConfigCommands.cs
Normal file
|
@ -0,0 +1,154 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public partial class Utility
|
||||
{
|
||||
public partial class ConfigCommands : EllieModule
|
||||
{
|
||||
private readonly IEnumerable<IConfigService> _settingServices;
|
||||
|
||||
public ConfigCommands(IEnumerable<IConfigService> 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<string> 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");
|
||||
}
|
||||
}
|
||||
}
|
118
src/EllieBot/Modules/Utility/Giveaway/GiveawayCommands.cs
Normal file
118
src/EllieBot/Modules/Utility/Giveaway/GiveawayCommands.cs
Normal file
|
@ -0,0 +1,118 @@
|
|||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public partial class Utility
|
||||
{
|
||||
[Name("Giveaways")]
|
||||
[Group("ga")]
|
||||
public partial class GiveawayCommands : EllieModule<GiveawayService>
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
359
src/EllieBot/Modules/Utility/Giveaway/GiveawayService.cs
Normal file
359
src/EllieBot/Modules/Utility/Giveaway/GiveawayService.cs
Normal file
|
@ -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<GiveawayModel> _giveawayCache = new SortedSet<GiveawayModel>();
|
||||
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<IUserMessage, ulong> msg,
|
||||
Cacheable<IMessageChannel, ulong> 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<IUserMessage, ulong> msg, Cacheable<IMessageChannel, ulong> 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<GiveawayModel>()
|
||||
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId))
|
||||
.ToArrayAsync();
|
||||
|
||||
lock (_giveawayCache)
|
||||
{
|
||||
_giveawayCache = new(gas, Comparer<GiveawayModel>.Create((x, y) => x.EndsAt.CompareTo(y.EndsAt)));
|
||||
}
|
||||
|
||||
var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
IEnumerable<GiveawayModel> 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<int?> 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<GiveawayModel>()
|
||||
.CountAsync(x => x.GuildId == guildId);
|
||||
|
||||
if (count >= 5)
|
||||
return null;
|
||||
|
||||
var endsAt = DateTime.UtcNow + duration;
|
||||
var ga = await ctx.GetTable<GiveawayModel>()
|
||||
.InsertWithOutputAsync(() => new GiveawayModel
|
||||
{
|
||||
GuildId = guildId,
|
||||
MessageId = messageId,
|
||||
ChannelId = channelId,
|
||||
Message = message,
|
||||
EndsAt = endsAt,
|
||||
});
|
||||
|
||||
lock (_giveawayCache)
|
||||
{
|
||||
_giveawayCache.Add(ga);
|
||||
}
|
||||
|
||||
return ga.Id;
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> EndGiveawayAsync(ulong guildId, int id)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var giveaway = await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.Where(x => x.GuildId == guildId && x.Id == id)
|
||||
.LoadWith(x => x.Participants)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
|
||||
if (giveaway is null)
|
||||
return false;
|
||||
|
||||
await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.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<bool> RerollGiveawayAsync(ulong guildId, int giveawayId)
|
||||
{
|
||||
var rerollModel = _cache.Get<GiveawayRerollData>("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<bool> CancelGiveawayAsync(ulong guildId, int id)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var ga = await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.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<IReadOnlyCollection<GiveawayModel>> GetGiveawaysAsync(ulong guildId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
return await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> JoinGivawayAsync(ulong messageId, ulong userId, string userName)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var giveaway = await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.Where(x => x.MessageId == messageId)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
|
||||
if (giveaway is null)
|
||||
return false;
|
||||
|
||||
// add the user to the database
|
||||
await ctx.GetTable<GiveawayUser>()
|
||||
.InsertAsync(
|
||||
() => new GiveawayUser()
|
||||
{
|
||||
UserId = userId,
|
||||
GiveawayId = giveaway.Id,
|
||||
Name = userName,
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> LeaveGivawayAsync(ulong messageId, ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var giveaway = await ctx
|
||||
.GetTable<GiveawayModel>()
|
||||
.Where(x => x.MessageId == messageId)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
|
||||
if (giveaway is null)
|
||||
return false;
|
||||
|
||||
await ctx
|
||||
.GetTable<GiveawayUser>()
|
||||
.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);
|
||||
}
|
||||
}
|
39
src/EllieBot/Modules/Utility/GuildColors.cs
Normal file
39
src/EllieBot/Modules/Utility/GuildColors.cs
Normal file
|
@ -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<GuildColors?> 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<IGuildColorsService>
|
||||
{
|
||||
|
||||
}
|
||||
}
|
180
src/EllieBot/Modules/Utility/Info/InfoCommands.cs
Normal file
180
src/EllieBot/Modules/Utility/Info/InfoCommands.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
96
src/EllieBot/Modules/Utility/Invite/InviteCommands.cs
Normal file
96
src/EllieBot/Modules/Utility/Invite/InviteCommands.cs
Normal file
|
@ -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<InviteService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[BotPerm(ChannelPerm.CreateInstantInvite)]
|
||||
[UserPerm(ChannelPerm.CreateInstantInvite)]
|
||||
[EllieOptions<InviteService.Options>]
|
||||
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))));
|
||||
}
|
||||
}
|
||||
}
|
48
src/EllieBot/Modules/Utility/Invite/InviteService.cs
Normal file
48
src/EllieBot/Modules/Utility/Invite/InviteService.cs
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
50
src/EllieBot/Modules/Utility/Quote/IQuoteService.cs
Normal file
50
src/EllieBot/Modules/Utility/Quote/IQuoteService.cs
Normal file
|
@ -0,0 +1,50 @@
|
|||
using EllieBot.Db.Models;
|
||||
|
||||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public interface IQuoteService
|
||||
{
|
||||
/// <summary>
|
||||
/// Delete all quotes created by the author in a guild
|
||||
/// </summary>
|
||||
/// <param name="guildId">ID of the guild</param>
|
||||
/// <param name="userId">ID of the user</param>
|
||||
/// <returns>Number of deleted qutoes</returns>
|
||||
Task<int> DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId);
|
||||
|
||||
/// <summary>
|
||||
/// Delete all quotes in a guild
|
||||
/// </summary>
|
||||
/// <param name="guildId">ID of the guild</param>
|
||||
/// <returns>Number of deleted qutoes</returns>
|
||||
Task<int> DeleteAllQuotesAsync(ulong guildId);
|
||||
|
||||
Task<IReadOnlyCollection<Quote>> GetAllQuotesAsync(ulong guildId, int page, OrderType order);
|
||||
Task<Quote?> GetQuoteByKeywordAsync(ulong guildId, string keyword);
|
||||
|
||||
Task<IReadOnlyCollection<Quote>> SearchQuoteKeywordTextAsync(
|
||||
ulong guildId,
|
||||
string? keyword,
|
||||
string text);
|
||||
|
||||
Task<IReadOnlyCollection<Quote>> GetGuildQuotesAsync(ulong guildId);
|
||||
Task<int> RemoveAllByKeyword(ulong guildId, string keyword);
|
||||
Task<Quote?> GetQuoteByIdAsync(ulong guildId, int quoteId);
|
||||
|
||||
Task<Quote> AddQuoteAsync(
|
||||
ulong guildId,
|
||||
ulong authorId,
|
||||
string authorName,
|
||||
string keyword,
|
||||
string text);
|
||||
|
||||
Task<Quote?> EditQuoteAsync(ulong authorId, int quoteId, string text);
|
||||
|
||||
Task<bool> DeleteQuoteAsync(
|
||||
ulong guildId,
|
||||
ulong authorId,
|
||||
bool isQuoteManager,
|
||||
int quoteId);
|
||||
|
||||
Task<bool> ImportQuotesAsync(ulong guildId, string input);
|
||||
}
|
389
src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs
Normal file
389
src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
222
src/EllieBot/Modules/Utility/Quote/QuoteService.cs
Normal file
222
src/EllieBot/Modules/Utility/Quote/QuoteService.cs
Normal file
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all quotes created by the author in a guild
|
||||
/// </summary>
|
||||
/// <param name="guildId">ID of the guild</param>
|
||||
/// <param name="userId">ID of the user</param>
|
||||
/// <returns>Number of deleted qutoes</returns>
|
||||
public async Task<int> DeleteAllAuthorQuotesAsync(ulong guildId, ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var deleted = await ctx.GetTable<Quote>()
|
||||
.Where(x => x.GuildId == guildId && x.AuthorId == userId)
|
||||
.DeleteAsync();
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all quotes in a guild
|
||||
/// </summary>
|
||||
/// <param name="guildId">ID of the guild</param>
|
||||
/// <returns>Number of deleted qutoes</returns>
|
||||
public async Task<int> DeleteAllQuotesAsync(ulong guildId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var deleted = await ctx.GetTable<Quote>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.DeleteAsync();
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Quote>> GetAllQuotesAsync(ulong guildId, int page, OrderType order)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var q = uow.Set<Quote>()
|
||||
.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<Quote?> GetQuoteByKeywordAsync(ulong guildId, string keyword)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var quotes = await uow.GetTable<Quote>()
|
||||
.Where(q => q.GuildId == guildId && q.Keyword == keyword)
|
||||
.ToArrayAsyncLinqToDB();
|
||||
|
||||
return quotes.RandomOrDefault();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<Quote>> SearchQuoteKeywordTextAsync(
|
||||
ulong guildId,
|
||||
string? keyword,
|
||||
string text)
|
||||
{
|
||||
keyword = keyword?.ToUpperInvariant();
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
var quotes = await uow.GetTable<Quote>()
|
||||
.Where(q => q.GuildId == guildId
|
||||
&& (keyword == null || q.Keyword == keyword))
|
||||
.ToArrayAsync();
|
||||
|
||||
var toReturn = new List<Quote>(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<IReadOnlyCollection<Quote>> GetGuildQuotesAsync(ulong guildId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var quotes = await uow.GetTable<Quote>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.ToListAsyncLinqToDB();
|
||||
return quotes;
|
||||
}
|
||||
|
||||
public Task<int> RemoveAllByKeyword(ulong guildId, string keyword)
|
||||
{
|
||||
keyword = keyword.ToUpperInvariant();
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
|
||||
var count = uow.GetTable<Quote>()
|
||||
.Where(x => x.GuildId == guildId && x.Keyword == keyword)
|
||||
.DeleteAsync();
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<Quote?> GetQuoteByIdAsync(ulong guildId, int quoteId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
var quote = await uow.GetTable<Quote>()
|
||||
.Where(x => x.Id == quoteId && x.GuildId == guildId)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
|
||||
return quote;
|
||||
}
|
||||
|
||||
public async Task<Quote> AddQuoteAsync(
|
||||
ulong guildId,
|
||||
ulong authorId,
|
||||
string authorName,
|
||||
string keyword,
|
||||
string text)
|
||||
{
|
||||
keyword = keyword.ToUpperInvariant();
|
||||
|
||||
Quote q;
|
||||
await using var uow = _db.GetDbContext();
|
||||
uow.Set<Quote>()
|
||||
.Add(q = new()
|
||||
{
|
||||
AuthorId = authorId,
|
||||
AuthorName = authorName,
|
||||
GuildId = guildId,
|
||||
Keyword = keyword,
|
||||
Text = text
|
||||
});
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return q;
|
||||
}
|
||||
|
||||
public async Task<Quote?> EditQuoteAsync(ulong authorId, int quoteId, string text)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var result = await uow.GetTable<Quote>()
|
||||
.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<bool> DeleteQuoteAsync(
|
||||
ulong guildId,
|
||||
ulong authorId,
|
||||
bool isQuoteManager,
|
||||
int quoteId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var q = uow.Set<Quote>().GetById(quoteId);
|
||||
|
||||
|
||||
var count = await uow.GetTable<Quote>()
|
||||
.Where(x => x.GuildId == guildId && x.Id == quoteId)
|
||||
.Where(x => isQuoteManager || (x.AuthorId == authorId))
|
||||
.DeleteAsync();
|
||||
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ImportQuotesAsync(ulong guildId, string input)
|
||||
{
|
||||
Dictionary<string?, List<ExportedQuote?>> data;
|
||||
try
|
||||
{
|
||||
data = Yaml.Deserializer.Deserialize<Dictionary<string?, List<ExportedQuote?>>>(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<Quote>()
|
||||
.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;
|
||||
}
|
||||
}
|
20
src/EllieBot/Modules/Utility/Quote/_common/ExportedQuote.cs
Normal file
20
src/EllieBot/Modules/Utility/Quote/_common/ExportedQuote.cs
Normal file
|
@ -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
|
||||
};
|
||||
}
|
224
src/EllieBot/Modules/Utility/Remind/RemindCommands.cs
Normal file
224
src/EllieBot/Modules/Utility/Remind/RemindCommands.cs
Normal file
|
@ -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<RemindService>
|
||||
{
|
||||
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<Reminder> 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<Reminder>().RemindersForServer(ctx.Guild.Id, index / 10).ToList()
|
||||
: uow.Set<Reminder>().RemindersFor(ctx.User.Id, index / 10).ToList();
|
||||
|
||||
var pageIndex = index % 10;
|
||||
if (rems.Count > pageIndex)
|
||||
{
|
||||
rem = rems[pageIndex];
|
||||
uow.Set<Reminder>().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<bool> 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<Reminder>().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;
|
||||
}
|
||||
}
|
||||
}
|
271
src/EllieBot/Modules/Utility/Remind/RemindService.cs
Normal file
271
src/EllieBot/Modules/Utility/Remind/RemindService.cs
Normal file
|
@ -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*(?<date>(?:\d{2}:\d{2}\s)?\d{1,2}\.\d{1,2}(?:\.\d{2,4})?)|(?:in\s?)?\s*(?:(?<mo>\d+)(?:\s?(?:months?|mos?),?))?(?:(?:\sand\s|\s*)?(?<w>\d+)(?:\s?(?:weeks?|w),?))?(?:(?:\sand\s|\s*)?(?<d>\d+)(?:\s?(?:days?|d),?))?(?:(?:\sand\s|\s*)?(?<h>\d+)(?:\s?(?:hours?|h),?))?(?:(?:\sand\s|\s*)?(?<m>\d+)(?:\s?(?:minutes?|mins?|m),?))?)\s+(?:to:?\s+)?(?<what>(?:\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<int> reminders)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
await uow.Set<Reminder>()
|
||||
.ToLinqToDBTable()
|
||||
.DeleteAsync(x => reminders.Contains(x.Id));
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<Reminder>> GetRemindersBeforeAsync(DateTime now)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
return await uow.Set<Reminder>()
|
||||
.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<string, int>();
|
||||
|
||||
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<Reminder>()
|
||||
.AddAsync(rem);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Reminder>> GetServerReminders(int page, ulong guildId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
return uow.Set<Reminder>().RemindersForServer(guildId, page).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<Reminder>> GetUserReminders(int page, ulong userId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
return uow.Set<Reminder>().RemindersFor(userId, page).ToList();
|
||||
}
|
||||
}
|
240
src/EllieBot/Modules/Utility/Repeater/RepeatCommands.cs
Normal file
240
src/EllieBot/Modules/Utility/Repeater/RepeatCommands.cs
Normal file
|
@ -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<RepeaterService>
|
||||
{
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
443
src/EllieBot/Modules/Utility/Repeater/RepeaterService.cs
Normal file
443
src/EllieBot/Modules/Utility/Repeater/RepeaterService.cs
Normal file
|
@ -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<RunningRepeater> _repeaterQueue;
|
||||
private readonly ConcurrentHashSet<int> _noRedundant;
|
||||
private readonly ConcurrentHashSet<int> _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<Repeater>()
|
||||
.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<RunningRepeater>();
|
||||
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<bool> TriggerExternal(ulong guildId, int index)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
var toTrigger = await uow.Set<Repeater>()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.Skip(index)
|
||||
.FirstOrDefaultAsyncEF();
|
||||
|
||||
if (toTrigger is null)
|
||||
return false;
|
||||
|
||||
LinkedListNode<RunningRepeater>? 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<Repeater>().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<Repeater>()
|
||||
.AsQueryable()
|
||||
.Where(x => x.Id == repeaterId)
|
||||
.UpdateAsync(rep => new()
|
||||
{
|
||||
LastMessageId = lastMsgId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<RunningRepeater?> 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<Repeater>().CountAsyncEF(x => x.GuildId == guildId) < MAX_REPEATERS)
|
||||
uow.Set<Repeater>().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<RunningRepeater?> RemoveByIndexAsync(ulong guildId, int index)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(index, MAX_REPEATERS * 2);
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var toRemove = await uow.Set<Repeater>()
|
||||
.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<Repeater>().Remove(toRemove);
|
||||
await uow.SaveChangesAsync();
|
||||
return removed;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<RunningRepeater> GetRepeaters(ulong guildId)
|
||||
{
|
||||
lock (_queueLocker)
|
||||
{
|
||||
return _repeaterQueue.Where(x => x.Repeater.GuildId == guildId).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleRedundantAsync(ulong guildId, int index)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var toToggle = await uow.Set<Repeater>()
|
||||
.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<bool?> ToggleSkipNextAsync(ulong guildId, int index)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var toSkip = await ctx.Set<Repeater>()
|
||||
.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);
|
||||
}
|
92
src/EllieBot/Modules/Utility/Repeater/RunningRepeater.cs
Normal file
92
src/EllieBot/Modules/Utility/Repeater/RunningRepeater.cs
Normal file
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate when is the proper time to run the repeater again based on initial time repeater ran.
|
||||
/// </summary>
|
||||
/// <param name="initialDateTime">Initial time repeater ran at (or should run at).</param>
|
||||
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;
|
||||
}
|
|
@ -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<StreamRoleService>
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
338
src/EllieBot/Modules/Utility/StreamRole/StreamRoleService.cs
Normal file
338
src/EllieBot/Modules/Utility/StreamRole/StreamRoleService.cs
Normal file
|
@ -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<ulong, StreamRoleSettings> _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());
|
||||
|
||||
/// <summary>
|
||||
/// Adds or removes a user from a blacklist or a whitelist in the specified guild.
|
||||
/// </summary>
|
||||
/// <param name="listType">List type</param>
|
||||
/// <param name="guild">Guild</param>
|
||||
/// <param name="action">Add or rem action</param>
|
||||
/// <param name="userId">User's Id</param>
|
||||
/// <param name="userName">User's name</param>
|
||||
/// <returns>Whether the operation was successful</returns>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets keyword on a guild and updates the cache.
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild Id</param>
|
||||
/// <param name="keyword">Keyword to set</param>
|
||||
/// <returns>The keyword set</returns>
|
||||
public async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently set keyword on a guild.
|
||||
/// </summary>
|
||||
/// <param name="guildId">Guild Id</param>
|
||||
/// <returns>The keyword set</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the role to monitor, and a role to which to add to
|
||||
/// the user who starts streaming in the monitored role.
|
||||
/// </summary>
|
||||
/// <param name="fromRole">Role to monitor</param>
|
||||
/// <param name="addRole">Role to add to the user</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the stream role feature on the specified guild.
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild</param>
|
||||
/// <param name="cleanup">Whether to rescan users</param>
|
||||
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);
|
||||
}
|
8
src/EllieBot/Modules/Utility/Todo/ArchiveTodoResult.cs
Normal file
8
src/EllieBot/Modules/Utility/Todo/ArchiveTodoResult.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public enum ArchiveTodoResult
|
||||
{
|
||||
MaxLimitReached,
|
||||
NoTodos,
|
||||
Success
|
||||
}
|
7
src/EllieBot/Modules/Utility/Todo/TodoAddResult.cs
Normal file
7
src/EllieBot/Modules/Utility/Todo/TodoAddResult.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace EllieBot.Modules.Utility;
|
||||
|
||||
public enum TodoAddResult
|
||||
{
|
||||
MaxLimitReached,
|
||||
Success
|
||||
}
|
237
src/EllieBot/Modules/Utility/Todo/TodoCommands.cs
Normal file
237
src/EllieBot/Modules/Utility/Todo/TodoCommands.cs
Normal file
|
@ -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<TodoService>
|
||||
{
|
||||
[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<TodoModel> 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<TodoService>
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
192
src/EllieBot/Modules/Utility/Todo/TodoService.cs
Normal file
192
src/EllieBot/Modules/Utility/Todo/TodoService.cs
Normal file
|
@ -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<TodoAddResult> AddAsync(ulong userId, string todo)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
if (await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.ArchiveId == null)
|
||||
.CountAsync() >= TODO_MAX_COUNT)
|
||||
{
|
||||
return TodoAddResult.MaxLimitReached;
|
||||
}
|
||||
|
||||
await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.InsertAsync(() => new TodoModel()
|
||||
{
|
||||
UserId = userId,
|
||||
Todo = todo,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
IsDone = false,
|
||||
});
|
||||
|
||||
return TodoAddResult.Success;
|
||||
}
|
||||
|
||||
public async Task<bool> EditAsync(ulong userId, int todoId, string newMessage)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.Id == todoId)
|
||||
.Set(x => x.Todo, newMessage)
|
||||
.UpdateAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<TodoModel[]> GetAllTodosAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
return await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.ArchiveId == null)
|
||||
.ToArrayAsyncLinqToDB();
|
||||
}
|
||||
|
||||
public async Task<bool> CompleteTodoAsync(ulong userId, int todoId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var count = await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.Id == todoId)
|
||||
.Set(x => x.IsDone, true)
|
||||
.UpdateAsync();
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteTodoAsync(ulong userId, int todoId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var count = await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.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<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.ArchiveId == null)
|
||||
.DeleteAsync();
|
||||
}
|
||||
|
||||
public async Task<ArchiveTodoResult> 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<ArchivedTodoListModel>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.CountAsync();
|
||||
|
||||
if (count >= ARCHIVE_MAX_COUNT)
|
||||
return ArchiveTodoResult.MaxLimitReached;
|
||||
|
||||
var inserted = await ctx
|
||||
.GetTable<ArchivedTodoListModel>()
|
||||
.InsertWithOutputAsync(() => new ArchivedTodoListModel()
|
||||
{
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
});
|
||||
|
||||
// mark all existing todos as archived
|
||||
|
||||
var updated = await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.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<ArchivedTodoListModel>()
|
||||
// .Where(x => x.Id == inserted.Id)
|
||||
// .DeleteAsync();
|
||||
|
||||
return ArchiveTodoResult.NoTodos;
|
||||
}
|
||||
|
||||
await tr.CommitAsync();
|
||||
|
||||
return ArchiveTodoResult.Success;
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlyCollection<ArchivedTodoListModel>> GetArchivedTodosAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
return await ctx
|
||||
.GetTable<ArchivedTodoListModel>()
|
||||
.Where(x => x.UserId == userId)
|
||||
.ToArrayAsyncLinqToDB();
|
||||
}
|
||||
|
||||
public async Task<ArchivedTodoListModel?> GetArchivedTodoListAsync(ulong userId, int archiveId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
return await ctx
|
||||
.GetTable<ArchivedTodoListModel>()
|
||||
.Where(x => x.UserId == userId && x.Id == archiveId)
|
||||
.LoadWith(x => x.Items)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
}
|
||||
|
||||
public async Task<bool> ArchiveDeleteAsync(ulong userId, int archiveId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
var count = await ctx
|
||||
.GetTable<ArchivedTodoListModel>()
|
||||
.Where(x => x.UserId == userId && x.Id == archiveId)
|
||||
.DeleteAsync();
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<TodoModel?> GetTodoAsync(ulong userId, int todoId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
return await ctx
|
||||
.GetTable<TodoModel>()
|
||||
.Where(x => x.UserId == userId && x.Id == todoId)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
}
|
||||
}
|
|
@ -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<List<ConvertUnit>> _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<Rates> GetCurrencyRates()
|
||||
{
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var res = await http.GetStringAsync("https://convertapi.nadeko.bot/latest");
|
||||
return JsonSerializer.Deserialize<Rates>(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<ConvertUnit[]>(stream);
|
||||
if(defaultUnits is not null)
|
||||
units.AddRange(defaultUnits);
|
||||
|
||||
units.Add(baseType);
|
||||
|
||||
await _cache.AddAsync(_convertKey, units);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConvertUnit>> GetUnitsAsync()
|
||||
=> (await _cache.GetAsync(_convertKey)).TryGetValue(out var list)
|
||||
? list
|
||||
: Array.Empty<ConvertUnit>();
|
||||
}
|
||||
|
||||
public class Rates
|
||||
{
|
||||
[JsonPropertyName("base")]
|
||||
public string Base { get; set; }
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; set; }
|
||||
|
||||
[JsonPropertyName("rates")]
|
||||
public Dictionary<string, decimal> ConversionRates { get; set; }
|
||||
}
|
|
@ -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<ConverterService>
|
||||
{
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
786
src/EllieBot/Modules/Utility/Utility.cs
Normal file
786
src/EllieBot/Modules/Utility/Utility.cs
Normal file
|
@ -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<IUser>(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<string>(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<IRole>)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<IRole>)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<Emote>();
|
||||
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<IMessage>(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();
|
||||
}
|
||||
}
|
||||
}
|
70
src/EllieBot/Modules/Utility/VerboseErrorsService.cs
Normal file
70
src/EllieBot/Modules/Utility/VerboseErrorsService.cs
Normal file
|
@ -0,0 +1,70 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Utility.Services;
|
||||
|
||||
public class VerboseErrorsService : IEService
|
||||
{
|
||||
private readonly ConcurrentHashSet<ulong> _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;
|
||||
}
|
||||
}
|
12
src/EllieBot/Modules/Utility/_common/ConvertUnit.cs
Normal file
12
src/EllieBot/Modules/Utility/_common/ConvertUnit.cs
Normal file
|
@ -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; }
|
||||
}
|
13
src/EllieBot/Modules/Utility/_common/EvalGlobals.cs
Normal file
13
src/EllieBot/Modules/Utility/_common/EvalGlobals.cs
Normal file
|
@ -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;
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
#nullable disable
|
||||
namespace EllieBot.Modules.Utility.Common;
|
||||
|
||||
public enum StreamRoleListType
|
||||
{
|
||||
Whitelist,
|
||||
Blacklist
|
||||
}
|
Loading…
Reference in a new issue