refactor project structure
This commit is contained in:
173
PluralKit.Bot/Bot.cs
Normal file
173
PluralKit.Bot/Bot.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
using Discord.WebSocket;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using Npgsql.BackendMessages;
|
||||
using Npgsql.PostgresTypes;
|
||||
using Npgsql.TypeHandling;
|
||||
using Npgsql.TypeMapping;
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
class Initialize
|
||||
{
|
||||
static void Main() => new Initialize().MainAsync().GetAwaiter().GetResult();
|
||||
|
||||
private async Task MainAsync()
|
||||
{
|
||||
Console.WriteLine("Starting PluralKit...");
|
||||
|
||||
// Dapper by default tries to pass ulongs to Npgsql, which rejects them since PostgreSQL technically
|
||||
// doesn't support unsigned types on its own.
|
||||
// Instead we add a custom mapper to encode them as signed integers instead, converting them back and forth.
|
||||
SqlMapper.RemoveTypeMap(typeof(ulong));
|
||||
SqlMapper.AddTypeHandler<ulong>(new UlongEncodeAsLongHandler());
|
||||
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||
|
||||
using (var services = BuildServiceProvider())
|
||||
{
|
||||
Console.WriteLine("- Connecting to database...");
|
||||
var connection = services.GetRequiredService<IDbConnection>() as NpgsqlConnection;
|
||||
connection.ConnectionString = Environment.GetEnvironmentVariable("PK_DATABASE_URI");
|
||||
await connection.OpenAsync();
|
||||
await Schema.CreateTables(connection);
|
||||
|
||||
Console.WriteLine("- Connecting to Discord...");
|
||||
var client = services.GetRequiredService<IDiscordClient>() as DiscordSocketClient;
|
||||
await client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("PK_TOKEN"));
|
||||
await client.StartAsync();
|
||||
|
||||
Console.WriteLine("- Initializing bot...");
|
||||
await services.GetRequiredService<Bot>().Init();
|
||||
|
||||
await Task.Delay(-1);
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceProvider BuildServiceProvider() => new ServiceCollection()
|
||||
.AddSingleton<IDiscordClient, DiscordSocketClient>()
|
||||
.AddSingleton<IDbConnection, NpgsqlConnection>()
|
||||
.AddSingleton<Bot>()
|
||||
|
||||
.AddSingleton<CommandService>()
|
||||
.AddSingleton<EmbedService>()
|
||||
.AddSingleton<LogChannelService>()
|
||||
.AddSingleton<ProxyService>()
|
||||
|
||||
.AddSingleton<SystemStore>()
|
||||
.AddSingleton<MemberStore>()
|
||||
.AddSingleton<MessageStore>()
|
||||
.BuildServiceProvider();
|
||||
}
|
||||
class Bot
|
||||
{
|
||||
private IServiceProvider _services;
|
||||
private DiscordSocketClient _client;
|
||||
private CommandService _commands;
|
||||
private IDbConnection _connection;
|
||||
private ProxyService _proxy;
|
||||
private Timer _updateTimer;
|
||||
|
||||
public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, IDbConnection connection, ProxyService proxy)
|
||||
{
|
||||
this._services = services;
|
||||
this._client = client as DiscordSocketClient;
|
||||
this._commands = commands;
|
||||
this._connection = connection;
|
||||
this._proxy = proxy;
|
||||
}
|
||||
|
||||
public async Task Init()
|
||||
{
|
||||
_commands.AddTypeReader<PKSystem>(new PKSystemTypeReader());
|
||||
_commands.AddTypeReader<PKMember>(new PKMemberTypeReader());
|
||||
_commands.CommandExecuted += CommandExecuted;
|
||||
await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
|
||||
|
||||
_client.Ready += Ready;
|
||||
|
||||
// Deliberately wrapping in an async function *without* awaiting, we don't want to "block" since this'd hold up the main loop
|
||||
// These handlers return Task so we gotta be careful not to return the Task itself (which would then be awaited) - kinda weird design but eh
|
||||
_client.MessageReceived += async (msg) => MessageReceived(msg).CatchException(HandleRuntimeError);
|
||||
_client.ReactionAdded += async (message, channel, reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction).CatchException(HandleRuntimeError);
|
||||
_client.MessageDeleted += async (message, channel) => _proxy.HandleMessageDeletedAsync(message, channel).CatchException(HandleRuntimeError);
|
||||
}
|
||||
|
||||
private async Task UpdatePeriodic()
|
||||
{
|
||||
// Method called every 60 seconds
|
||||
await _client.SetGameAsync($"pk;help | in {_client.Guilds.Count} servers");
|
||||
}
|
||||
|
||||
private async Task Ready()
|
||||
{
|
||||
_updateTimer = new Timer((_) => this.UpdatePeriodic(), null, 0, 60*1000);
|
||||
|
||||
Console.WriteLine($"Shard #{_client.ShardId} connected to {_client.Guilds.Sum(g => g.Channels.Count)} channels in {_client.Guilds.Count} guilds.");
|
||||
Console.WriteLine($"PluralKit started as {_client.CurrentUser.Username}#{_client.CurrentUser.Discriminator} ({_client.CurrentUser.Id}).");
|
||||
}
|
||||
|
||||
private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result)
|
||||
{
|
||||
// TODO: refactor this entire block, it's fugly.
|
||||
if (!_result.IsSuccess) {
|
||||
if (_result.Error == CommandError.Unsuccessful || _result.Error == CommandError.Exception) {
|
||||
// If this is a PKError (ie. thrown deliberately), show user facing message
|
||||
// If not, log as error
|
||||
var exception = (_result as ExecuteResult?)?.Exception;
|
||||
if (exception is PKError) {
|
||||
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {exception.Message}");
|
||||
} else if (exception is TimeoutException) {
|
||||
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} Operation timed out. Try being faster next time :)");
|
||||
} else {
|
||||
HandleRuntimeError((_result as ExecuteResult?)?.Exception);
|
||||
}
|
||||
} else if ((_result.Error == CommandError.BadArgCount || _result.Error == CommandError.MultipleMatches) && cmd.IsSpecified) {
|
||||
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}\n**Usage: **pk;{cmd.Value.Remarks}");
|
||||
} else if (_result.Error == CommandError.UnknownCommand || _result.Error == CommandError.UnmetPrecondition) {
|
||||
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MessageReceived(SocketMessage _arg)
|
||||
{
|
||||
// Ignore system messages (member joined, message pinned, etc)
|
||||
var arg = _arg as SocketUserMessage;
|
||||
if (arg == null) return;
|
||||
|
||||
// Ignore bot messages
|
||||
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
|
||||
|
||||
int argPos = 0;
|
||||
// Check if message starts with the command prefix
|
||||
if (arg.HasStringPrefix("pk;", ref argPos) || arg.HasStringPrefix("pk!", ref argPos) || arg.HasMentionPrefix(_client.CurrentUser, ref argPos))
|
||||
{
|
||||
// If it does, fetch the sender's system (because most commands need that) into the context,
|
||||
// and start command execution
|
||||
// Note system may be null if user has no system, hence `OrDefault`
|
||||
var system = await _connection.QueryFirstOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.uid = @Id and systems.id = accounts.system", new { Id = arg.Author.Id });
|
||||
await _commands.ExecuteAsync(new PKCommandContext(_client, arg as SocketUserMessage, _connection, system), argPos, _services);
|
||||
}
|
||||
else
|
||||
{
|
||||
// If not, try proxying anyway
|
||||
await _proxy.HandleMessageAsync(arg);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRuntimeError(Exception e)
|
||||
{
|
||||
Console.Error.WriteLine(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
PluralKit.Bot/Commands/MemberCommands.cs
Normal file
106
PluralKit.Bot/Commands/MemberCommands.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Discord.Commands;
|
||||
|
||||
namespace PluralKit.Bot.Commands
|
||||
{
|
||||
[Group("member")]
|
||||
public class MemberCommands : ContextParameterModuleBase<PKMember>
|
||||
{
|
||||
public MemberStore Members { get; set; }
|
||||
|
||||
public override string Prefix => "member";
|
||||
public override string ContextNoun => "member";
|
||||
|
||||
[Command("new")]
|
||||
[Remarks("member new <name>")]
|
||||
[MustHaveSystem]
|
||||
public async Task NewMember([Remainder] string memberName) {
|
||||
// Hard name length cap
|
||||
if (memberName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(memberName.Length);
|
||||
|
||||
// Warn if member name will be unproxyable (with/without tag)
|
||||
if (memberName.Length > Context.SenderSystem.MaxMemberNameLength) {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Member name too long ({memberName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later)");
|
||||
if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled.");
|
||||
}
|
||||
|
||||
// Warn if there's already a member by this name
|
||||
var existingMember = await Members.GetByName(Context.SenderSystem, memberName);
|
||||
if (existingMember != null) {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?");
|
||||
if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled.");
|
||||
}
|
||||
|
||||
// Create the member
|
||||
var member = await Members.Create(Context.SenderSystem, memberName);
|
||||
|
||||
// Send confirmation and space hint
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Type `pk;help member` for a list of commands to edit this member.");
|
||||
if (memberName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it.");
|
||||
}
|
||||
|
||||
[Command("rename")]
|
||||
[Alias("name", "changename", "setname")]
|
||||
[Remarks("member <member> rename <newname>")]
|
||||
[MustPassOwnMember]
|
||||
public async Task RenameMember([Remainder] string newName) {
|
||||
// TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean?
|
||||
|
||||
// Hard name length cap
|
||||
if (newName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(newName.Length);
|
||||
|
||||
// Warn if member name will be unproxyable (with/without tag)
|
||||
if (newName.Length > Context.SenderSystem.MaxMemberNameLength) {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} New member name too long ({newName.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to change it anyway?");
|
||||
if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
|
||||
}
|
||||
|
||||
// Warn if there's already a member by this name
|
||||
var existingMember = await Members.GetByName(Context.SenderSystem, newName);
|
||||
if (existingMember != null) {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?");
|
||||
if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
|
||||
}
|
||||
|
||||
// Rename the mebmer
|
||||
ContextEntity.Name = newName;
|
||||
await Members.Save(ContextEntity);
|
||||
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member renamed.");
|
||||
if (newName.Contains(" ")) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it.");
|
||||
}
|
||||
|
||||
[Command("description")]
|
||||
[Alias("info", "bio", "text")]
|
||||
[Remarks("member <member> description <description")]
|
||||
[MustPassOwnMember]
|
||||
public async Task MemberDescription([Remainder] string description = null) {
|
||||
if (description.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(description.Length);
|
||||
|
||||
ContextEntity.Description = description;
|
||||
await Members.Save(ContextEntity);
|
||||
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member description {(description == null ? "cleared" : "changed")}.");
|
||||
}
|
||||
|
||||
[Command("pronouns")]
|
||||
[Alias("pronoun")]
|
||||
[Remarks("member <member> pronouns <pronouns")]
|
||||
[MustPassOwnMember]
|
||||
public async Task MemberPronouns([Remainder] string pronouns = null) {
|
||||
if (pronouns.Length > Limits.MaxPronounsLength) throw Errors.MemberPronounsTooLongError(pronouns.Length);
|
||||
|
||||
ContextEntity.Pronouns = pronouns;
|
||||
await Members.Save(ContextEntity);
|
||||
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member pronouns {(pronouns == null ? "cleared" : "changed")}.");
|
||||
}
|
||||
|
||||
public override async Task<PKMember> ReadContextParameterAsync(string value)
|
||||
{
|
||||
var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services);
|
||||
return res.IsSuccess ? res.BestMatch as PKMember : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
PluralKit.Bot/Commands/MiscCommands.cs
Normal file
27
PluralKit.Bot/Commands/MiscCommands.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
public class MiscCommands: ModuleBase<PKCommandContext> {
|
||||
[Command("invite")]
|
||||
[Remarks("invite")]
|
||||
public async Task Invite() {
|
||||
var info = await Context.Client.GetApplicationInfoAsync();
|
||||
|
||||
var permissions = new GuildPermissions(
|
||||
addReactions: true,
|
||||
attachFiles: true,
|
||||
embedLinks: true,
|
||||
manageMessages: true,
|
||||
manageWebhooks: true,
|
||||
readMessageHistory: true,
|
||||
sendMessages: true
|
||||
);
|
||||
|
||||
// TODO: allow customization of invite ID
|
||||
var invite = $"https://discordapp.com/oauth2/authorize?client_id={info.Id}&scope=bot&permissions={permissions.RawValue}";
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>");
|
||||
}
|
||||
}
|
||||
}
|
||||
149
PluralKit.Bot/Commands/SystemCommands.cs
Normal file
149
PluralKit.Bot/Commands/SystemCommands.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Discord.Commands;
|
||||
|
||||
namespace PluralKit.Bot.Commands
|
||||
{
|
||||
[Group("system")]
|
||||
public class SystemCommands : ContextParameterModuleBase<PKSystem>
|
||||
{
|
||||
public override string Prefix => "system";
|
||||
public override string ContextNoun => "system";
|
||||
|
||||
public SystemStore Systems {get; set;}
|
||||
public MemberStore Members {get; set;}
|
||||
public EmbedService EmbedService {get; set;}
|
||||
|
||||
[Command]
|
||||
public async Task Query(PKSystem system = null) {
|
||||
if (system == null) system = Context.SenderSystem;
|
||||
if (system == null) throw Errors.NotOwnSystemError;
|
||||
|
||||
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system));
|
||||
}
|
||||
|
||||
[Command("new")]
|
||||
[Remarks("system new <name>")]
|
||||
public async Task New([Remainder] string systemName = null)
|
||||
{
|
||||
if (ContextEntity != null) throw Errors.NotOwnSystemError;
|
||||
if (Context.SenderSystem != null) throw Errors.NoSystemError;
|
||||
|
||||
var system = await Systems.Create(systemName);
|
||||
await Systems.Link(system, Context.User.Id);
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;help` for more information about commands you can use now.");
|
||||
}
|
||||
|
||||
[Command("name")]
|
||||
[Remarks("system name <name>")]
|
||||
[MustHaveSystem]
|
||||
public async Task Name([Remainder] string newSystemName = null) {
|
||||
if (newSystemName != null && newSystemName.Length > Limits.MaxSystemNameLength) throw Errors.SystemNameTooLongError(newSystemName.Length);
|
||||
|
||||
Context.SenderSystem.Name = newSystemName;
|
||||
await Systems.Save(Context.SenderSystem);
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} System name {(newSystemName != null ? "changed" : "cleared")}.");
|
||||
}
|
||||
|
||||
[Command("description")]
|
||||
[Remarks("system description <description>")]
|
||||
[MustHaveSystem]
|
||||
public async Task Description([Remainder] string newDescription = null) {
|
||||
if (newDescription != null && newDescription.Length > Limits.MaxDescriptionLength) throw Errors.DescriptionTooLongError(newDescription.Length);
|
||||
|
||||
Context.SenderSystem.Description = newDescription;
|
||||
await Systems.Save(Context.SenderSystem);
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} System description {(newDescription != null ? "changed" : "cleared")}.");
|
||||
}
|
||||
|
||||
[Command("tag")]
|
||||
[Remarks("system tag <tag>")]
|
||||
[MustHaveSystem]
|
||||
public async Task Tag([Remainder] string newTag = null) {
|
||||
if (newTag.Length > Limits.MaxSystemTagLength) throw Errors.SystemNameTooLongError(newTag.Length);
|
||||
|
||||
Context.SenderSystem.Tag = newTag;
|
||||
|
||||
// Check unproxyable messages *after* changing the tag (so it's seen in the method) but *before* we save to DB (so we can cancel)
|
||||
var unproxyableMembers = await Members.GetUnproxyableMembers(Context.SenderSystem);
|
||||
if (unproxyableMembers.Count > 0) {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Changing your system tag to '{newTag}' will result in the following members being unproxyable, since the tag would bring their name over 32 characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name))}**\nDo you want to continue anyway?");
|
||||
if (!await Context.PromptYesNo(msg)) throw new PKError("Tag change cancelled.");
|
||||
}
|
||||
|
||||
await Systems.Save(Context.SenderSystem);
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} System tag {(newTag != null ? "changed" : "cleared")}.");
|
||||
}
|
||||
|
||||
[Command("delete")]
|
||||
[Remarks("system delete")]
|
||||
[MustHaveSystem]
|
||||
public async Task Delete() {
|
||||
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{Context.SenderSystem.Hid}`).\n**Note: this action is permanent.**");
|
||||
var reply = await Context.AwaitMessage(Context.Channel, Context.User, timeout: TimeSpan.FromMinutes(1));
|
||||
if (reply.Content != Context.SenderSystem.Hid) throw new PKError($"System deletion cancelled. Note that you must reply with your system ID (`{Context.SenderSystem.Hid}`) *verbatim*.");
|
||||
|
||||
await Systems.Delete(Context.SenderSystem);
|
||||
await Context.Channel.SendMessageAsync($"{Emojis.Success} System deleted.");
|
||||
}
|
||||
|
||||
[Group("list")]
|
||||
public class SystemListCommands: ModuleBase<PKCommandContext> {
|
||||
public MemberStore Members { get; set; }
|
||||
|
||||
[Command]
|
||||
[Remarks("system [system] list")]
|
||||
public async Task MemberShortList() {
|
||||
var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem;
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
|
||||
var members = await Members.GetBySystem(system);
|
||||
var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`";
|
||||
await Context.Paginate<PKMember>(
|
||||
members.OrderBy(m => m.Name).ToList(),
|
||||
25,
|
||||
embedTitle,
|
||||
(eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => {
|
||||
if (m.HasProxyTags) return $"[`{m.Hid}`] **{m.Name}** *({m.ProxyString})*";
|
||||
return $"[`{m.Hid}`] **{m.Name}**";
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
[Command("full")]
|
||||
[Alias("big", "details", "long")]
|
||||
[Remarks("system [system] list full")]
|
||||
public async Task MemberLongList() {
|
||||
var system = Context.GetContextEntity<PKSystem>() ?? Context.SenderSystem;
|
||||
if (system == null) throw Errors.NoSystemError;
|
||||
|
||||
var members = await Members.GetBySystem(system);
|
||||
var embedTitle = system.Name != null ? $"Members of {system.Name} (`{system.Hid}`)" : $"Members of `{system.Hid}`";
|
||||
await Context.Paginate<PKMember>(
|
||||
members.OrderBy(m => m.Name).ToList(),
|
||||
10,
|
||||
embedTitle,
|
||||
(eb, ms) => {
|
||||
foreach (var m in ms) {
|
||||
var profile = $"**ID**: {m.Hid}";
|
||||
if (m.Pronouns != null) profile += $"\n**Pronouns**: {m.Pronouns}";
|
||||
if (m.Birthday != null) profile += $"\n**Birthdate**: {m.BirthdayString}";
|
||||
if (m.Prefix != null || m.Suffix != null) profile += $"\n**Proxy tags**: {m.ProxyString}";
|
||||
if (m.Description != null) profile += $"\n\n{m.Description}";
|
||||
eb.AddField(m.Name, profile);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<PKSystem> ReadContextParameterAsync(string value)
|
||||
{
|
||||
var res = await new PKSystemTypeReader().ReadAsync(Context, value, _services);
|
||||
return res.IsSuccess ? res.BestMatch as PKSystem : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
101
PluralKit.Bot/ContextUtils.cs
Normal file
101
PluralKit.Bot/ContextUtils.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
public static class ContextUtils {
|
||||
public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, TimeSpan? timeout = null) {
|
||||
await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)});
|
||||
var reaction = await ctx.AwaitReaction(message, ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1));
|
||||
return reaction.Emote.Name == Emojis.Success;
|
||||
}
|
||||
|
||||
public static async Task<SocketReaction> AwaitReaction(this ICommandContext ctx, IUserMessage message, IUser user = null, Func<SocketReaction, bool> predicate = null, TimeSpan? timeout = null) {
|
||||
var tcs = new TaskCompletionSource<SocketReaction>();
|
||||
Task Inner(Cacheable<IUserMessage, ulong> _message, ISocketMessageChannel _channel, SocketReaction reaction) {
|
||||
if (message.Id != _message.Id) return Task.CompletedTask; // Ignore reactions for different messages
|
||||
if (user != null && user.Id != reaction.UserId) return Task.CompletedTask; // Ignore messages from other users if a user was defined
|
||||
if (predicate != null && !predicate.Invoke(reaction)) return Task.CompletedTask; // Check predicate
|
||||
tcs.SetResult(reaction);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
(ctx.Client as BaseSocketClient).ReactionAdded += Inner;
|
||||
try {
|
||||
return await (tcs.Task.TimeoutAfter(timeout));
|
||||
} finally {
|
||||
(ctx.Client as BaseSocketClient).ReactionAdded -= Inner;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IUserMessage> AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) {
|
||||
var tcs = new TaskCompletionSource<IUserMessage>();
|
||||
Task Inner(SocketMessage msg) {
|
||||
if (channel != msg.Channel) return Task.CompletedTask; // Ignore messages in a different channel
|
||||
if (user != null && user != msg.Author) return Task.CompletedTask; // Ignore messages from other users
|
||||
if (predicate != null && !predicate.Invoke(msg)) return Task.CompletedTask; // Check predicate
|
||||
|
||||
(ctx.Client as BaseSocketClient).MessageReceived -= Inner;
|
||||
tcs.SetResult(msg as IUserMessage);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
(ctx.Client as BaseSocketClient).MessageReceived += Inner;
|
||||
return await (tcs.Task.TimeoutAfter(timeout));
|
||||
}
|
||||
|
||||
public static async Task Paginate<T>(this ICommandContext ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) {
|
||||
var pageCount = (items.Count / itemsPerPage) + 1;
|
||||
Embed MakeEmbedForPage(int page) {
|
||||
var eb = new EmbedBuilder();
|
||||
eb.Title = pageCount > 1 ? $"[{page+1}/{pageCount}] {title}" : title;
|
||||
renderer(eb, items.Skip(page*itemsPerPage).Take(itemsPerPage));
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
var msg = await ctx.Channel.SendMessageAsync(embed: MakeEmbedForPage(0));
|
||||
var botEmojis = new[] { new Emoji("\u23EA"), new Emoji("\u2B05"), new Emoji("\u27A1"), new Emoji("\u23E9"), new Emoji(Emojis.Error) };
|
||||
await msg.AddReactionsAsync(botEmojis);
|
||||
|
||||
try {
|
||||
var currentPage = 0;
|
||||
while (true) {
|
||||
var reaction = await ctx.AwaitReaction(msg, ctx.User, timeout: TimeSpan.FromMinutes(5));
|
||||
|
||||
// Increment/decrement page counter based on which reaction was clicked
|
||||
if (reaction.Emote.Name == "\u23EA") currentPage = 0; // <<
|
||||
if (reaction.Emote.Name == "\u2B05") currentPage = (currentPage - 1) % pageCount; // <
|
||||
if (reaction.Emote.Name == "\u27A1") currentPage = (currentPage + 1) % pageCount; // >
|
||||
if (reaction.Emote.Name == "\u23E9") currentPage = pageCount - 1; // >>
|
||||
if (reaction.Emote.Name == Emojis.Error) break; // X
|
||||
|
||||
// If we can, remove the user's reaction (so they can press again quickly)
|
||||
if (await ctx.HasPermission(ChannelPermission.ManageMessages) && reaction.User.IsSpecified) await msg.RemoveReactionAsync(reaction.Emote, reaction.User.Value);
|
||||
|
||||
// Edit the embed with the new page
|
||||
await msg.ModifyAsync((mp) => mp.Embed = MakeEmbedForPage(currentPage));
|
||||
}
|
||||
} catch (TimeoutException) {
|
||||
// "escape hatch", clean up as if we hit X
|
||||
}
|
||||
|
||||
if (await ctx.HasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync();
|
||||
else await msg.RemoveReactionsAsync(ctx.Client.CurrentUser, botEmojis);
|
||||
}
|
||||
|
||||
public static async Task<ChannelPermissions> Permissions(this ICommandContext ctx) {
|
||||
if (ctx.Channel is IGuildChannel) {
|
||||
var gu = await ctx.Guild.GetCurrentUserAsync();
|
||||
return gu.GetPermissions(ctx.Channel as IGuildChannel);
|
||||
}
|
||||
return ChannelPermissions.DM;
|
||||
}
|
||||
|
||||
public static async Task<bool> HasPermission(this ICommandContext ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission);
|
||||
}
|
||||
}
|
||||
17
PluralKit.Bot/Errors.cs
Normal file
17
PluralKit.Bot/Errors.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace PluralKit.Bot {
|
||||
public static class Errors {
|
||||
// TODO: is returning constructed errors and throwing them at call site a good idea, or should these be methods that insta-throw instead?
|
||||
|
||||
public static PKError NotOwnSystemError => new PKError($"You can only run this command on your own system.");
|
||||
public static PKError NotOwnMemberError => new PKError($"You can only run this command on your own member.");
|
||||
public static PKError NoSystemError => new PKError("You do not have a system registered with PluralKit. To create one, type `pk;system new`.");
|
||||
public static PKError ExistinSystemError => new PKError("You already have a system registered with PluralKit. To view it, type `pk;system`. If you'd like to delete your system and start anew, type `pk;system delete`, or if you'd like to unlink this account from it, type `pk;unlink`.");
|
||||
public static PKError MissingMemberError => new PKSyntaxError("You need to specify a member to run this command on.");
|
||||
|
||||
public static PKError SystemNameTooLongError(int length) => new PKError($"System name too long ({length}/{Limits.MaxSystemNameLength} characters).");
|
||||
public static PKError SystemTagTooLongError(int length) => new PKError($"System tag too long ({length}/{Limits.MaxSystemTagLength} characters).");
|
||||
public static PKError DescriptionTooLongError(int length) => new PKError($"Description too long ({length}/{Limits.MaxDescriptionLength} characters).");
|
||||
public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters).");
|
||||
public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters).");
|
||||
}
|
||||
}
|
||||
9
PluralKit.Bot/Limits.cs
Normal file
9
PluralKit.Bot/Limits.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace PluralKit.Bot {
|
||||
public static class Limits {
|
||||
public static readonly int MaxSystemNameLength = 100;
|
||||
public static readonly int MaxSystemTagLength = 31;
|
||||
public static readonly int MaxDescriptionLength = 1000;
|
||||
public static readonly int MaxMemberNameLength = 50;
|
||||
public static readonly int MaxPronounsLength = 100;
|
||||
}
|
||||
}
|
||||
18
PluralKit.Bot/PluralKit.Bot.csproj
Normal file
18
PluralKit.Bot/PluralKit.Bot.csproj
Normal file
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>netcoreapp2.2</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net.Commands" Version="2.0.1" />
|
||||
<PackageReference Include="Discord.Net.Webhook" Version="2.0.1" />
|
||||
<PackageReference Include="Discord.Net.WebSocket" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
34
PluralKit.Bot/Preconditions.cs
Normal file
34
PluralKit.Bot/Preconditions.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Discord.Commands;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
class MustHaveSystem : PreconditionAttribute
|
||||
{
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
|
||||
{
|
||||
var c = context as PKCommandContext;
|
||||
if (c == null) return PreconditionResult.FromError("Must be called on a PKCommandContext (should never happen!)");
|
||||
if (c.SenderSystem == null) return PreconditionResult.FromError(Errors.NoSystemError);
|
||||
return PreconditionResult.FromSuccess();
|
||||
}
|
||||
}
|
||||
|
||||
class MustPassOwnMember : PreconditionAttribute
|
||||
{
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
|
||||
{
|
||||
// OK when:
|
||||
// - Sender has a system
|
||||
// - Sender passes a member as a context parameter
|
||||
// - Sender owns said member
|
||||
|
||||
var c = context as PKCommandContext;
|
||||
if (c == null)
|
||||
if (c.SenderSystem == null) return PreconditionResult.FromError(Errors.NoSystemError);
|
||||
if (c.GetContextEntity<PKMember>() == null) return PreconditionResult.FromError(Errors.MissingMemberError);
|
||||
if (c.GetContextEntity<PKMember>().System != c.SenderSystem.Id) return PreconditionResult.FromError(Errors.NotOwnMemberError);
|
||||
return PreconditionResult.FromSuccess();
|
||||
}
|
||||
}
|
||||
}
|
||||
45
PluralKit.Bot/Services/EmbedService.cs
Normal file
45
PluralKit.Bot/Services/EmbedService.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Discord;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
public class EmbedService {
|
||||
private SystemStore _systems;
|
||||
private IDiscordClient _client;
|
||||
|
||||
public EmbedService(SystemStore systems, IDiscordClient client)
|
||||
{
|
||||
this._systems = systems;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
public async Task<Embed> CreateSystemEmbed(PKSystem system) {
|
||||
var accounts = await _systems.GetLinkedAccountIds(system);
|
||||
|
||||
// Fetch/render info for all accounts simultaneously
|
||||
var users = await Task.WhenAll(accounts.Select(async uid => (await _client.GetUserAsync(uid)).NameAndMention() ?? $"(deleted account {uid})"));
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
.WithColor(Color.Blue)
|
||||
.WithTitle(system.Name ?? null)
|
||||
.WithDescription(system.Description?.Truncate(1024))
|
||||
.WithThumbnailUrl(system.AvatarUrl ?? null)
|
||||
.WithFooter($"System ID: {system.Hid}");
|
||||
|
||||
eb.AddField("Linked accounts", string.Join(", ", users));
|
||||
eb.AddField("Members", $"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)");
|
||||
// TODO: fronter
|
||||
return eb.Build();
|
||||
}
|
||||
|
||||
public Embed CreateLoggedMessageEmbed(PKSystem system, PKMember member, IMessage message, IUser sender) {
|
||||
// TODO: pronouns in ?-reacted response using this card
|
||||
return new EmbedBuilder()
|
||||
.WithAuthor($"#{message.Channel.Name}: {member.Name}", member.AvatarUrl)
|
||||
.WithDescription(message.Content)
|
||||
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Sender: ${sender.Username}#{sender.Discriminator} ({sender.Id}) | Message ID: ${message.Id}")
|
||||
.WithTimestamp(message.Timestamp)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
47
PluralKit.Bot/Services/LogChannelService.cs
Normal file
47
PluralKit.Bot/Services/LogChannelService.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Data;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Discord;
|
||||
|
||||
namespace PluralKit.Bot {
|
||||
class ServerDefinition {
|
||||
public ulong Id;
|
||||
public ulong LogChannel;
|
||||
}
|
||||
|
||||
class LogChannelService {
|
||||
private IDiscordClient _client;
|
||||
private IDbConnection _connection;
|
||||
private EmbedService _embed;
|
||||
|
||||
public LogChannelService(IDiscordClient client, IDbConnection connection, EmbedService embed)
|
||||
{
|
||||
this._client = client;
|
||||
this._connection = connection;
|
||||
this._embed = embed;
|
||||
}
|
||||
|
||||
public async Task LogMessage(PKSystem system, PKMember member, IMessage message, IUser sender) {
|
||||
var channel = await GetLogChannel((message.Channel as IGuildChannel).Guild);
|
||||
if (channel == null) return;
|
||||
|
||||
var embed = _embed.CreateLoggedMessageEmbed(system, member, message, sender);
|
||||
await channel.SendMessageAsync(text: message.GetJumpUrl(), embed: embed);
|
||||
}
|
||||
|
||||
public async Task<ITextChannel> GetLogChannel(IGuild guild) {
|
||||
var server = await _connection.QueryFirstAsync<ServerDefinition>("select * from servers where id = @Id", new { Id = guild.Id });
|
||||
if (server == null) return null;
|
||||
return await _client.GetChannelAsync(server.LogChannel) as ITextChannel;
|
||||
}
|
||||
|
||||
public async Task SetLogChannel(IGuild guild, ITextChannel newLogChannel) {
|
||||
var def = new ServerDefinition {
|
||||
Id = guild.Id,
|
||||
LogChannel = newLogChannel.Id
|
||||
};
|
||||
|
||||
await _connection.ExecuteAsync("insert into servers(id, log_channel) values (@Id, @LogChannel) on conflict (id) do update set log_channel = @LogChannel", def);
|
||||
}
|
||||
}
|
||||
}
|
||||
151
PluralKit.Bot/Services/ProxyService.cs
Normal file
151
PluralKit.Bot/Services/ProxyService.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Discord;
|
||||
using Discord.Rest;
|
||||
using Discord.Webhook;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
class ProxyDatabaseResult
|
||||
{
|
||||
public PKSystem System;
|
||||
public PKMember Member;
|
||||
}
|
||||
|
||||
class ProxyMatch {
|
||||
public PKMember Member;
|
||||
public PKSystem System;
|
||||
public string InnerText;
|
||||
|
||||
public string ProxyName => Member.Name + (System.Tag.Length > 0 ? " " + System.Tag : "");
|
||||
}
|
||||
|
||||
class ProxyService {
|
||||
private IDiscordClient _client;
|
||||
private IDbConnection _connection;
|
||||
private LogChannelService _logger;
|
||||
private MessageStore _messageStorage;
|
||||
|
||||
private ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>> _webhooks;
|
||||
|
||||
public ProxyService(IDiscordClient client, IDbConnection connection, LogChannelService logger, MessageStore messageStorage)
|
||||
{
|
||||
this._client = client;
|
||||
this._connection = connection;
|
||||
this._logger = logger;
|
||||
this._messageStorage = messageStorage;
|
||||
|
||||
_webhooks = new ConcurrentDictionary<ulong, Lazy<Task<IWebhook>>>();
|
||||
}
|
||||
|
||||
private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials) {
|
||||
// TODO: add detection of leading @mention
|
||||
|
||||
// Sort by specificity (prefix+suffix first, prefix/suffix second)
|
||||
var ordered = potentials.OrderByDescending((p) => (p.Member.Prefix != null ? 0 : 1) + (p.Member.Suffix != null ? 0 : 1));
|
||||
foreach (var potential in ordered) {
|
||||
var prefix = potential.Member.Prefix ?? "";
|
||||
var suffix = potential.Member.Suffix ?? "";
|
||||
|
||||
if (message.StartsWith(prefix) && message.EndsWith(suffix)) {
|
||||
var inner = message.Substring(prefix.Length, message.Length - prefix.Length - suffix.Length);
|
||||
return new ProxyMatch { Member = potential.Member, System = potential.System, InnerText = inner };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task HandleMessageAsync(IMessage message) {
|
||||
var results = await _connection.QueryAsync<PKMember, PKSystem, ProxyDatabaseResult>("select members.*, systems.* from members, systems, accounts where members.system = systems.id and accounts.system = systems.id and accounts.uid = @Uid", (member, system) => new ProxyDatabaseResult { Member = member, System = system }, new { Uid = message.Author.Id });
|
||||
|
||||
// Find a member with proxy tags matching the message
|
||||
var match = GetProxyTagMatch(message.Content, results);
|
||||
if (match == null) return;
|
||||
|
||||
// Fetch a webhook for this channel, and send the proxied message
|
||||
var webhook = await GetWebhookByChannelCaching(message.Channel as ITextChannel);
|
||||
var hookMessage = await ExecuteWebhook(webhook, match.InnerText, match.ProxyName, match.Member.AvatarUrl, message.Attachments.FirstOrDefault());
|
||||
|
||||
// Store the message in the database, and log it in the log channel (if applicable)
|
||||
await _messageStorage.Store(message.Author.Id, hookMessage.Id, hookMessage.Channel.Id, match.Member);
|
||||
await _logger.LogMessage(match.System, match.Member, hookMessage, message.Author);
|
||||
|
||||
// Wait a second or so before deleting the original message
|
||||
await Task.Delay(1000);
|
||||
await message.DeleteAsync();
|
||||
}
|
||||
|
||||
private async Task<IMessage> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment) {
|
||||
var client = new DiscordWebhookClient(webhook);
|
||||
|
||||
ulong messageId;
|
||||
if (attachment != null) {
|
||||
using (var stream = await WebRequest.CreateHttp(attachment.Url).GetRequestStreamAsync()) {
|
||||
messageId = await client.SendFileAsync(stream, filename: attachment.Filename, text: text, username: username, avatarUrl: avatarUrl);
|
||||
}
|
||||
} else {
|
||||
messageId = await client.SendMessageAsync(text, username: username, avatarUrl: avatarUrl);
|
||||
}
|
||||
return await webhook.Channel.GetMessageAsync(messageId);
|
||||
}
|
||||
|
||||
private async Task<IWebhook> GetWebhookByChannelCaching(ITextChannel channel) {
|
||||
// We cache the webhook through a Lazy<Task<T>>, this way we make sure to only create one webhook per channel
|
||||
// TODO: make sure this is sharding-safe. Intuition says yes, since one channel is guaranteed to only be handled by one shard, but best to make sure
|
||||
var webhookFactory = _webhooks.GetOrAdd(channel.Id, new Lazy<Task<IWebhook>>(() => FindWebhookByChannel(channel)));
|
||||
return await webhookFactory.Value;
|
||||
}
|
||||
|
||||
private async Task<IWebhook> FindWebhookByChannel(ITextChannel channel) {
|
||||
IWebhook webhook;
|
||||
|
||||
webhook = (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine);
|
||||
if (webhook != null) return webhook;
|
||||
|
||||
webhook = await channel.CreateWebhookAsync("PluralKit Proxy Webhook");
|
||||
return webhook;
|
||||
}
|
||||
|
||||
private bool IsWebhookMine(IWebhook arg)
|
||||
{
|
||||
return arg.Creator.Id == this._client.CurrentUser.Id && arg.Name == "PluralKit Proxy Webhook";
|
||||
}
|
||||
|
||||
public async Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction)
|
||||
{
|
||||
// Make sure it's the right emoji (red X)
|
||||
if (reaction.Emote.Name != "\u274C") return;
|
||||
|
||||
// Find the message in the database
|
||||
var storedMessage = await _messageStorage.Get(message.Id);
|
||||
if (storedMessage == null) return; // (if we can't, that's ok, no worries)
|
||||
|
||||
// Make sure it's the actual sender of that message deleting the message
|
||||
if (storedMessage.SenderId != reaction.UserId) return;
|
||||
|
||||
try {
|
||||
// Then, fetch the Discord message and delete that
|
||||
// TODO: this could be faster if we didn't bother fetching it and just deleted it directly
|
||||
// somehow through REST?
|
||||
await (await message.GetOrDownloadAsync()).DeleteAsync();
|
||||
} catch (NullReferenceException) {
|
||||
// Message was deleted before we got to it... cool, no problem, lmao
|
||||
}
|
||||
|
||||
// Finally, delete it from our database.
|
||||
await _messageStorage.Delete(message.Id);
|
||||
}
|
||||
|
||||
public async Task HandleMessageDeletedAsync(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
|
||||
{
|
||||
await _messageStorage.Delete(message.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
174
PluralKit.Bot/Utils.cs
Normal file
174
PluralKit.Bot/Utils.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
using Discord.Commands.Builders;
|
||||
using Discord.WebSocket;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace PluralKit.Bot
|
||||
{
|
||||
public static class Utils {
|
||||
public static string NameAndMention(this IUser user) {
|
||||
return $"{user.Username}#{user.Discriminator} ({user.Mention})";
|
||||
}
|
||||
}
|
||||
|
||||
class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong>
|
||||
{
|
||||
public override ulong Parse(object value)
|
||||
{
|
||||
// Cast to long to unbox, then to ulong (???)
|
||||
return (ulong)(long)value;
|
||||
}
|
||||
|
||||
public override void SetValue(IDbDataParameter parameter, ulong value)
|
||||
{
|
||||
parameter.Value = (long)value;
|
||||
}
|
||||
}
|
||||
|
||||
class PKSystemTypeReader : TypeReader
|
||||
{
|
||||
public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
|
||||
{
|
||||
var client = services.GetService<IDiscordClient>();
|
||||
var conn = services.GetService<IDbConnection>();
|
||||
|
||||
// System references can take three forms:
|
||||
// - The direct user ID of an account connected to the system
|
||||
// - A @mention of an account connected to the system (<@uid>)
|
||||
// - A system hid
|
||||
|
||||
// First, try direct user ID parsing
|
||||
if (ulong.TryParse(input, out var idFromNumber)) return await FindSystemByAccountHelper(idFromNumber, client, conn);
|
||||
|
||||
// Then, try mention parsing.
|
||||
if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, conn);
|
||||
|
||||
// Finally, try HID parsing
|
||||
var res = await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where hid = @Hid", new { Hid = input });
|
||||
if (res != null) return TypeReaderResult.FromSuccess(res);
|
||||
return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System with ID `{input}` not found.");
|
||||
}
|
||||
|
||||
async Task<TypeReaderResult> FindSystemByAccountHelper(ulong id, IDiscordClient client, IDbConnection conn)
|
||||
{
|
||||
var foundByAccountId = await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from accounts, systems where accounts.system = system.id and accounts.id = @Id", new { Id = id });
|
||||
if (foundByAccountId != null) return TypeReaderResult.FromSuccess(foundByAccountId);
|
||||
|
||||
// We didn't find any, so we try to resolve the user ID to find the associated account,
|
||||
// so we can print their username.
|
||||
var user = await client.GetUserAsync(id);
|
||||
|
||||
// Return descriptive errors based on whether we found the user or not.
|
||||
if (user == null) return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"System or account with ID `{id}` not found.");
|
||||
return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"Account **{user.Username}#{user.Discriminator}** not found.");
|
||||
}
|
||||
}
|
||||
|
||||
class PKMemberTypeReader : TypeReader
|
||||
{
|
||||
public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
|
||||
{
|
||||
var conn = services.GetService(typeof(IDbConnection)) as IDbConnection;
|
||||
|
||||
// If the sender of the command is in a system themselves,
|
||||
// then try searching by the member's name
|
||||
if (context is PKCommandContext ctx && ctx.SenderSystem != null)
|
||||
{
|
||||
var foundByName = await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where system = @System and lower(name) = lower(@Name)", new { System = ctx.SenderSystem.Id, Name = input });
|
||||
if (foundByName != null) return TypeReaderResult.FromSuccess(foundByName);
|
||||
}
|
||||
|
||||
// Otherwise, if sender isn't in a system, or no member found by that name,
|
||||
// do a standard by-hid search.
|
||||
var foundByHid = await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = input });
|
||||
if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid);
|
||||
return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Member not found.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Subclass of ICommandContext with PK-specific additional fields and functionality
|
||||
public class PKCommandContext : SocketCommandContext, ICommandContext
|
||||
{
|
||||
public IDbConnection Connection { get; }
|
||||
public PKSystem SenderSystem { get; }
|
||||
|
||||
private object _entity;
|
||||
|
||||
public PKCommandContext(DiscordSocketClient client, SocketUserMessage msg, IDbConnection connection, PKSystem system) : base(client, msg)
|
||||
{
|
||||
Connection = connection;
|
||||
SenderSystem = system;
|
||||
}
|
||||
|
||||
public T GetContextEntity<T>() where T: class {
|
||||
return _entity as T;
|
||||
}
|
||||
|
||||
public void SetContextEntity(object entity) {
|
||||
_entity = entity;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class ContextParameterModuleBase<T> : ModuleBase<PKCommandContext> where T: class
|
||||
{
|
||||
public IServiceProvider _services { get; set; }
|
||||
public CommandService _commands { get; set; }
|
||||
|
||||
public abstract string Prefix { get; }
|
||||
public abstract string ContextNoun { get; }
|
||||
public abstract Task<T> ReadContextParameterAsync(string value);
|
||||
|
||||
public T ContextEntity => Context.GetContextEntity<T>();
|
||||
|
||||
protected override void OnModuleBuilding(CommandService commandService, ModuleBuilder builder) {
|
||||
// We create a catch-all command that intercepts the first argument, tries to parse it as
|
||||
// the context parameter, then runs the command service AGAIN with that given in a wrapped
|
||||
// context, with the context argument removed so it delegates to the subcommand executor
|
||||
builder.AddCommand("", async (ctx, param, services, info) => {
|
||||
var pkCtx = ctx as PKCommandContext;
|
||||
pkCtx.SetContextEntity(param[0] as T);
|
||||
|
||||
await commandService.ExecuteAsync(pkCtx, Prefix + " " + param[1] as string, services);
|
||||
}, (cb) => {
|
||||
cb.WithPriority(-9999);
|
||||
cb.AddPrecondition(new MustNotHaveContextPrecondition());
|
||||
cb.AddParameter<T>("contextValue", (pb) => pb.WithDefault(""));
|
||||
cb.AddParameter<string>("rest", (pb) => pb.WithDefault("").WithIsRemainder(true));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class MustNotHaveContextPrecondition : PreconditionAttribute
|
||||
{
|
||||
public MustNotHaveContextPrecondition()
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
|
||||
{
|
||||
if ((context as PKCommandContext)?.GetContextEntity<object>() == null) return PreconditionResult.FromSuccess();
|
||||
return PreconditionResult.FromError("(should not be seen)");
|
||||
}
|
||||
}
|
||||
|
||||
public class PKError : Exception
|
||||
{
|
||||
public PKError(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class PKSyntaxError : PKError
|
||||
{
|
||||
public PKSyntaxError(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user