refactor project structure

This commit is contained in:
Ske
2019-05-08 00:06:27 +02:00
parent 23d8592394
commit c5d2b7c251
21 changed files with 54 additions and 25 deletions

173
PluralKit.Bot/Bot.cs Normal file
View 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);
}
}
}

View 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;
}
}
}

View 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}>");
}
}
}

View 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;
}
}
}

View 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
View 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
View 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;
}
}

View 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>

View 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();
}
}
}

View 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();
}
}
}

View 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);
}
}
}

View 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
View 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)
{
}
}
}