Merge branch 'master' into patch-2

This commit is contained in:
Astrid 2019-10-18 13:37:09 +02:00 committed by GitHub
commit af4da8fd72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 2838 additions and 1518 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
/.git/
/.github/
/.idea/
/docs/
/logs/
/scripts/
bin/
obj/
*.conf
*.md
Dockerfile
docker-compose.yml

53
.editorconfig Normal file
View File

@ -0,0 +1,53 @@
[*]
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=false
insert_final_newline=false
indent_style=space
indent_size=4
# Microsoft .NET properties
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
csharp_space_before_colon_in_inheritance_clause=false
csharp_style_var_elsewhere=true:hint
csharp_style_var_for_built_in_types=true:hint
csharp_style_var_when_type_is_apparent=true:hint
dotnet_style_predefined_type_for_locals_parameters_members=true:hint
dotnet_style_predefined_type_for_member_access=true:hint
dotnet_style_qualification_for_event=false:warning
dotnet_style_qualification_for_field=false:warning
dotnet_style_qualification_for_method=false:warning
dotnet_style_qualification_for_property=false:warning
dotnet_style_require_accessibility_modifiers=for_non_interface_members:hint
# ReSharper properties
resharper_align_multiline_parameter=true
resharper_autodetect_indent_settings=true
resharper_blank_lines_between_using_groups=1
resharper_braces_for_using=required_for_multiline
resharper_csharp_stick_comment=false
resharper_empty_block_style=together_same_line
resharper_keep_existing_attribute_arrangement=true
resharper_keep_existing_initializer_arrangement=false
resharper_local_function_body=expression_body
resharper_method_or_operator_body=expression_body
resharper_place_accessor_with_attrs_holder_on_single_line=true
resharper_place_simple_case_statement_on_same_line=if_owner_is_single_line
resharper_space_before_type_parameter_constraint_colon=false
resharper_use_indent_from_vs=false
resharper_wrap_before_first_type_parameter_constraint=true
# ReSharper inspection severities:
resharper_web_config_module_not_resolved_highlighting=warning
resharper_web_config_type_not_resolved_highlighting=warning
resharper_web_config_wrong_module_highlighting=warning
[{*.yml,*.yaml}]
indent_style=space
indent_size=2
[*.{appxmanifest,asax,ascx,aspx,build,config,cs,cshtml,csproj,dbml,discomap,dtd,fs,fsi,fsscript,fsx,htm,html,jsproj,lsproj,master,ml,mli,njsproj,nuspec,proj,props,razor,resw,resx,skin,StyleCop,targets,tasks,vb,vbproj,xaml,xamlx,xml,xoml,xsd}]
indent_style=space
indent_size=4
tab_width=4

4
.gitignore vendored
View File

@ -6,4 +6,6 @@ obj/
pluralkit.conf
pluralkit.*.conf
logs/
logs/
*.DotSettings.user

View File

@ -1,9 +1,13 @@
FROM mcr.microsoft.com/dotnet/core/sdk:2.2-alpine
FROM mcr.microsoft.com/dotnet/core/sdk:2.2-alpine AS build
WORKDIR /app
COPY PluralKit.API /app/PluralKit.API
COPY PluralKit.Bot /app/PluralKit.Bot
COPY PluralKit.Core /app/PluralKit.Core
COPY PluralKit.Web /app/PluralKit.Web
COPY PluralKit.sln /app
RUN dotnet build
COPY . /app
RUN dotnet publish -c Release -o out
FROM mcr.microsoft.com/dotnet/core/runtime:2.2-alpine
WORKDIR /app
COPY --from=build /app/PluralKit.*/out ./
ENTRYPOINT ["dotnet"]
CMD ["PluralKit.Bot.dll"]

View File

@ -48,9 +48,9 @@ namespace PluralKit.API.Controllers
[HttpGet]
[RequiresSystem]
public async Task<ActionResult<PKSystem>> GetOwnSystem()
public Task<ActionResult<PKSystem>> GetOwnSystem()
{
return Ok(_auth.CurrentSystem);
return Task.FromResult<ActionResult<PKSystem>>(Ok(_auth.CurrentSystem));
}
[HttpGet("{hid}")]

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using App.Metrics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
@ -28,12 +29,16 @@ namespace PluralKit.API
.AddTransient<MemberStore>()
.AddTransient<SwitchStore>()
.AddTransient<MessageStore>()
.AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService<CoreConfig>(), "API"))
.AddScoped<TokenAuthService>()
.AddTransient(_ => Configuration.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig())
.AddSingleton(svc => InitUtils.InitLogger(svc.GetRequiredService<CoreConfig>(), "api"))
.AddSingleton(svc => new DbConnectionFactory(svc.GetRequiredService<CoreConfig>().Database));
.AddTransient<DbConnectionCountHolder>()
.AddTransient<DbConnectionFactory>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -5,19 +5,20 @@ using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using App.Metrics;
using App.Metrics.Logging;
using Dapper;
using Discord;
using Discord.Commands;
using Discord.WebSocket;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using PluralKit.Bot.Commands;
using PluralKit.Bot.CommandSystem;
using Sentry;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Compact;
using Serilog.Sinks.SystemConsole.Themes;
namespace PluralKit.Bot
{
@ -61,7 +62,6 @@ namespace PluralKit.Bot
logger.Information("Initializing bot");
await services.GetRequiredService<Bot>().Init();
await client.StartAsync();
@ -79,36 +79,34 @@ namespace PluralKit.Bot
.AddTransient(_ => _config.GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig())
.AddTransient(_ => _config.GetSection("PluralKit").GetSection("Bot").Get<BotConfig>() ?? new BotConfig())
.AddTransient(svc => new DbConnectionFactory(svc.GetRequiredService<CoreConfig>().Database))
.AddSingleton<DbConnectionCountHolder>()
.AddTransient<DbConnectionFactory>()
.AddSingleton<IDiscordClient, DiscordShardedClient>(_ => new DiscordShardedClient(new DiscordSocketConfig
{
MessageCacheSize = 0,
MessageCacheSize = 5,
ExclusiveBulkDelete = true
}))
.AddSingleton<Bot>()
.AddSingleton(_ => new CommandService(new CommandServiceConfig
{
CaseSensitiveCommands = false,
QuotationMarkAliasMap = new Dictionary<char, char>
{
{'"', '"'},
{'\'', '\''},
{'', ''},
{'“', '”'},
{'„', '‟'},
},
// We're already asyncing stuff by forking off at the client event handlers
// So adding an additional layer of forking is pointless
// and leads to the service scope being disposed of prematurely
DefaultRunMode = RunMode.Sync
}))
.AddTransient<CommandTree>()
.AddTransient<SystemCommands>()
.AddTransient<MemberCommands>()
.AddTransient<SwitchCommands>()
.AddTransient<LinkCommands>()
.AddTransient<APICommands>()
.AddTransient<ImportExportCommands>()
.AddTransient<HelpCommands>()
.AddTransient<ModCommands>()
.AddTransient<MiscCommands>()
.AddTransient<EmbedService>()
.AddTransient<ProxyService>()
.AddTransient<LogChannelService>()
.AddTransient<DataFileService>()
.AddTransient<WebhookExecutorService>()
.AddTransient<ProxyCacheService>()
.AddSingleton<WebhookCacheService>()
.AddTransient<SystemStore>()
@ -116,58 +114,57 @@ namespace PluralKit.Bot
.AddTransient<MessageStore>()
.AddTransient<SwitchStore>()
.AddSingleton<IMetrics>(svc =>
{
var cfg = svc.GetRequiredService<CoreConfig>();
var builder = AppMetrics.CreateDefaultBuilder();
if (cfg.InfluxUrl != null && cfg.InfluxDb != null)
builder.Report.ToInfluxDb(cfg.InfluxUrl, cfg.InfluxDb);
return builder.Build();
})
.AddSingleton(svc => InitUtils.InitMetrics(svc.GetRequiredService<CoreConfig>()))
.AddSingleton<PeriodicStatCollector>()
.AddScoped(_ => new Sentry.Scope(null))
.AddTransient<PKEventHandler>()
.AddSingleton(svc => InitUtils.InitLogger(svc.GetRequiredService<CoreConfig>(), "bot"))
.AddScoped<EventIdProvider>()
.AddSingleton(svc => new LoggerProvider(svc.GetRequiredService<CoreConfig>(), "bot"))
.AddScoped(svc => svc.GetRequiredService<LoggerProvider>().RootLogger.ForContext("EventId", svc.GetRequiredService<EventIdProvider>().EventId))
.AddMemoryCache()
.BuildServiceProvider();
}
class Bot
{
private IServiceProvider _services;
private DiscordShardedClient _client;
private CommandService _commands;
private ProxyService _proxy;
private Timer _updateTimer;
private IMetrics _metrics;
private PeriodicStatCollector _collector;
private ILogger _logger;
public Bot(IServiceProvider services, IDiscordClient client, CommandService commands, ProxyService proxy, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
public Bot(IServiceProvider services, IDiscordClient client, IMetrics metrics, PeriodicStatCollector collector, ILogger logger)
{
_services = services;
_client = client as DiscordShardedClient;
_commands = commands;
_proxy = proxy;
_metrics = metrics;
_collector = collector;
_logger = logger.ForContext<Bot>();
}
public async Task Init()
public Task Init()
{
_commands.AddTypeReader<PKSystem>(new PKSystemTypeReader());
_commands.AddTypeReader<PKMember>(new PKMemberTypeReader());
_commands.CommandExecuted += CommandExecuted;
await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services);
_client.ShardReady += ShardReady;
_client.Log += FrameworkLog;
_client.MessageReceived += (msg) => HandleEvent(s => s.AddMessageBreadcrumb(msg), eh => eh.HandleMessage(msg));
_client.MessageReceived += (msg) =>
{
// _client.CurrentUser will be null if we've connected *some* shards but not shard #0 yet
// This will cause an error in WebhookCacheService so we just workaround and don't process any messages
// until we properly connect. TODO: can we do this without chucking away a bunch of messages?
if (_client.CurrentUser == null) return Task.CompletedTask;
return HandleEvent(s => s.AddMessageBreadcrumb(msg), eh => eh.HandleMessage(msg));
};
_client.ReactionAdded += (msg, channel, reaction) => HandleEvent(s => s.AddReactionAddedBreadcrumb(msg, channel, reaction), eh => eh.HandleReactionAdded(msg, channel, reaction));
_client.MessageDeleted += (msg, channel) => HandleEvent(s => s.AddMessageDeleteBreadcrumb(msg, channel), eh => eh.HandleMessageDeleted(msg, channel));
_client.MessagesBulkDeleted += (msgs, channel) => HandleEvent(s => s.AddMessageBulkDeleteBreadcrumb(msgs, channel), eh => eh.HandleMessagesBulkDelete(msgs, channel));
return Task.CompletedTask;
}
private Task FrameworkLog(LogMessage msg)
@ -221,44 +218,56 @@ namespace PluralKit.Bot
return Task.CompletedTask;
}
private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result)
{
_metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
// 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 if (_result is PreconditionResult)
{
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
} else {
HandleRuntimeError((_result as ExecuteResult?)?.Exception, ((PKCommandContext) ctx).ServiceProvider.GetRequiredService<Scope>());
}
} 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 || _result.Error == CommandError.ObjectNotFound) {
await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
}
}
}
// private async Task CommandExecuted(Optional<CommandInfo> cmd, ICommandContext ctx, IResult _result)
// {
// var svc = ((PKCommandContext) ctx).ServiceProvider;
// var id = svc.GetService<EventIdProvider>();
//
// _metrics.Measure.Meter.Mark(BotMetrics.CommandsRun);
//
// // 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 if (_result is PreconditionResult)
// {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
// } else
// {
// await ctx.Message.Channel.SendMessageAsync(
// $"{Emojis.Error} Internal error occurred. Please join the support server (<https://discord.gg/PczBt78>), and send the developer this ID: `{id.EventId}`.");
// HandleRuntimeError((_result as ExecuteResult?)?.Exception, svc);
// }
// } 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 || _result.Error == CommandError.ObjectNotFound) {
// await ctx.Message.Channel.SendMessageAsync($"{Emojis.Error} {_result.ErrorReason}");
// }
// }
// }
private Task HandleEvent(Action<Scope> breadcrumbFactory, Func<PKEventHandler, Task> handler)
{
// Inner function so we can await the handler without stalling the entire pipeline
async Task Inner()
{
// "Fork" this task off by ~~yeeting~~ yielding it at the back of the task queue
// This prevents any synchronous nonsense from also stalling the pipeline before the first await point
await Task.Yield();
// Create a DI scope for this event
// and log the breadcrumb to the newly created (in-svc-scope) Sentry scope
using (var scope = _services.CreateScope())
{
var evtid = scope.ServiceProvider.GetService<EventIdProvider>().EventId;
var sentryScope = scope.ServiceProvider.GetRequiredService<Scope>();
sentryScope.SetTag("evtid", evtid.ToString());
breadcrumbFactory(sentryScope);
try
@ -267,7 +276,7 @@ namespace PluralKit.Bot
}
catch (Exception e)
{
HandleRuntimeError(e, sentryScope);
HandleRuntimeError(e, scope.ServiceProvider);
}
}
@ -279,9 +288,12 @@ namespace PluralKit.Bot
return Task.CompletedTask;
}
private void HandleRuntimeError(Exception e, Scope scope = null)
private void HandleRuntimeError(Exception e, IServiceProvider services)
{
_logger.Error(e, "Exception in bot event handler");
var logger = services.GetRequiredService<ILogger>();
var scope = services.GetRequiredService<Scope>();
logger.Error(e, "Exception in bot event handler");
var evt = new SentryEvent(e);
SentrySdk.CaptureEvent(evt, scope);
@ -291,50 +303,51 @@ namespace PluralKit.Bot
}
class PKEventHandler {
private CommandService _commands;
private ProxyService _proxy;
private ILogger _logger;
private IMetrics _metrics;
private DiscordShardedClient _client;
private DbConnectionFactory _connectionFactory;
private IServiceProvider _services;
private CommandTree _tree;
public PKEventHandler(CommandService commands, ProxyService proxy, ILogger logger, IMetrics metrics, IDiscordClient client, DbConnectionFactory connectionFactory, IServiceProvider services)
public PKEventHandler(ProxyService proxy, ILogger logger, IMetrics metrics, IDiscordClient client, DbConnectionFactory connectionFactory, IServiceProvider services, CommandTree tree)
{
_commands = commands;
_proxy = proxy;
_logger = logger;
_metrics = metrics;
_client = (DiscordShardedClient) client;
_connectionFactory = connectionFactory;
_services = services;
_tree = tree;
}
public async Task HandleMessage(SocketMessage msg)
public async Task HandleMessage(SocketMessage arg)
{
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
// _client.CurrentUser will be null if we've connected *some* shards but not shard #0 yet
// This will cause an error in WebhookCacheServices so we just workaround and don't process any messages
// until we properly connect. TODO: can we do this without chucking away a bunch of messages?
if (_client.CurrentUser == null) return;
RegisterMessageMetrics(arg);
// Ignore system messages (member joined, message pinned, etc)
var arg = msg as SocketUserMessage;
if (arg == null) return;
var msg = arg as SocketUserMessage;
if (msg == null) return;
// Ignore bot messages
if (arg.Author.IsBot || arg.Author.IsWebhook) return;
if (msg.Author.IsBot || msg.Author.IsWebhook) return;
int argPos = 0;
int argPos = -1;
// Check if message starts with the command prefix
if (arg.HasStringPrefix("pk;", ref argPos, StringComparison.OrdinalIgnoreCase) ||
arg.HasStringPrefix("pk!", ref argPos, StringComparison.OrdinalIgnoreCase) ||
arg.HasMentionPrefix(_client.CurrentUser, ref argPos))
if (msg.Content.StartsWith("pk;")) argPos = 3;
else if (msg.Content.StartsWith("pk!")) argPos = 3;
else if (Utils.HasMentionPrefix(msg.Content, ref argPos, out var id)) // Set argPos to the proper value
if (id != _client.CurrentUser.Id) // But undo it if it's someone else's ping
argPos = -1;
if (argPos > -1)
{
_logger.Debug("Parsing command {Command} from message {Channel}-{Message}", msg.Content, msg.Channel.Id, msg.Id);
// Essentially move the argPos pointer by however much whitespace is at the start of the post-argPos string
var trimStartLengthDiff = arg.Content.Substring(argPos).Length -
arg.Content.Substring(argPos).TrimStart().Length;
var trimStartLengthDiff = msg.Content.Substring(argPos).Length -
msg.Content.Substring(argPos).TrimStart().Length;
argPos += trimStartLengthDiff;
// If it does, fetch the sender's system (because most commands need that) into the context,
@ -344,17 +357,32 @@ namespace PluralKit.Bot
using (var conn = await _connectionFactory.Obtain())
system = await conn.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, system, _services), argPos,
_services);
new {Id = msg.Author.Id});
await _tree.ExecuteCommand(new Context(_services, msg, argPos, system));
}
else
{
// If not, try proxying anyway
await _proxy.HandleMessageAsync(arg);
try
{
await _proxy.HandleMessageAsync(msg);
}
catch (PKError e)
{
await arg.Channel.SendMessageAsync($"{Emojis.Error} {e.Message}");
}
}
}
private void RegisterMessageMetrics(SocketMessage msg)
{
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
var gatewayLatency = DateTimeOffset.Now - msg.CreatedAt;
_logger.Debug("Message received with latency {Latency}", gatewayLatency);
}
public Task HandleReactionAdded(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel,
SocketReaction reaction) => _proxy.HandleReactionAddedAsync(message, channel, reaction);

View File

@ -1,6 +1,8 @@
using App.Metrics;
using App.Metrics.Gauge;
using App.Metrics.Histogram;
using App.Metrics.Meter;
using App.Metrics.Timer;
namespace PluralKit.Bot
{
@ -13,6 +15,8 @@ namespace PluralKit.Bot
public static GaugeOptions MembersOnline => new GaugeOptions {Name = "Members online", MeasurementUnit = Unit.None, Context = "Bot"};
public static GaugeOptions Guilds => new GaugeOptions {Name = "Guilds", MeasurementUnit = Unit.None, Context = "Bot"};
public static GaugeOptions Channels => new GaugeOptions {Name = "Channels", MeasurementUnit = Unit.None, Context = "Bot"};
public static GaugeOptions ShardsConnected => new GaugeOptions { Name = "Shards Connected", Context = "Bot" };
public static GaugeOptions WebhookCacheSize => new GaugeOptions { Name = "Webhook Cache Size", Context = "Bot" };
public static TimerOptions WebhookResponseTime => new TimerOptions { Name = "Webhook Response Time", Context = "Bot", RateUnit = TimeUnit.Seconds, MeasurementUnit = Unit.Requests, DurationUnit = TimeUnit.Seconds };
}
}

View File

@ -0,0 +1,16 @@
namespace PluralKit.Bot.CommandSystem
{
public class Command
{
public string Key { get; }
public string Usage { get; }
public string Description { get; }
public Command(string key, string usage, string description)
{
Key = key;
Usage = usage;
Description = description;
}
}
}

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace PluralKit.Bot.CommandSystem
{
public class CommandGroup
{
public string Key { get; }
public string Description { get; }
public ICollection<Command> Children { get; }
public CommandGroup(string key, string description, ICollection<Command> children)
{
Key = key;
Description = description;
Children = children;
}
}
}

View File

@ -0,0 +1,242 @@
using System;
using System.Threading.Tasks;
using Discord;
using Discord.Net;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
namespace PluralKit.Bot.CommandSystem
{
public class Context
{
private IServiceProvider _provider;
private readonly DiscordShardedClient _client;
private readonly SocketUserMessage _message;
private readonly Parameters _parameters;
private readonly SystemStore _systems;
private readonly MemberStore _members;
private readonly PKSystem _senderSystem;
private Command _currentCommand;
public Context(IServiceProvider provider, SocketUserMessage message, int commandParseOffset,
PKSystem senderSystem)
{
_client = provider.GetRequiredService<IDiscordClient>() as DiscordShardedClient;
_message = message;
_systems = provider.GetRequiredService<SystemStore>();
_members = provider.GetRequiredService<MemberStore>();
_senderSystem = senderSystem;
_provider = provider;
_parameters = new Parameters(message.Content.Substring(commandParseOffset));
}
public IUser Author => _message.Author;
public IMessageChannel Channel => _message.Channel;
public IUserMessage Message => _message;
public IGuild Guild => (_message.Channel as ITextChannel)?.Guild;
public DiscordSocketClient Shard => _client.GetShardFor(Guild);
public DiscordShardedClient Client => _client;
public PKSystem System => _senderSystem;
public string PopArgument() => _parameters.Pop();
public string PeekArgument() => _parameters.Peek();
public string Remainder() => _parameters.Remainder();
public string RemainderOrNull() => Remainder().Trim().Length == 0 ? null : Remainder();
public bool HasNext() => RemainderOrNull() != null;
public string FullCommand => _parameters.FullCommand;
public Task<IUserMessage> Reply(string text = null, Embed embed = null) =>
Channel.SendMessageAsync(text, embed: embed);
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public bool Match(params string[] potentialMatches)
{
foreach (var match in potentialMatches)
{
if (PeekArgument().Equals(match, StringComparison.InvariantCultureIgnoreCase))
{
PopArgument();
return true;
}
}
return false;
}
public async Task Execute<T>(Command commandDef, Func<T, Task> handler)
{
_currentCommand = commandDef;
try
{
await handler(_provider.GetRequiredService<T>());
}
catch (PKSyntaxError e)
{
await Reply($"{Emojis.Error} {e.Message}\n**Command usage:** pk;{commandDef.Usage}");
}
catch (PKError e)
{
await Reply($"{Emojis.Error} {e.Message}");
}
}
public async Task<IUser> MatchUser()
{
var text = PeekArgument();
if (MentionUtils.TryParseUser(text, out var id))
return await Shard.Rest.GetUserAsync(id); // TODO: this should properly fetch
return null;
}
public Task<PKSystem> PeekSystem() => MatchSystemInner();
public async Task<PKSystem> MatchSystem()
{
var system = await MatchSystemInner();
if (system != null) PopArgument();
return system;
}
private async Task<PKSystem> MatchSystemInner()
{
var input = PeekArgument();
// 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
// Direct IDs and mentions are both handled by the below method:
if (input.TryParseMention(out var id))
return await _systems.GetByAccount(id);
// Finally, try HID parsing
var system = await _systems.GetByHid(input);
return system;
}
public async Task<PKMember> PeekMember()
{
var input = PeekArgument();
// Member references can have one or two forms, depending on
// whether you're in a system or not:
// - A member hid
// - A textual name of a member *in your own system*
// First, try member HID parsing:
if (await _members.GetByHid(input) is PKMember memberByHid)
return memberByHid;
// Then, if we have a system, try finding by member name in system
if (_senderSystem != null && await _members.GetByName(_senderSystem, input) is PKMember memberByName)
return memberByName;
// We didn't find anything, so we return null.
return null;
}
/// <summary>
/// Attempts to pop a member descriptor from the stack, returning it if present. If a member could not be
/// resolved by the next word in the argument stack, does *not* touch the stack, and returns null.
/// </summary>
public async Task<PKMember> MatchMember()
{
// First, peek a member
var member = await PeekMember();
// If the peek was successful, we've used up the next argument, so we pop that just to get rid of it.
if (member != null) PopArgument();
// Finally, we return the member value.
return member;
}
public string CreateMemberNotFoundError(string input)
{
// TODO: does this belong here?
if (input.Length == 5)
{
if (_senderSystem != null)
return $"Member with ID or name \"{input.SanitizeMentions()}\" not found.";
return $"Member with ID \"{input.SanitizeMentions()}\" not found."; // Accounts without systems can't query by name
}
if (_senderSystem != null)
return $"Member with name \"{input.SanitizeMentions()}\" not found. Note that a member ID is 5 characters long.";
return $"Member not found. Note that a member ID is 5 characters long.";
}
public Context CheckSystem()
{
if (_senderSystem == null)
throw Errors.NoSystemError;
return this;
}
public Context CheckNoSystem()
{
if (_senderSystem != null)
throw Errors.ExistingSystemError;
return this;
}
public Context CheckOwnMember(PKMember member)
{
if (member.System != _senderSystem.Id)
throw Errors.NotOwnMemberError;
return this;
}
public GuildPermissions GetGuildPermissions(IUser user)
{
if (Channel is SocketGuildChannel gc)
return gc.GetUser(user.Id).GuildPermissions;
return GuildPermissions.None;
}
public ChannelPermissions GetChannelPermissions(IUser user)
{
if (Channel is SocketGuildChannel gc)
return gc.GetUser(user.Id).GetPermissions(gc);
return ChannelPermissions.DM;
}
public Context CheckAuthorPermission(GuildPermission permission, string permissionName)
{
if (!GetGuildPermissions(Author).Has(permission))
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
return this;
}
public Context CheckAuthorPermission(ChannelPermission permission, string permissionName)
{
if (!GetChannelPermissions(Author).Has(permission))
throw new PKError($"You must have the \"{permissionName}\" permission in this server to use this command.");
return this;
}
public Context CheckGuildContext()
{
if (Channel is IGuildChannel) return this;
throw new PKError("This command can not be run in a DM.");
}
public ITextChannel MatchChannel()
{
if (!MentionUtils.TryParseChannel(PeekArgument(), out var channel)) return null;
if (!(_client.GetChannel(channel) is ITextChannel textChannel)) return null;
PopArgument();
return textChannel;
}
}
}

View File

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
namespace PluralKit.Bot.CommandSystem
{
public class Parameters
{
private static readonly Dictionary<char, char> _quotePairs = new Dictionary<char, char>()
{
{'\'', '\''}, {'"', '"'}
};
private readonly string _cmd;
private int _ptr;
public Parameters(string cmd)
{
_cmd = cmd;
_ptr = 0;
}
public string Pop()
{
var positions = NextWordPosition();
if (positions == null) return "";
var (start, end, advance) = positions.Value;
_ptr = end + advance;
return _cmd.Substring(start, end - start).Trim();
}
public string Peek()
{
var positions = NextWordPosition();
if (positions == null) return "";
var (start, end, _) = positions.Value;
return _cmd.Substring(start, end - start).Trim();
}
public string Remainder() => _cmd.Substring(_ptr).Trim();
public string FullCommand => _cmd;
// Returns tuple of (startpos, endpos, advanceafter)
private ValueTuple<int, int, int>? NextWordPosition()
{
// Is this the end of the string?
if (_cmd.Length <= _ptr) return null;
// Is this a quoted word?
if (_quotePairs.ContainsKey(_cmd[_ptr]))
{
// This is a quoted word, find corresponding end quote and return span
var endQuote = _quotePairs[_cmd[_ptr]];
var endQuotePosition = _cmd.IndexOf(endQuote, _ptr + 1);
// Position after the end quote should be a space (or EOL)
// Otherwise treat it as a standard word that's not quoted
if (_cmd.Length == endQuotePosition + 1 || _cmd[endQuotePosition + 1] == ' ')
{
if (endQuotePosition == -1)
{
// This is an unterminated quoted word, just return the entire word including the start quote
// TODO: should we do something else here?
return (_ptr, _cmd.Length, 0);
}
return (_ptr + 1, endQuotePosition, 1);
}
}
// Not a quoted word, just find the next space and return as appropriate
var wordEnd = _cmd.IndexOf(' ', _ptr + 1);
return wordEnd != -1 ? (_ptr, wordEnd, 1) : (_ptr, _cmd.Length, 0);
}
}
}

View File

@ -1,66 +1,67 @@
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
[Group("token")]
public class APICommands: ModuleBase<PKCommandContext>
public class APICommands
{
public SystemStore Systems { get; set; }
[Command]
[MustHaveSystem]
[Remarks("token")]
public async Task GetToken()
private SystemStore _systems;
public APICommands(SystemStore systems)
{
_systems = systems;
}
public async Task GetToken(Context ctx)
{
ctx.CheckSystem();
// Get or make a token
var token = Context.SenderSystem.Token ?? await MakeAndSetNewToken();
var token = ctx.System.Token ?? await MakeAndSetNewToken(ctx.System);
// If we're not already in a DM, reply with a reminder to check
if (!(Context.Channel is IDMChannel))
if (!(ctx.Channel is IDMChannel))
{
await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!");
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
// DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile)
await Context.User.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:");
await Context.User.SendMessageAsync(token);
await ctx.Author.SendMessageAsync($"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure. If it leaks or you need a new one, you can invalidate this one with `pk;token refresh`.\n\nYour token is below:");
await ctx.Author.SendMessageAsync(token);
}
private async Task<string> MakeAndSetNewToken()
private async Task<string> MakeAndSetNewToken(PKSystem system)
{
Context.SenderSystem.Token = PluralKit.Utils.GenerateToken();
await Systems.Save(Context.SenderSystem);
return Context.SenderSystem.Token;
system.Token = PluralKit.Utils.GenerateToken();
await _systems.Save(system);
return system.Token;
}
[Command("refresh")]
[MustHaveSystem]
[Alias("expire", "invalidate", "update", "new")]
[Remarks("token refresh")]
public async Task RefreshToken()
public async Task RefreshToken(Context ctx)
{
if (Context.SenderSystem.Token == null)
ctx.CheckSystem();
if (ctx.System.Token == null)
{
// If we don't have a token, call the other method instead
// This does pretty much the same thing, except words the messages more appropriately for that :)
await GetToken();
await GetToken(ctx);
return;
}
// Make a new token from scratch
var token = await MakeAndSetNewToken();
var token = await MakeAndSetNewToken(ctx.System);
// If we're not already in a DM, reply with a reminder to check
if (!(Context.Channel is IDMChannel))
if (!(ctx.Channel is IDMChannel))
{
await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!");
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
// DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile)
await Context.User.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:");
await Context.User.SendMessageAsync(token);
await ctx.Author.SendMessageAsync($"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:");
await ctx.Author.SendMessageAsync(token);
}
}
}

View File

@ -0,0 +1,285 @@
using System.Linq;
using System.Threading.Tasks;
using Discord;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class CommandTree
{
public static Command SystemInfo = new Command("system", "system [system]", "uwu");
public static Command SystemNew = new Command("system new", "system new [name]", "uwu");
public static Command SystemRename = new Command("system name", "system rename [name]", "uwu");
public static Command SystemDesc = new Command("system description", "system description [description]", "uwu");
public static Command SystemTag = new Command("system tag", "system tag [tag]", "uwu");
public static Command SystemAvatar = new Command("system avatar", "system avatar [url|@mention]", "uwu");
public static Command SystemDelete = new Command("system delete", "system delete", "uwu");
public static Command SystemTimezone = new Command("system timezone", "system timezone [timezone]", "uwu");
public static Command SystemList = new Command("system list", "system list [full]", "uwu");
public static Command SystemFronter = new Command("system fronter", "system fronter", "uwu");
public static Command SystemFrontHistory = new Command("system fronthistory", "system fronthistory", "uwu");
public static Command SystemFrontPercent = new Command("system frontpercent", "system frontpercent [timespan]", "uwu");
public static Command MemberInfo = new Command("member", "member <member>", "uwu");
public static Command MemberNew = new Command("member new", "member new <name>", "uwu");
public static Command MemberRename = new Command("member rename", "member <member> rename <new name>", "uwu");
public static Command MemberDesc = new Command("member description", "member <member> description [description]", "uwu");
public static Command MemberPronouns = new Command("member pronouns", "member <member> pronouns [pronouns]", "uwu");
public static Command MemberColor = new Command("member color", "member <member> color [color]", "uwu");
public static Command MemberBirthday = new Command("member birthday", "member <member> birthday [birthday]", "uwu");
public static Command MemberProxy = new Command("member proxy", "member <member> proxy [example proxy]", "uwu");
public static Command MemberDelete = new Command("member delete", "member <member> delete", "uwu");
public static Command MemberAvatar = new Command("member avatar", "member <member> avatar [url|@mention]", "uwu");
public static Command MemberDisplayName = new Command("member displayname", "member <member> displayname [display name]", "uwu");
public static Command Switch = new Command("switch", "switch <member> [member 2] [member 3...]", "uwu");
public static Command SwitchOut = new Command("switch out", "switch out", "uwu");
public static Command SwitchMove = new Command("switch move", "switch move <date/time>", "uwu");
public static Command SwitchDelete = new Command("switch delete", "switch delete", "uwu");
public static Command Link = new Command("link", "link <account>", "uwu");
public static Command Unlink = new Command("unlink", "unlink [account]", "uwu");
public static Command TokenGet = new Command("token", "token", "uwu");
public static Command TokenRefresh = new Command("token refresh", "token refresh", "uwu");
public static Command Import = new Command("import", "import [fileurl]", "uwu");
public static Command Export = new Command("export", "export", "uwu");
public static Command HelpCommandList = new Command("commands", "commands", "uwu");
public static Command HelpProxy = new Command("help proxy", "help proxy", "uwu");
public static Command Help = new Command("help", "help", "uwu");
public static Command Message = new Command("message", "message <id|link>", "uwu");
public static Command Log = new Command("log", "log <channel>", "uwu");
public static Command Invite = new Command("invite", "invite", "uwu");
public static Command PermCheck = new Command("permcheck", "permcheck <guild>", "uwu");
private IDiscordClient _client;
public CommandTree(IDiscordClient client)
{
_client = client;
}
public Task ExecuteCommand(Context ctx)
{
if (ctx.Match("system", "s"))
return HandleSystemCommand(ctx);
if (ctx.Match("member", "m"))
return HandleMemberCommand(ctx);
if (ctx.Match("switch", "sw"))
return HandleSwitchCommand(ctx);
if (ctx.Match("link"))
return ctx.Execute<LinkCommands>(Link, m => m.LinkSystem(ctx));
if (ctx.Match("unlink"))
return ctx.Execute<LinkCommands>(Unlink, m => m.UnlinkAccount(ctx));
if (ctx.Match("token"))
if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen"))
return ctx.Execute<APICommands>(TokenRefresh, m => m.RefreshToken(ctx));
else
return ctx.Execute<APICommands>(TokenGet, m => m.GetToken(ctx));
if (ctx.Match("import"))
return ctx.Execute<ImportExportCommands>(Import, m => m.Import(ctx));
if (ctx.Match("export"))
return ctx.Execute<ImportExportCommands>(Export, m => m.Export(ctx));
if (ctx.Match("help"))
if (ctx.Match("commands"))
return ctx.Execute<HelpCommands>(HelpCommandList, m => m.CommandList(ctx));
else if (ctx.Match("proxy"))
return ctx.Execute<HelpCommands>(HelpProxy, m => m.HelpProxy(ctx));
else return ctx.Execute<HelpCommands>(Help, m => m.HelpRoot(ctx));
if (ctx.Match("commands"))
return ctx.Execute<HelpCommands>(HelpCommandList, m => m.CommandList(ctx));
if (ctx.Match("message", "msg"))
return ctx.Execute<ModCommands>(Message, m => m.GetMessage(ctx));
if (ctx.Match("log"))
return ctx.Execute<ModCommands>(Log, m => m.SetLogChannel(ctx));
if (ctx.Match("invite")) return ctx.Execute<MiscCommands>(Invite, m => m.Invite(ctx));
if (ctx.Match("mn")) return ctx.Execute<MiscCommands>(null, m => m.Mn(ctx));
if (ctx.Match("fire")) return ctx.Execute<MiscCommands>(null, m => m.Fire(ctx));
if (ctx.Match("thunder")) return ctx.Execute<MiscCommands>(null, m => m.Thunder(ctx));
if (ctx.Match("freeze")) return ctx.Execute<MiscCommands>(null, m => m.Freeze(ctx));
if (ctx.Match("starstorm")) return ctx.Execute<MiscCommands>(null, m => m.Starstorm(ctx));
if (ctx.Match("stats")) return ctx.Execute<MiscCommands>(null, m => m.Stats(ctx));
if (ctx.Match("permcheck"))
return ctx.Execute<MiscCommands>(PermCheck, m => m.PermCheckGuild(ctx));
ctx.Reply(
$"{Emojis.Error} Unknown command `{ctx.PeekArgument().SanitizeMentions()}`. For a list of possible commands, see <https://pluralkit.me/commands>.");
return Task.CompletedTask;
}
private async Task HandleSystemCommand(Context ctx)
{
// If we have no parameters, default to self-target
if (!ctx.HasNext())
await ctx.Execute<SystemCommands>(SystemInfo, m => m.Query(ctx, ctx.System));
// First, we match own-system-only commands (ie. no target system parameter)
else if (ctx.Match("new", "create", "make", "add", "register", "init"))
await ctx.Execute<SystemCommands>(SystemNew, m => m.New(ctx));
else if (ctx.Match("name", "rename", "changename"))
await ctx.Execute<SystemCommands>(SystemRename, m => m.Name(ctx));
else if (ctx.Match("tag"))
await ctx.Execute<SystemCommands>(SystemTag, m => m.Tag(ctx));
else if (ctx.Match("description", "desc", "bio"))
await ctx.Execute<SystemCommands>(SystemDesc, m => m.Description(ctx));
else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp"))
await ctx.Execute<SystemCommands>(SystemAvatar, m => m.SystemAvatar(ctx));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.Execute<SystemCommands>(SystemDelete, m => m.Delete(ctx));
else if (ctx.Match("timezone", "tz"))
await ctx.Execute<SystemCommands>(SystemTimezone, m => m.SystemTimezone(ctx));
else if (ctx.Match("list", "l", "members"))
{
if (ctx.Match("f", "full", "big", "details", "long"))
await ctx.Execute<SystemCommands>(SystemList, m => m.MemberLongList(ctx, ctx.System));
else
await ctx.Execute<SystemCommands>(SystemList, m => m.MemberShortList(ctx, ctx.System));
}
else if (ctx.Match("f", "front", "fronter", "fronters"))
{
if (ctx.Match("h", "history"))
await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System));
else if (ctx.Match("p", "percent", "%"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System));
else
await ctx.Execute<SystemCommands>(SystemFronter, m => m.SystemFronter(ctx, ctx.System));
}
else if (ctx.Match("fh", "fronthistory", "history", "switches"))
await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, ctx.System));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, ctx.System));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<SystemCommands>(SystemInfo, m => m.Query(ctx, ctx.System));
else
await HandleSystemCommandTargeted(ctx);
}
private async Task HandleSystemCommandTargeted(Context ctx)
{
// Commands that have a system target (eg. pk;system <system> fronthistory)
var target = await ctx.MatchSystem();
if (target == null)
{
var list = CreatePotentialCommandList(SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemDelete, SystemTimezone, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent);
await ctx.Reply(
$"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\nPerhaps you meant to use one of the following commands?\n{list}");
}
else if (ctx.Match("list", "l", "members"))
{
if (ctx.Match("f", "full", "big", "details", "long"))
await ctx.Execute<SystemCommands>(SystemList, m => m.MemberLongList(ctx, target));
else
await ctx.Execute<SystemCommands>(SystemList, m => m.MemberShortList(ctx, target));
}
else if (ctx.Match("f", "front", "fronter", "fronters"))
{
if (ctx.Match("h", "history"))
await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target));
else if (ctx.Match("p", "percent", "%"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, target));
else
await ctx.Execute<SystemCommands>(SystemFronter, m => m.SystemFronter(ctx, target));
}
else if (ctx.Match("fh", "fronthistory", "history", "switches"))
await ctx.Execute<SystemCommands>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemCommands>(SystemFrontPercent, m => m.SystemFrontPercent(ctx, target));
else if (ctx.Match("info", "view", "show"))
await ctx.Execute<SystemCommands>(SystemInfo, m => m.Query(ctx, target));
else if (!ctx.HasNext())
await ctx.Execute<SystemCommands>(SystemInfo, m => m.Query(ctx, target));
else
await PrintCommandNotFoundError(ctx, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent,
SystemInfo);
}
private async Task HandleMemberCommand(Context ctx)
{
if (ctx.Match("new", "n", "add", "create", "register"))
await ctx.Execute<MemberCommands>(MemberNew, m => m.NewMember(ctx));
else if (await ctx.MatchMember() is PKMember target)
await HandleMemberCommandTargeted(ctx, target);
else if (!ctx.HasNext())
await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName, MemberDesc, MemberPronouns,
MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar);
else
await ctx.Reply($"{Emojis.Error} {ctx.CreateMemberNotFoundError(ctx.PopArgument())}");
}
private async Task HandleMemberCommandTargeted(Context ctx, PKMember target)
{
// Commands that have a member target (eg. pk;member <member> delete)
if (ctx.Match("rename", "name", "changename", "setname"))
await ctx.Execute<MemberCommands>(MemberRename, m => m.RenameMember(ctx, target));
else if (ctx.Match("description", "info", "bio", "text", "desc"))
await ctx.Execute<MemberCommands>(MemberDesc, m => m.MemberDescription(ctx, target));
else if (ctx.Match("pronouns", "pronoun"))
await ctx.Execute<MemberCommands>(MemberPronouns, m => m.MemberPronouns(ctx, target));
else if (ctx.Match("color", "colour"))
await ctx.Execute<MemberCommands>(MemberColor, m => m.MemberColor(ctx, target));
else if (ctx.Match("birthday", "bday", "birthdate", "cakeday", "bdate"))
await ctx.Execute<MemberCommands>(MemberBirthday, m => m.MemberBirthday(ctx, target));
else if (ctx.Match("proxy", "tags", "proxytags", "brackets"))
await ctx.Execute<MemberCommands>(MemberProxy, m => m.MemberProxy(ctx, target));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.Execute<MemberCommands>(MemberDelete, m => m.MemberDelete(ctx, target));
else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic"))
await ctx.Execute<MemberCommands>(MemberAvatar, m => m.MemberAvatar(ctx, target));
else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname"))
await ctx.Execute<MemberCommands>(MemberDisplayName, m => m.MemberDisplayName(ctx, target));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<MemberCommands>(MemberInfo, m => m.ViewMember(ctx, target));
else
await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar, SystemList);
}
private async Task HandleSwitchCommand(Context ctx)
{
if (ctx.Match("out"))
await ctx.Execute<SwitchCommands>(SwitchOut, m => m.SwitchOut(ctx));
else if (ctx.Match("move", "shift", "offset"))
await ctx.Execute<SwitchCommands>(SwitchMove, m => m.SwitchMove(ctx));
else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet"))
await ctx.Execute<SwitchCommands>(SwitchDelete, m => m.SwitchDelete(ctx));
else if (ctx.HasNext()) // there are following arguments
await ctx.Execute<SwitchCommands>(Switch, m => m.Switch(ctx));
else
await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchDelete, SystemFronter, SystemFrontHistory);
}
private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands)
{
var commandListStr = CreatePotentialCommandList(potentialCommands);
await ctx.Reply(
$"{Emojis.Error} Unknown command `pk;{ctx.FullCommand}`. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see <https://pluralkit.me/commands>.");
}
private async Task PrintCommandExpectedError(Context ctx, params Command[] potentialCommands)
{
var commandListStr = CreatePotentialCommandList(potentialCommands);
await ctx.Reply(
$"{Emojis.Error} You need to pass a command. Perhaps you meant to use one of the following commands?\n{commandListStr}\n\nFor a full list of possible commands, see <https://pluralkit.me/commands>.");
}
private static string CreatePotentialCommandList(params Command[] potentialCommands)
{
return string.Join("\n", potentialCommands.Select(cmd => $"- `pk;{cmd.Usage}`"));
}
private async Task<string> CreateSystemNotFoundError(Context ctx)
{
var input = ctx.PopArgument();
if (input.TryParseMention(out var id))
{
// Try to resolve the user ID to find the associated account,
// so we can print their username.
var user = await _client.GetUserAsync(id);
// Print descriptive errors based on whether we found the user or not.
if (user == null)
return $"Account with ID `{id}` not found.";
return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered.";
}
return $"System with ID `{input}` not found.";
}
}
}

View File

@ -1,56 +1,43 @@
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class HelpCommands: ModuleBase<PKCommandContext>
public class HelpCommands
{
[Group("help")]
public class HelpGroup: ModuleBase<PKCommandContext>
public async Task HelpProxy(Context ctx)
{
[Command("proxy")]
[Priority(1)]
[Remarks("help proxy")]
public async Task HelpProxy()
{
await Context.Channel.SendMessageAsync(
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying");
}
[Command("member")]
[Priority(1)]
[Remarks("help member")]
public async Task HelpMember()
{
await Context.Channel.SendMessageAsync(
"The member help page has been moved! See the website: https://pluralkit.me/guide#member-management");
}
[Command]
[Remarks("help")]
public async Task HelpRoot([Remainder] string _ignored = null)
{
await Context.Channel.SendMessageAsync(embed: new EmbedBuilder()
.WithTitle("PluralKit")
.WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.")
.AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.")
.AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.")
.AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the documentation](https://pluralkit.me/guide#member-management) for more information.")
.AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!")
.AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.")
.AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78")
.WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/")
.WithColor(Color.Blue)
.Build());
}
await ctx.Reply(
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying");
}
[Command("commands")]
[Remarks("commands")]
public async Task CommandList()
public async Task HelpMember(Context ctx)
{
await Context.Channel.SendMessageAsync(
await ctx.Reply(
"The member help page has been moved! See the website: https://pluralkit.me/guide#member-management");
}
public async Task HelpRoot(Context ctx)
{
await ctx.Reply(embed: new EmbedBuilder()
.WithTitle("PluralKit")
.WithDescription("PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.")
.AddField("What is this for? What are systems?", "This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.")
.AddField("Why are people's names saying [BOT] next to them?", "These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation.")
.AddField("How do I get started?", "To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):\n**1**. `pk;system new` - Create a system (if you haven't already)\n**2**. `pk;member add John` - Add a new member to your system\n**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags\n**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.\n**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.\n\nSee [the documentation](https://pluralkit.me/guide#member-management) for more information.")
.AddField("Useful tips", $"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)\nReact with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)\nType **`pk;invite`** to get a link to invite this bot to your own server!")
.AddField("More information", "For a full list of commands, see [the command list](https://pluralkit.me/commands).\nFor a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).\nIf you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.")
.AddField("Support server", "We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78")
.WithFooter("By @Ske#6201 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/")
.WithColor(Color.Blue)
.Build());
}
public async Task CommandList(Context ctx)
{
await ctx.Reply(
"The command list has been moved! See the website: https://pluralkit.me/commands");
}
}

View File

@ -1,31 +1,45 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using Discord.Net;
using Newtonsoft.Json;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class ImportExportCommands : ModuleBase<PKCommandContext>
public class ImportExportCommands
{
public DataFileService DataFiles { get; set; }
[Command("import")]
[Remarks("import [fileurl]")]
public async Task Import([Remainder] string url = null)
private DataFileService _dataFiles;
public ImportExportCommands(DataFileService dataFiles)
{
if (url == null) url = Context.Message.Attachments.FirstOrDefault()?.Url;
_dataFiles = dataFiles;
}
public async Task Import(Context ctx)
{
var url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url;
if (url == null) throw Errors.NoImportFilePassed;
await Context.BusyIndicator(async () =>
await ctx.BusyIndicator(async () =>
{
using (var client = new HttpClient())
{
var response = await client.GetAsync(url);
HttpResponseMessage response;
try
{
response = await client.GetAsync(url);
}
catch (InvalidOperationException)
{
// Invalid URL throws this, we just error back out
throw Errors.InvalidImportFile;
}
if (!response.IsSuccessStatusCode) throw Errors.InvalidImportFile;
var json = await response.Content.ReadAsStringAsync();
@ -63,8 +77,8 @@ namespace PluralKit.Bot.Commands
issueStr +=
"\n- PluralKit does not support per-member system tags. Since you had multiple members with distinct tags, tags will not be imported. You can set your system tag using the `pk;system tag <tag>` command later.";
var msg = await Context.Channel.SendMessageAsync($"{issueStr}\n\nDo you want to proceed with the import?");
if (!await Context.PromptYesNo(msg)) throw Errors.ImportCancelled;
var msg = await ctx.Reply($"{issueStr}\n\nDo you want to proceed with the import?");
if (!await ctx.PromptYesNo(msg)) throw Errors.ImportCancelled;
}
data = res.System;
@ -78,37 +92,36 @@ namespace PluralKit.Bot.Commands
if (!data.Valid) throw Errors.InvalidImportFile;
if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(Context.User.Id))
if (data.LinkedAccounts != null && !data.LinkedAccounts.Contains(ctx.Author.Id))
{
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?");
if (!await Context.PromptYesNo(msg)) throw Errors.ImportCancelled;
var msg = await ctx.Reply($"{Emojis.Warn} You seem to importing a system profile belonging to another account. Are you sure you want to proceed?");
if (!await ctx.PromptYesNo(msg)) throw Errors.ImportCancelled;
}
// If passed system is null, it'll create a new one
// (and that's okay!)
var result = await DataFiles.ImportSystem(data, Context.SenderSystem, Context.User.Id);
var result = await _dataFiles.ImportSystem(data, ctx.System, ctx.Author.Id);
if (Context.SenderSystem == null)
if (ctx.System != null)
{
await Context.Channel.SendMessageAsync($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.System.Hid}`. Type `pk;system` for more information.");
await ctx.Reply($"{Emojis.Success} PluralKit has created a system for you based on the given file. Your system ID is `{result.System.Hid}`. Type `pk;system` for more information.");
}
else
{
await Context.Channel.SendMessageAsync($"{Emojis.Success} Updated {result.ModifiedNames.Count} members, created {result.AddedNames.Count} members. Type `pk;system list` to check!");
await ctx.Reply($"{Emojis.Success} Updated {result.ModifiedNames.Count} members, created {result.AddedNames.Count} members. Type `pk;system list` to check!");
}
}
});
}
[Command("export")]
[Remarks("export")]
[MustHaveSystem]
public async Task Export()
public async Task Export(Context ctx)
{
var json = await Context.BusyIndicator(async () =>
ctx.CheckSystem();
var json = await ctx.BusyIndicator(async () =>
{
// Make the actual data file
var data = await DataFiles.ExportSystem(Context.SenderSystem);
var data = await _dataFiles.ExportSystem(ctx.System);
return JsonConvert.SerializeObject(data, Formatting.None);
});
@ -118,16 +131,16 @@ namespace PluralKit.Bot.Commands
try
{
await Context.User.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!");
await ctx.Author.SendFileAsync(stream, "system.json", $"{Emojis.Success} Here you go!");
// If the original message wasn't posted in DMs, send a public reminder
if (!(Context.Channel is IDMChannel))
await Context.Channel.SendMessageAsync($"{Emojis.Success} Check your DMs!");
if (!(ctx.Channel is IDMChannel))
await ctx.Reply($"{Emojis.Success} Check your DMs!");
}
catch (HttpException)
{
// If user has DMs closed, tell 'em to open them
await Context.Channel.SendMessageAsync(
await ctx.Reply(
$"{Emojis.Error} Could not send the data file in your DMs. Do you have DMs closed?");
}
}

View File

@ -1,50 +1,57 @@
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class LinkCommands: ModuleBase<PKCommandContext>
public class LinkCommands
{
public SystemStore Systems { get; set; }
private SystemStore _systems;
[Command("link")]
[Remarks("link <account>")]
[MustHaveSystem]
public async Task LinkSystem(IUser account)
public LinkCommands(SystemStore systems)
{
var accountIds = await Systems.GetLinkedAccountIds(Context.SenderSystem);
_systems = systems;
}
public async Task LinkSystem(Context ctx)
{
ctx.CheckSystem();
var account = await ctx.MatchUser() ?? throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
var accountIds = await _systems.GetLinkedAccountIds(ctx.System);
if (accountIds.Contains(account.Id)) throw Errors.AccountAlreadyLinked;
var existingAccount = await Systems.GetByAccount(account.Id);
var existingAccount = await _systems.GetByAccount(account.Id);
if (existingAccount != null) throw Errors.AccountInOtherSystem(existingAccount);
var msg = await Context.Channel.SendMessageAsync(
$"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message.");
if (!await Context.PromptYesNo(msg, user: account)) throw Errors.MemberLinkCancelled;
await Systems.Link(Context.SenderSystem, account.Id);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Account linked to system.");
var msg = await ctx.Reply($"{account.Mention}, please confirm the link by clicking the {Emojis.Success} reaction on this message.");
if (!await ctx.PromptYesNo(msg, user: account)) throw Errors.MemberLinkCancelled;
await _systems.Link(ctx.System, account.Id);
await ctx.Reply($"{Emojis.Success} Account linked to system.");
}
[Command("unlink")]
[Remarks("unlink [account]")]
[MustHaveSystem]
public async Task UnlinkAccount(IUser account = null)
public async Task UnlinkAccount(Context ctx)
{
if (account == null) account = Context.User;
ctx.CheckSystem();
var accountIds = (await Systems.GetLinkedAccountIds(Context.SenderSystem)).ToList();
IUser account;
if (!ctx.HasNext())
account = ctx.Author;
else
account = await ctx.MatchUser() ?? throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
var accountIds = (await _systems.GetLinkedAccountIds(ctx.System)).ToList();
if (!accountIds.Contains(account.Id)) throw Errors.AccountNotLinked;
if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount;
var msg = await Context.Channel.SendMessageAsync(
var msg = await ctx.Reply(
$"Are you sure you want to unlink {account.Mention} from your system?");
if (!await Context.PromptYesNo(msg)) throw Errors.MemberUnlinkCancelled;
if (!await ctx.PromptYesNo(msg)) throw Errors.MemberUnlinkCancelled;
await Systems.Unlink(Context.SenderSystem, account.Id);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Account unlinked.");
await _systems.Unlink(ctx.System, account.Id);
await ctx.Reply($"{Emojis.Success} Account unlinked.");
}
}
}

View File

@ -2,161 +2,170 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using NodaTime;
using PluralKit.Bot.CommandSystem;
using PluralKit.Core;
namespace PluralKit.Bot.Commands
{
[Group("member")]
[Alias("m")]
public class MemberCommands : ContextParameterModuleBase<PKMember>
public class MemberCommands
{
public SystemStore Systems { get; set; }
public MemberStore Members { get; set; }
public EmbedService Embeds { get; set; }
private SystemStore _systems;
private MemberStore _members;
private EmbedService _embeds;
public override string Prefix => "member";
public override string ContextNoun => "member";
private ProxyCacheService _proxyCache;
[Command("new")]
[Alias("n", "add", "create", "register")]
[Remarks("member new <name>")]
[MustHaveSystem]
public async Task NewMember([Remainder] string memberName) {
public MemberCommands(SystemStore systems, MemberStore members, EmbedService embeds, ProxyCacheService proxyCache)
{
_systems = systems;
_members = members;
_embeds = embeds;
_proxyCache = proxyCache;
}
public async Task NewMember(Context ctx) {
if (ctx.System == null) throw Errors.NoSystemError;
var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name.");
// 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, or set a member display name)");
if (!await Context.PromptYesNo(msg)) throw new PKError("Member creation cancelled.");
if (memberName.Length > ctx.System.MaxMemberNameLength) {
var msg = await ctx.Reply($"{Emojis.Warn} Member name too long ({memberName.Length} > {ctx.System.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to create it anyway? (You can change the name later, or set a member display name)");
if (!await ctx.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);
var existingMember = await _members.GetByName(ctx.System, 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.Sanitize()}\" (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.");
var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.SanitizeMentions()}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?");
if (!await ctx.PromptYesNo(msg)) throw new PKError("Member creation cancelled.");
}
// Create the member
var member = await Members.Create(Context.SenderSystem, memberName);
var member = await _members.Create(ctx.System, memberName);
// Send confirmation and space hint
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member \"{memberName.Sanitize()}\" (`{member.Hid}`) registered! See the user guide for commands for editing this member: https://pluralkit.me/guide#member-management");
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, or just use the member's 5-character ID (which is `{member.Hid}`).");
await ctx.Reply($"{Emojis.Success} Member \"{memberName.SanitizeMentions()}\" (`{member.Hid}`) registered! See the user guide for commands for editing this member: https://pluralkit.me/guide#member-management");
if (memberName.Contains(" ")) await ctx.Reply($"{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, or just use the member's 5-character ID (which is `{member.Hid}`).");
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
[Command("rename")]
[Alias("name", "changename", "setname")]
[Remarks("member <member> rename <newname>")]
[MustPassOwnMember]
public async Task RenameMember([Remainder] string newName) {
public async Task RenameMember(Context ctx, PKMember target) {
// TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean?
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
var newName = ctx.RemainderOrNull();
// Hard name length cap
if (newName.Length > Limits.MaxMemberNameLength) throw Errors.MemberNameTooLongError(newName.Length);
// Warn if member name will be unproxyable (with/without tag), only if member doesn't have a display name
if (ContextEntity.DisplayName == null && 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? (You can set a member display name instead)");
if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
if (target.DisplayName == null && newName.Length > ctx.System.MaxMemberNameLength) {
var msg = await ctx.Reply($"{Emojis.Warn} New member name too long ({newName.Length} > {ctx.System.MaxMemberNameLength} characters), this member will be unproxyable. Do you want to change it anyway? (You can set a member display name instead)");
if (!await ctx.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);
var existingMember = await _members.GetByName(ctx.System, 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.Sanitize()}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?");
if (!await Context.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
var msg = await ctx.Reply($"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.Name.SanitizeMentions()}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?");
if (!await ctx.PromptYesNo(msg)) throw new PKError("Member renaming cancelled.");
}
// Rename the member
ContextEntity.Name = newName;
await Members.Save(ContextEntity);
target.Name = newName;
await _members.Save(target);
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.");
if (ContextEntity.DisplayName != null) await Context.Channel.SendMessageAsync($"{Emojis.Note} Note that this member has a display name set (`{ContextEntity.DisplayName}`), and will be proxied using that name instead.");
await ctx.Reply($"{Emojis.Success} Member renamed.");
if (newName.Contains(" ")) await ctx.Reply($"{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.");
if (target.DisplayName != null) await ctx.Reply($"{Emojis.Note} Note that this member has a display name set ({target.DisplayName.SanitizeMentions()}), and will be proxied using that name instead.");
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
public async Task MemberDescription(Context ctx, PKMember target) {
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
[Command("description")]
[Alias("info", "bio", "text", "desc")]
[Remarks("member <member> description <description>")]
[MustPassOwnMember]
public async Task MemberDescription([Remainder] string description = null) {
var description = ctx.RemainderOrNull();
if (description.IsLongerThan(Limits.MaxDescriptionLength)) throw Errors.DescriptionTooLongError(description.Length);
ContextEntity.Description = description;
await Members.Save(ContextEntity);
target.Description = description;
await _members.Save(target);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member description {(description == null ? "cleared" : "changed")}.");
await ctx.Reply($"{Emojis.Success} Member description {(description == null ? "cleared" : "changed")}.");
}
public async Task MemberPronouns(Context ctx, PKMember target) {
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
[Command("pronouns")]
[Alias("pronoun")]
[Remarks("member <member> pronouns <pronouns>")]
[MustPassOwnMember]
public async Task MemberPronouns([Remainder] string pronouns = null) {
var pronouns = ctx.RemainderOrNull();
if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) throw Errors.MemberPronounsTooLongError(pronouns.Length);
ContextEntity.Pronouns = pronouns;
await Members.Save(ContextEntity);
target.Pronouns = pronouns;
await _members.Save(target);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member pronouns {(pronouns == null ? "cleared" : "changed")}.");
await ctx.Reply($"{Emojis.Success} Member pronouns {(pronouns == null ? "cleared" : "changed")}.");
}
[Command("color")]
[Alias("colour")]
[Remarks("member <member> color <color>")]
[MustPassOwnMember]
public async Task MemberColor([Remainder] string color = null)
public async Task MemberColor(Context ctx, PKMember target)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
var color = ctx.RemainderOrNull();
if (color != null)
{
if (color.StartsWith("#")) color = color.Substring(1);
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
}
ContextEntity.Color = color;
await Members.Save(ContextEntity);
target.Color = color;
await _members.Save(target);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member color {(color == null ? "cleared" : "changed")}.");
await ctx.Reply($"{Emojis.Success} Member color {(color == null ? "cleared" : "changed")}.");
}
[Command("birthday")]
[Alias("birthdate", "bday", "cakeday", "bdate")]
[Remarks("member <member> birthday <birthday>")]
[MustPassOwnMember]
public async Task MemberBirthday([Remainder] string birthday = null)
public async Task MemberBirthday(Context ctx, PKMember target)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
LocalDate? date = null;
var birthday = ctx.RemainderOrNull();
if (birthday != null)
{
date = PluralKit.Utils.ParseDate(birthday, true);
if (date == null) throw Errors.BirthdayParseError(birthday);
}
ContextEntity.Birthday = date;
await Members.Save(ContextEntity);
target.Birthday = date;
await _members.Save(target);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member birthdate {(date == null ? "cleared" : $"changed to {ContextEntity.BirthdayString}")}.");
await ctx.Reply($"{Emojis.Success} Member birthdate {(date == null ? "cleared" : $"changed to {target.BirthdayString}")}.");
}
[Command("proxy")]
[Alias("proxy", "tags", "proxytags", "brackets")]
[Remarks("member <member> proxy <proxy tags>")]
[MustPassOwnMember]
public async Task MemberProxy([Remainder] string exampleProxy = null)
public async Task MemberProxy(Context ctx, PKMember target)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
// Handling the clear case in an if here to keep the body dedented
var exampleProxy = ctx.RemainderOrNull();
if (exampleProxy == null)
{
// Just reset and send OK message
ContextEntity.Prefix = null;
ContextEntity.Suffix = null;
await Members.Save(ContextEntity);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags cleared.");
target.Prefix = null;
target.Suffix = null;
await _members.Save(target);
await ctx.Reply($"{Emojis.Success} Member proxy tags cleared.");
return;
}
@ -166,101 +175,110 @@ namespace PluralKit.Bot.Commands
if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText;
// If the prefix/suffix is empty, use "null" instead (for DB)
ContextEntity.Prefix = prefixAndSuffix[0].Length > 0 ? prefixAndSuffix[0] : null;
ContextEntity.Suffix = prefixAndSuffix[1].Length > 0 ? prefixAndSuffix[1] : null;
await Members.Save(ContextEntity);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member proxy tags changed to `{ContextEntity.ProxyString.Sanitize()}`. Try proxying now!");
}
[Command("delete")]
[Alias("remove", "destroy", "erase", "yeet")]
[Remarks("member <member> delete")]
[MustPassOwnMember]
public async Task MemberDelete()
{
await Context.Channel.SendMessageAsync($"{Emojis.Warn} Are you sure you want to delete \"{ContextEntity.Name.Sanitize()}\"? If so, reply to this message with the member's ID (`{ContextEntity.Hid}`). __***This cannot be undone!***__");
if (!await Context.ConfirmWithReply(ContextEntity.Hid)) throw Errors.MemberDeleteCancelled;
await Members.Delete(ContextEntity);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member deleted.");
}
[Command("avatar")]
[Alias("profile", "picture", "icon", "image", "pic", "pfp")]
[Remarks("member <member> avatar <avatar url>")]
[MustPassOwnMember]
public async Task MemberAvatarByMention(IUser member)
{
if (member.AvatarId == null) throw Errors.UserHasNoAvatar;
ContextEntity.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256);
await Members.Save(ContextEntity);
target.Prefix = prefixAndSuffix[0].Length > 0 ? prefixAndSuffix[0] : null;
target.Suffix = prefixAndSuffix[1].Length > 0 ? prefixAndSuffix[1] : null;
await _members.Save(target);
await ctx.Reply($"{Emojis.Success} Member proxy tags changed to `{target.ProxyString.SanitizeMentions()}`. Try proxying now!");
var embed = new EmbedBuilder().WithImageUrl(ContextEntity.AvatarUrl).Build();
await Context.Channel.SendMessageAsync(
$"{Emojis.Success} Member avatar changed to {member.Username}'s avatar! {Emojis.Warn} Please note that if {member.Username} changes their avatar, the webhook's avatar will need to be re-set.", embed: embed);
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
[Command("avatar")]
[Alias("profile", "picture", "icon", "image", "pic", "pfp")]
[Remarks("member <member> avatar <avatar url>")]
[MustPassOwnMember]
public async Task MemberAvatar([Remainder] string avatarUrl = null)
public async Task MemberDelete(Context ctx, PKMember target)
{
string url = avatarUrl ?? Context.Message.Attachments.FirstOrDefault()?.ProxyUrl;
if (url != null) await Context.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url));
ContextEntity.AvatarUrl = url;
await Members.Save(ContextEntity);
var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null;
await Context.Channel.SendMessageAsync($"{Emojis.Success} Member avatar {(url == null ? "cleared" : "changed")}.", embed: embed);
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.Name.SanitizeMentions()}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__");
if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled;
await _members.Delete(target);
await ctx.Reply($"{Emojis.Success} Member deleted.");
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
[Command("displayname")]
[Alias("nick", "nickname", "displayname")]
[Remarks("member <member> displayname <displayname>")]
[MustPassOwnMember]
public async Task MemberDisplayName([Remainder] string newDisplayName = null)
{
// Refuse if proxy name will be unproxyable (with/without tag)
if (newDisplayName != null && newDisplayName.Length > Context.SenderSystem.MaxMemberNameLength)
throw Errors.DisplayNameTooLong(newDisplayName, Context.SenderSystem.MaxMemberNameLength);
public async Task MemberAvatar(Context ctx, PKMember target)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
ContextEntity.DisplayName = newDisplayName;
await Members.Save(ContextEntity);
if (await ctx.MatchUser() is IUser user)
{
if (user.AvatarId == null) throw Errors.UserHasNoAvatar;
target.AvatarUrl = user.GetAvatarUrl(ImageFormat.Png, size: 256);
await _members.Save(target);
var embed = new EmbedBuilder().WithImageUrl(target.AvatarUrl).Build();
await ctx.Reply(
$"{Emojis.Success} Member avatar changed to {user.Username}'s avatar! {Emojis.Warn} Please note that if {user.Username} changes their avatar, the webhook's avatar will need to be re-set.", embed: embed);
}
else if (ctx.RemainderOrNull() is string url)
{
await Utils.VerifyAvatarOrThrow(url);
target.AvatarUrl = url;
await _members.Save(target);
var embed = new EmbedBuilder().WithImageUrl(url).Build();
await ctx.Reply($"{Emojis.Success} Member avatar changed.", embed: embed);
}
else if (ctx.Message.Attachments.FirstOrDefault() is Attachment attachment)
{
await Utils.VerifyAvatarOrThrow(attachment.Url);
target.AvatarUrl = attachment.Url;
await _members.Save(target);
await ctx.Reply($"{Emojis.Success} Member avatar changed to attached image. Please note that if you delete the message containing the attachment, the avatar will stop working.");
}
else
{
target.AvatarUrl = null;
await _members.Save(target);
await ctx.Reply($"{Emojis.Success} Member avatar cleared.");
}
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
public async Task MemberDisplayName(Context ctx, PKMember target)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError;
var newDisplayName = ctx.RemainderOrNull();
// Refuse if proxy name will be unproxyable (with/without tag)
if (newDisplayName != null && newDisplayName.Length > ctx.System.MaxMemberNameLength)
throw Errors.DisplayNameTooLong(newDisplayName, ctx.System.MaxMemberNameLength);
target.DisplayName = newDisplayName;
await _members.Save(target);
var successStr = $"{Emojis.Success} ";
if (newDisplayName != null)
{
successStr +=
$"Member display name changed. This member will now be proxied using the name `{newDisplayName}`.";
$"Member display name changed. This member will now be proxied using the name \"{newDisplayName.SanitizeMentions()}\".";
}
else
{
successStr += $"Member display name cleared. ";
// If we're removing display name and the *real* name will be unproxyable, warn.
if (ContextEntity.Name.Length > Context.SenderSystem.MaxMemberNameLength)
if (target.Name.Length > ctx.System.MaxMemberNameLength)
successStr +=
$" {Emojis.Warn} This member's actual name is too long ({ContextEntity.Name.Length} > {Context.SenderSystem.MaxMemberNameLength} characters), and thus cannot be proxied.";
$" {Emojis.Warn} This member's actual name is too long ({target.Name.Length} > {ctx.System.MaxMemberNameLength} characters), and thus cannot be proxied.";
else
successStr += $"This member will now be proxied using their member name `{ContextEntity.Name}.";
successStr += $"This member will now be proxied using their member name \"{target.Name.SanitizeMentions()}\".";
}
await Context.Channel.SendMessageAsync(successStr);
}
[Command]
[Alias("view", "show", "info")]
[Remarks("member <member>")]
public async Task ViewMember(PKMember member)
{
var system = await Systems.GetById(member.System);
await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMemberEmbed(system, member));
await ctx.Reply(successStr);
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
public override async Task<PKMember> ReadContextParameterAsync(string value)
public async Task ViewMember(Context ctx, PKMember target)
{
var res = await new PKMemberTypeReader().ReadAsync(Context, value, _services);
return res.IsSuccess ? res.BestMatch as PKMember : null;
var system = await _systems.GetById(target.System);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target));
}
}
}

View File

@ -1,23 +1,27 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using App.Metrics;
using Discord;
using Discord.Commands;
using Humanizer;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands {
public class MiscCommands: ModuleBase<PKCommandContext> {
public BotConfig BotConfig { get; set; }
public IMetrics Metrics { get; set; }
[Command("invite")]
[Alias("inv")]
[Remarks("invite")]
public async Task Invite()
public class MiscCommands
{
private BotConfig _botConfig;
private IMetrics _metrics;
public MiscCommands(BotConfig botConfig, IMetrics metrics)
{
var clientId = BotConfig.ClientId ?? (await Context.Client.GetApplicationInfoAsync()).Id;
_botConfig = botConfig;
_metrics = metrics;
}
public async Task Invite(Context ctx)
{
var clientId = _botConfig.ClientId ?? (await ctx.Client.GetApplicationInfoAsync()).Id;
var permissions = new GuildPermissions(
addReactions: true,
attachFiles: true,
@ -29,38 +33,36 @@ namespace PluralKit.Bot.Commands {
);
var invite = $"https://discordapp.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={permissions.RawValue}";
await Context.Channel.SendMessageAsync($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>");
await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>");
}
public Task Mn(Context ctx) => ctx.Reply("Gotta catch 'em all!");
public Task Fire(Context ctx) => ctx.Reply("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*");
public Task Thunder(Context ctx) => ctx.Reply("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*");
public Task Freeze(Context ctx) => ctx.Reply("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*");
public Task Starstorm(Context ctx) => ctx.Reply("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*");
[Command("mn")] public Task Mn() => Context.Channel.SendMessageAsync("Gotta catch 'em all!");
[Command("fire")] public Task Fire() => Context.Channel.SendMessageAsync("*A giant lightning bolt promptly erupts into a pillar of fire as it hits your opponent.*");
[Command("thunder")] public Task Thunder() => Context.Channel.SendMessageAsync("*A giant ball of lightning is conjured and fired directly at your opponent, vanquishing them.*");
[Command("freeze")] public Task Freeze() => Context.Channel.SendMessageAsync("*A giant crystal ball of ice is charged and hurled toward your opponent, bursting open and freezing them solid on contact.*");
[Command("starstorm")] public Task Starstorm() => Context.Channel.SendMessageAsync("*Vibrant colours burst forth from the sky as meteors rain down upon your opponent.*");
[Command("stats")]
public async Task Stats()
public async Task Stats(Context ctx)
{
var messagesReceived = Metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name).Value;
var messagesProxied = Metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name).Value;
var proxySuccessRate = messagesProxied.Items.First(i => i.Item == "success");
var messagesReceived = _metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.MessagesReceived.Name).Value;
var messagesProxied = _metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.MessagesProxied.Name).Value;
var commandsRun = Metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name).Value;
var commandsRun = _metrics.Snapshot.GetForContext("Bot").Meters.First(m => m.MultidimensionalName == BotMetrics.CommandsRun.Name).Value;
await Context.Channel.SendMessageAsync(embed: new EmbedBuilder()
await ctx.Reply(embed: new EmbedBuilder()
.AddField("Messages processed", $"{messagesReceived.OneMinuteRate:F1}/s ({messagesReceived.FifteenMinuteRate:F1}/s over 15m)")
.AddField("Messages proxied", $"{messagesProxied.OneMinuteRate:F1}/s ({messagesProxied.FifteenMinuteRate:F1}/s over 15m)")
.AddField("Commands executed", $"{commandsRun.OneMinuteRate:F1}/s ({commandsRun.FifteenMinuteRate:F1}/s over 15m)")
.AddField("Proxy success rate", $"{proxySuccessRate.Percent/100:P1}")
.Build());
}
[Command("permcheck")]
[Summary("permcheck [guild]")]
public async Task PermCheckGuild(ulong guildId)
public async Task PermCheckGuild(Context ctx)
{
var guildIdStr = ctx.PopArgument() ?? throw new PKSyntaxError("You must pass a server ID.");
if (!ulong.TryParse(guildIdStr, out var guildId)) throw new PKSyntaxError($"Could not parse `{guildIdStr.SanitizeMentions()}` as an ID.");
// TODO: will this call break for sharding if you try to request a guild on a different bot instance?
var guild = Context.Client.GetGuild(guildId) as IGuild;
var guild = ctx.Client.GetGuild(guildId) as IGuild;
if (guild == null)
throw Errors.GuildNotFound(guildId);
@ -95,7 +97,7 @@ namespace PluralKit.Bot.Commands {
// Generate the output embed
var eb = new EmbedBuilder()
.WithTitle($"Permission check for **{guild.Name}**");
.WithTitle($"Permission check for **{guild.Name.SanitizeMentions()}**");
if (permissionsMissing.Count == 0)
{
@ -120,13 +122,7 @@ namespace PluralKit.Bot.Commands {
}
// Send! :)
await Context.Channel.SendMessageAsync(embed: eb.Build());
await ctx.Reply(embed: eb.Build());
}
[Command("permcheck")]
[Summary("permcheck [guild]")]
[RequireContext(ContextType.Guild, ErrorMessage =
"When running this command in DMs, you must pass a guild ID.")]
public Task PermCheckGuild() => PermCheckGuild(Context.Guild.Id);
}
}

View File

@ -1,44 +1,56 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class ModCommands: ModuleBase<PKCommandContext>
public class ModCommands
{
public LogChannelService LogChannels { get; set; }
public MessageStore Messages { get; set; }
public EmbedService Embeds { get; set; }
[Command("log")]
[Remarks("log <channel>")]
[RequireUserPermission(GuildPermission.ManageGuild, ErrorMessage = "You must have the Manage Server permission to use this command.")]
[RequireContext(ContextType.Guild, ErrorMessage = "This command can not be run in a DM.")]
public async Task SetLogChannel(ITextChannel channel = null)
private LogChannelService _logChannels;
private MessageStore _messages;
private EmbedService _embeds;
public ModCommands(LogChannelService logChannels, MessageStore messages, EmbedService embeds)
{
await LogChannels.SetLogChannel(Context.Guild, channel);
_logChannels = logChannels;
_messages = messages;
_embeds = embeds;
}
public async Task SetLogChannel(Context ctx)
{
ctx.CheckAuthorPermission(GuildPermission.ManageGuild, "Manage Server").CheckGuildContext();
ITextChannel channel = null;
if (ctx.HasNext())
channel = ctx.MatchChannel() ?? throw new PKSyntaxError("You must pass a #channel to set.");
await _logChannels.SetLogChannel(ctx.Guild, channel);
if (channel != null)
await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel set to #{channel.Name.Sanitize()}.");
await ctx.Reply($"{Emojis.Success} Proxy logging channel set to #{channel.Name.SanitizeMentions()}.");
else
await Context.Channel.SendMessageAsync($"{Emojis.Success} Proxy logging channel cleared.");
await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared.");
}
[Command("message")]
[Remarks("message <messageid>")]
[Alias("msg")]
public async Task GetMessage(ulong messageId)
public async Task GetMessage(Context ctx)
{
var message = await Messages.Get(messageId);
var word = ctx.PopArgument() ?? throw new PKSyntaxError("You must pass a message ID or link.");
ulong messageId;
if (ulong.TryParse(word, out var id))
messageId = id;
else if (Regex.Match(word, "https://discordapp.com/channels/\\d+/(\\d+)") is Match match && match.Success)
messageId = ulong.Parse(match.Groups[1].Value);
else throw new PKSyntaxError($"Could not parse `{word}` as a message ID or link.");
var message = await _messages.Get(messageId);
if (message == null) throw Errors.MessageNotFound(messageId);
await Context.Channel.SendMessageAsync(embed: await Embeds.CreateMessageInfoEmbed(message));
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message));
}
[Command("message")]
[Remarks("message <messageid>")]
[Alias("msg")]
public async Task GetMessage(IMessage msg) => await GetMessage(msg.Id);
}
}

View File

@ -2,75 +2,91 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using NodaTime;
using NodaTime.TimeZones;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
[Group("switch")]
[Alias("sw")]
public class SwitchCommands: ModuleBase<PKCommandContext>
public class SwitchCommands
{
public SystemStore Systems { get; set; }
public SwitchStore Switches { get; set; }
private SwitchStore _switches;
[Command]
[Remarks("switch <member> [member...]")]
[MustHaveSystem]
public async Task Switch(params PKMember[] members) => await DoSwitchCommand(members);
[Command("out")]
[Alias("none")]
[Remarks("switch out")]
[MustHaveSystem]
public async Task SwitchOut() => await DoSwitchCommand(new PKMember[] { });
private async Task DoSwitchCommand(ICollection<PKMember> members)
public SwitchCommands(SwitchStore switches)
{
// Make sure all the members *are actually in the system*
// PKMember parameters won't let this happen if they resolve by name
// but they can if they resolve with ID
if (members.Any(m => m.System != Context.SenderSystem.Id)) throw Errors.SwitchMemberNotInSystem;
_switches = switches;
}
public async Task Switch(Context ctx)
{
ctx.CheckSystem();
var members = new List<PKMember>();
// Loop through all the given arguments
while (ctx.HasNext())
{
// and attempt to match a member
var member = await ctx.MatchMember();
if (member == null)
// if we can't, big error. Every member name must be valid.
throw new PKError(ctx.CreateMemberNotFoundError(ctx.PopArgument()));
ctx.CheckOwnMember(member); // Ensure they're in our own system
members.Add(member); // Then add to the final output list
}
// Finally, do the actual switch
await DoSwitchCommand(ctx, members);
}
public async Task SwitchOut(Context ctx)
{
ctx.CheckSystem();
// Switch with no members = switch-out
await DoSwitchCommand(ctx, new PKMember[] { });
}
private async Task DoSwitchCommand(Context ctx, ICollection<PKMember> members)
{
// Make sure there are no dupes in the list
// We do this by checking if removing duplicate member IDs results in a list of different length
if (members.Select(m => m.Id).Distinct().Count() != members.Count) throw Errors.DuplicateSwitchMembers;
// Find the last switch and its members if applicable
var lastSwitch = await Switches.GetLatestSwitch(Context.SenderSystem);
var lastSwitch = await _switches.GetLatestSwitch(ctx.System);
if (lastSwitch != null)
{
var lastSwitchMembers = await Switches.GetSwitchMembers(lastSwitch);
var lastSwitchMembers = await _switches.GetSwitchMembers(lastSwitch);
// Make sure the requested switch isn't identical to the last one
if (lastSwitchMembers.Select(m => m.Id).SequenceEqual(members.Select(m => m.Id)))
throw Errors.SameSwitch(members);
}
await Switches.RegisterSwitch(Context.SenderSystem, members);
await _switches.RegisterSwitch(ctx.System, members);
if (members.Count == 0)
await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch-out registered.");
await ctx.Reply($"{Emojis.Success} Switch-out registered.");
else
await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name)).Sanitize()}.");
await ctx.Reply($"{Emojis.Success} Switch registered. Current fronter is now {string.Join(", ", members.Select(m => m.Name)).SanitizeMentions()}.");
}
[Command("move")]
[Alias("shift")]
[Remarks("switch move <date/time>")]
[MustHaveSystem]
public async Task SwitchMove([Remainder] string str)
public async Task SwitchMove(Context ctx)
{
var tz = TzdbDateTimeZoneSource.Default.ForId(Context.SenderSystem.UiTz ?? "UTC");
ctx.CheckSystem();
var result = PluralKit.Utils.ParseDateTime(str, true, tz);
if (result == null) throw Errors.InvalidDateTime(str);
var timeToMove = ctx.RemainderOrNull() ?? throw new PKSyntaxError("Must pass a date or time to move the switch to.");
var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC");
var result = PluralKit.Utils.ParseDateTime(timeToMove, true, tz);
if (result == null) throw Errors.InvalidDateTime(timeToMove);
var time = result.Value;
if (time.ToInstant() > SystemClock.Instance.GetCurrentInstant()) throw Errors.SwitchTimeInFuture;
// Fetch the last two switches for the system to do bounds checking on
var lastTwoSwitches = (await Switches.GetSwitches(Context.SenderSystem, 2)).ToArray();
var lastTwoSwitches = (await _switches.GetSwitches(ctx.System, 2)).ToArray();
// If we don't have a switch to move, don't bother
if (lastTwoSwitches.Length == 0) throw Errors.NoRegisteredSwitches;
@ -84,55 +100,53 @@ namespace PluralKit.Bot.Commands
// Now we can actually do the move, yay!
// But, we do a prompt to confirm.
var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]);
var lastSwitchMembers = await _switches.GetSwitchMembers(lastTwoSwitches[0]);
var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name));
var lastSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(Context.SenderSystem.Zone));
var lastSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(lastTwoSwitches[0].Timestamp.InZone(ctx.System.Zone));
var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp);
var newSwitchTimeStr = Formats.ZonedDateTimeFormat.Format(time);
var newSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - time.ToInstant());
// yeet
var msg = await Context.Channel.SendMessageAsync($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr.Sanitize()}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?");
if (!await Context.PromptYesNo(msg)) throw Errors.SwitchMoveCancelled;
var msg = await ctx.Reply($"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr.SanitizeMentions()}) from {lastSwitchTimeStr} ({lastSwitchDeltaStr} ago) to {newSwitchTimeStr} ({newSwitchDeltaStr} ago). Is this OK?");
if (!await ctx.PromptYesNo(msg)) throw Errors.SwitchMoveCancelled;
// aaaand *now* we do the move
await Switches.MoveSwitch(lastTwoSwitches[0], time.ToInstant());
await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch moved.");
await _switches.MoveSwitch(lastTwoSwitches[0], time.ToInstant());
await ctx.Reply($"{Emojis.Success} Switch moved.");
}
[Command("delete")]
[Remarks("switch delete")]
[Alias("remove", "erase", "cancel", "yeet")]
[MustHaveSystem]
public async Task SwitchDelete()
public async Task SwitchDelete(Context ctx)
{
ctx.CheckSystem();
// Fetch the last two switches for the system to do bounds checking on
var lastTwoSwitches = (await Switches.GetSwitches(Context.SenderSystem, 2)).ToArray();
var lastTwoSwitches = (await _switches.GetSwitches(ctx.System, 2)).ToArray();
if (lastTwoSwitches.Length == 0) throw Errors.NoRegisteredSwitches;
var lastSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[0]);
var lastSwitchMembers = await _switches.GetSwitchMembers(lastTwoSwitches[0]);
var lastSwitchMemberStr = string.Join(", ", lastSwitchMembers.Select(m => m.Name));
var lastSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[0].Timestamp);
IUserMessage msg;
if (lastTwoSwitches.Length == 1)
{
msg = await Context.Channel.SendMessageAsync(
$"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.Sanitize()}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?");
msg = await ctx.Reply(
$"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.SanitizeMentions()}, {lastSwitchDeltaStr} ago). You have no other switches logged. Is this okay?");
}
else
{
var secondSwitchMembers = await Switches.GetSwitchMembers(lastTwoSwitches[1]);
var secondSwitchMembers = await _switches.GetSwitchMembers(lastTwoSwitches[1]);
var secondSwitchMemberStr = string.Join(", ", secondSwitchMembers.Select(m => m.Name));
var secondSwitchDeltaStr = Formats.DurationFormat.Format(SystemClock.Instance.GetCurrentInstant() - lastTwoSwitches[1].Timestamp);
msg = await Context.Channel.SendMessageAsync(
$"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.Sanitize()}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr.Sanitize()} ({secondSwitchDeltaStr} ago). Is this okay?");
msg = await ctx.Reply(
$"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr.SanitizeMentions()}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr.SanitizeMentions()} ({secondSwitchDeltaStr} ago). Is this okay?");
}
if (!await Context.PromptYesNo(msg)) throw Errors.SwitchDeleteCancelled;
await Switches.DeleteSwitch(lastTwoSwitches[0]);
if (!await ctx.PromptYesNo(msg)) throw Errors.SwitchDeleteCancelled;
await _switches.DeleteSwitch(lastTwoSwitches[0]);
await Context.Channel.SendMessageAsync($"{Emojis.Success} Switch deleted.");
await ctx.Reply($"{Emojis.Success} Switch deleted.");
}
}
}

View File

@ -2,230 +2,206 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using Humanizer;
using NodaTime;
using NodaTime.Text;
using NodaTime.TimeZones;
using PluralKit.Bot.CommandSystem;
using PluralKit.Core;
namespace PluralKit.Bot.Commands
{
[Group("system")]
[Alias("s")]
public class SystemCommands : ContextParameterModuleBase<PKSystem>
public class SystemCommands
{
public override string Prefix => "system";
public override string ContextNoun => "system";
private SystemStore _systems;
private MemberStore _members;
public SystemStore Systems {get; set;}
public MemberStore Members {get; set;}
public SwitchStore Switches {get; set;}
public EmbedService EmbedService {get; set;}
private SwitchStore _switches;
private EmbedService _embeds;
[Command]
[Remarks("system <name>")]
public async Task Query(PKSystem system = null) {
if (system == null) system = Context.SenderSystem;
private ProxyCacheService _proxyCache;
public SystemCommands(SystemStore systems, MemberStore members, SwitchStore switches, EmbedService embeds, ProxyCacheService proxyCache)
{
_systems = systems;
_members = members;
_switches = switches;
_embeds = embeds;
_proxyCache = proxyCache;
}
public async Task Query(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError;
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateSystemEmbed(system));
await ctx.Reply(embed: await _embeds.CreateSystemEmbed(system));
}
[Command("new")]
[Alias("register", "create", "init", "add", "make")]
[Remarks("system new <name>")]
public async Task New([Remainder] string systemName = null)
public async Task New(Context ctx)
{
if (ContextEntity != null) throw Errors.NotOwnSystemError;
if (Context.SenderSystem != null) throw Errors.ExistingSystemError;
ctx.CheckNoSystem();
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.");
var system = await _systems.Create(ctx.RemainderOrNull());
await _systems.Link(system, ctx.Author.Id);
await ctx.Reply($"{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.");
}
public async Task Name(Context ctx)
{
ctx.CheckSystem();
[Command("name")]
[Alias("rename", "changename")]
[Remarks("system name <name>")]
[MustHaveSystem]
public async Task Name([Remainder] string newSystemName = null) {
var newSystemName = ctx.RemainderOrNull();
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")}.");
ctx.System.Name = newSystemName;
await _systems.Save(ctx.System);
await ctx.Reply($"{Emojis.Success} System name {(newSystemName != null ? "changed" : "cleared")}.");
}
public async Task Description(Context ctx) {
ctx.CheckSystem();
[Command("description")]
[Alias("desc")]
[Remarks("system description <description>")]
[MustHaveSystem]
public async Task Description([Remainder] string newDescription = null) {
var newDescription = ctx.RemainderOrNull();
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")}.");
ctx.System.Description = newDescription;
await _systems.Save(ctx.System);
await ctx.Reply($"{Emojis.Success} System description {(newDescription != null ? "changed" : "cleared")}.");
}
public async Task Tag(Context ctx)
{
ctx.CheckSystem();
[Command("tag")]
[Remarks("system tag <tag>")]
[MustHaveSystem]
public async Task Tag([Remainder] string newTag = null) {
Context.SenderSystem.Tag = newTag;
var newTag = ctx.RemainderOrNull();
ctx.System.Tag = newTag;
if (newTag != null)
{
if (newTag.Length > Limits.MaxSystemTagLength) throw Errors.SystemNameTooLongError(newTag.Length);
// 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);
var unproxyableMembers = await _members.GetUnproxyableMembers(ctx.System);
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.");
var msg = await ctx.Reply(
$"{Emojis.Warn} Changing your system tag to '{newTag.SanitizeMentions()}' will result in the following members being unproxyable, since the tag would bring their name over {Limits.MaxProxyNameLength} characters:\n**{string.Join(", ", unproxyableMembers.Select((m) => m.Name.SanitizeMentions()))}**\nDo you want to continue anyway?");
if (!await ctx.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("avatar")]
[Alias("profile", "picture", "icon", "image", "pic", "pfp")]
[Remarks("system avatar <avatar url>")]
[MustHaveSystem]
public async Task SystemAvatar(IUser member)
{
if (member.AvatarId == null) throw Errors.UserHasNoAvatar;
Context.SenderSystem.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256);
await Systems.Save(Context.SenderSystem);
await _systems.Save(ctx.System);
await ctx.Reply($"{Emojis.Success} System tag {(newTag != null ? "changed" : "cleared")}.");
var embed = new EmbedBuilder().WithImageUrl(Context.SenderSystem.AvatarUrl).Build();
await Context.Channel.SendMessageAsync(
$"{Emojis.Success} System avatar changed to {member.Username}'s avatar! {Emojis.Warn} Please note that if {member.Username} changes their avatar, the system's avatar will need to be re-set.", embed: embed);
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
[Command("avatar")]
[Alias("profile", "picture", "icon", "image", "pic", "pfp")]
[Remarks("system avatar <avatar url>")]
[MustHaveSystem]
public async Task SystemAvatar([Remainder] string avatarUrl = null)
public async Task SystemAvatar(Context ctx)
{
string url = avatarUrl ?? Context.Message.Attachments.FirstOrDefault()?.ProxyUrl;
if (url != null) await Context.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url));
ctx.CheckSystem();
Context.SenderSystem.AvatarUrl = url;
await Systems.Save(Context.SenderSystem);
var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null;
await Context.Channel.SendMessageAsync($"{Emojis.Success} System avatar {(url == null ? "cleared" : "changed")}.", embed: embed);
}
[Command("delete")]
[Alias("remove", "destroy", "erase", "yeet")]
[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")]
[Alias("l", "members")]
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.toLower()).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}**";
}))
);
var member = await ctx.MatchUser();
if (member != null)
{
if (member.AvatarId == null) throw Errors.UserHasNoAvatar;
ctx.System.AvatarUrl = member.GetAvatarUrl(ImageFormat.Png, size: 256);
await _systems.Save(ctx.System);
var embed = new EmbedBuilder().WithImageUrl(ctx.System.AvatarUrl).Build();
await ctx.Reply(
$"{Emojis.Success} System avatar changed to {member.Username}'s avatar! {Emojis.Warn} Please note that if {member.Username} changes their avatar, the system's avatar will need to be re-set.", embed: embed);
}
else
{
string url = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.ProxyUrl;
if (url != null) await ctx.BusyIndicator(() => Utils.VerifyAvatarOrThrow(url));
[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;
ctx.System.AvatarUrl = url;
await _systems.Save(ctx.System);
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(),
5,
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.Truncate(1024));
}
var embed = url != null ? new EmbedBuilder().WithImageUrl(url).Build() : null;
await ctx.Reply($"{Emojis.Success} System avatar {(url == null ? "cleared" : "changed")}.", embed: embed);
}
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
public async Task Delete(Context ctx) {
ctx.CheckSystem();
var msg = await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{ctx.System.Hid}`).\n**Note: this action is permanent.**");
var reply = await ctx.AwaitMessage(ctx.Channel, ctx.Author, timeout: TimeSpan.FromMinutes(1));
if (reply.Content != ctx.System.Hid) throw new PKError($"System deletion cancelled. Note that you must reply with your system ID (`{ctx.System.Hid}`) *verbatim*.");
await _systems.Delete(ctx.System);
await ctx.Reply($"{Emojis.Success} System deleted.");
await _proxyCache.InvalidateResultsForSystem(ctx.System);
}
public async Task MemberShortList(Context ctx, PKSystem system) {
if (system == null) throw Errors.NoSystemError;
var members = await _members.GetBySystem(system);
var embedTitle = system.Name != null ? $"Members of {system.Name.SanitizeMentions()} (`{system.Hid}`)" : $"Members of `{system.Hid}`";
await ctx.Paginate<PKMember>(
members.OrderBy(m => m.Name.ToLower()).ToList(),
25,
embedTitle,
(eb, ms) => eb.Description = string.Join("\n", ms.Select((m) => {
if (m.HasProxyTags) return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}** *({m.ProxyString.SanitizeMentions()})*";
return $"[`{m.Hid}`] **{m.Name.SanitizeMentions()}**";
}))
);
}
public async Task MemberLongList(Context ctx, PKSystem system) {
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 ctx.Paginate<PKMember>(
members.OrderBy(m => m.Name.ToLower()).ToList(),
5,
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.Truncate(1024));
}
);
}
}
);
}
[Command("fronter")]
[Alias("f", "front", "fronters")]
[Remarks("system [system] fronter")]
public async Task SystemFronter()
public async Task SystemFronter(Context ctx, PKSystem system)
{
var system = ContextEntity ?? Context.SenderSystem;
if (system == null) throw Errors.NoSystemError;
var sw = await Switches.GetLatestSwitch(system);
var sw = await _switches.GetLatestSwitch(system);
if (sw == null) throw Errors.NoRegisteredSwitches;
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFronterEmbed(sw, system.Zone));
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone));
}
[Command("fronthistory")]
[Alias("fh", "history", "switches")]
[Remarks("system [system] fronthistory")]
public async Task SystemFrontHistory()
public async Task SystemFrontHistory(Context ctx, PKSystem system)
{
var system = ContextEntity ?? Context.SenderSystem;
if (system == null) throw Errors.NoSystemError;
var sws = (await Switches.GetSwitches(system, 10)).ToList();
var sws = (await _switches.GetSwitches(system, 10)).ToList();
if (sws.Count == 0) throw Errors.NoRegisteredSwitches;
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontHistoryEmbed(sws, system.Zone));
await ctx.Reply(embed: await _embeds.CreateFrontHistoryEmbed(sws, system.Zone));
}
[Command("frontpercent")]
[Alias("frontbreakdown", "frontpercent", "front%", "fp")]
[Remarks("system [system] frontpercent [duration]")]
public async Task SystemFrontPercent([Remainder] string durationStr = "30d")
public async Task SystemFrontPercent(Context ctx, PKSystem system)
{
var system = ContextEntity ?? Context.SenderSystem;
if (system == null) throw Errors.NoSystemError;
string durationStr = ctx.RemainderOrNull() ?? "30d";
var now = SystemClock.Instance.GetCurrentInstant();
@ -233,38 +209,37 @@ namespace PluralKit.Bot.Commands
if (rangeStart == null) throw Errors.InvalidDateTime(durationStr);
if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
var frontpercent = await Switches.GetPerMemberSwitchDuration(system, rangeStart.Value.ToInstant(), now);
await Context.Channel.SendMessageAsync(embed: await EmbedService.CreateFrontPercentEmbed(frontpercent, system.Zone));
var frontpercent = await _switches.GetPerMemberSwitchDuration(system, rangeStart.Value.ToInstant(), now);
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system.Zone));
}
[Command("timezone")]
[Alias("tz")]
[Remarks("system timezone [timezone]")]
[MustHaveSystem]
public async Task SystemTimezone([Remainder] string zoneStr = null)
public async Task SystemTimezone(Context ctx)
{
if (ctx.System == null) throw Errors.NoSystemError;
var zoneStr = ctx.RemainderOrNull();
if (zoneStr == null)
{
Context.SenderSystem.UiTz = "UTC";
await Systems.Save(Context.SenderSystem);
await Context.Channel.SendMessageAsync($"{Emojis.Success} System time zone cleared.");
ctx.System.UiTz = "UTC";
await _systems.Save(ctx.System);
await ctx.Reply($"{Emojis.Success} System time zone cleared.");
return;
}
var zone = await FindTimeZone(zoneStr);
var zone = await FindTimeZone(ctx, zoneStr);
if (zone == null) throw Errors.InvalidTimeZone(zoneStr);
var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone);
var msg = await Context.Channel.SendMessageAsync(
var msg = await ctx.Reply(
$"This will change the system time zone to {zone.Id}. The current time is {Formats.ZonedDateTimeFormat.Format(currentTime)}. Is this correct?");
if (!await Context.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled;
Context.SenderSystem.UiTz = zone.Id;
await Systems.Save(Context.SenderSystem);
if (!await ctx.PromptYesNo(msg)) throw Errors.TimezoneChangeCancelled;
ctx.System.UiTz = zone.Id;
await _systems.Save(ctx.System);
await Context.Channel.SendMessageAsync($"System time zone changed to {zone.Id}.");
await ctx.Reply($"System time zone changed to {zone.Id}.");
}
public async Task<DateTimeZone> FindTimeZone(string zoneStr) {
public async Task<DateTimeZone> FindTimeZone(Context ctx, string zoneStr) {
// First, if we're given a flag emoji, we extract the flag emoji code from it.
zoneStr = PluralKit.Utils.ExtractCountryFlag(zoneStr) ?? zoneStr;
@ -312,7 +287,7 @@ namespace PluralKit.Bot.Commands
return matchingZones.First();
// Otherwise, prompt and return!
return await Context.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones,
return await ctx.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones,
z =>
{
if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id))
@ -320,12 +295,6 @@ namespace PluralKit.Bot.Commands
return $"**{z.Id}**";
});
}
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

@ -6,15 +6,17 @@ using Discord;
using Discord.Commands;
using Discord.WebSocket;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot {
public static class ContextUtils {
public static async Task<bool> PromptYesNo(this ICommandContext ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) {
public static async Task<bool> PromptYesNo(this Context ctx, IUserMessage message, IUser user = null, TimeSpan? timeout = null) {
await message.AddReactionsAsync(new[] {new Emoji(Emojis.Success), new Emoji(Emojis.Error)});
var reaction = await ctx.AwaitReaction(message, user ?? ctx.User, (r) => r.Emote.Name == Emojis.Success || r.Emote.Name == Emojis.Error, timeout ?? TimeSpan.FromMinutes(1));
var reaction = await ctx.AwaitReaction(message, user ?? ctx.Author, (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) {
public static async Task<SocketReaction> AwaitReaction(this Context 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
@ -24,38 +26,38 @@ namespace PluralKit.Bot {
return Task.CompletedTask;
}
(ctx.Client as BaseSocketClient).ReactionAdded += Inner;
((BaseSocketClient) ctx.Shard).ReactionAdded += Inner;
try {
return await (tcs.Task.TimeoutAfter(timeout));
} finally {
(ctx.Client as BaseSocketClient).ReactionAdded -= Inner;
((BaseSocketClient) ctx.Shard).ReactionAdded -= Inner;
}
}
public static async Task<IUserMessage> AwaitMessage(this ICommandContext ctx, IMessageChannel channel, IUser user = null, Func<SocketMessage, bool> predicate = null, TimeSpan? timeout = null) {
public static async Task<IUserMessage> AwaitMessage(this Context 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;
((BaseSocketClient) ctx.Shard).MessageReceived -= Inner;
tcs.SetResult(msg as IUserMessage);
return Task.CompletedTask;
}
(ctx.Client as BaseSocketClient).MessageReceived += Inner;
((BaseSocketClient) ctx.Shard).MessageReceived += Inner;
return await (tcs.Task.TimeoutAfter(timeout));
}
public static async Task<bool> ConfirmWithReply(this ICommandContext ctx, string expectedReply)
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply)
{
var msg = await ctx.AwaitMessage(ctx.Channel, ctx.User, timeout: TimeSpan.FromMinutes(1));
var msg = await ctx.AwaitMessage(ctx.Channel, ctx.Author, timeout: TimeSpan.FromMinutes(1));
return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
}
public static async Task Paginate<T>(this ICommandContext ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) {
public static async Task Paginate<T>(this Context ctx, ICollection<T> items, int itemsPerPage, string title, Action<EmbedBuilder, IEnumerable<T>> renderer) {
// TODO: make this generic enough we can use it in Choose<T> below
var pageCount = (items.Count / itemsPerPage) + 1;
@ -74,7 +76,7 @@ namespace PluralKit.Bot {
try {
var currentPage = 0;
while (true) {
var reaction = await ctx.AwaitReaction(msg, ctx.User, timeout: TimeSpan.FromMinutes(5));
var reaction = await ctx.AwaitReaction(msg, ctx.Author, timeout: TimeSpan.FromMinutes(5));
// Increment/decrement page counter based on which reaction was clicked
if (reaction.Emote.Name == "\u23EA") currentPage = 0; // <<
@ -97,10 +99,10 @@ namespace PluralKit.Bot {
}
if (await ctx.HasPermission(ChannelPermission.ManageMessages)) await msg.RemoveAllReactionsAsync();
else await msg.RemoveReactionsAsync(ctx.Client.CurrentUser, botEmojis);
else await msg.RemoveReactionsAsync(ctx.Shard.CurrentUser, botEmojis);
}
public static async Task<T> Choose<T>(this ICommandContext ctx, string description, IList<T> items, Func<T, string> display = null)
public static async Task<T> Choose<T>(this Context ctx, string description, IList<T> items, Func<T, string> display = null)
{
// Generate a list of :regional_indicator_?: emoji surrogate pairs (starting at codepoint 0x1F1E6)
// We just do 7 (ABCDEFG), this amount is arbitrary (although sending a lot of emojis takes a while)
@ -143,7 +145,7 @@ namespace PluralKit.Bot {
while (true)
{
// Wait for a reaction
var reaction = await ctx.AwaitReaction(msg, ctx.User);
var reaction = await ctx.AwaitReaction(msg, ctx.Author);
// If it's a movement reaction, inc/dec the page index
if (reaction.Emote.Name == "\u2B05") currPage -= 1; // <
@ -160,7 +162,7 @@ namespace PluralKit.Bot {
if (idx < items.Count) return items[idx];
}
var __ = msg.RemoveReactionAsync(reaction.Emote, ctx.User); // don't care about awaiting
var __ = msg.RemoveReactionAsync(reaction.Emote, ctx.Author); // don't care about awaiting
await msg.ModifyAsync(mp => mp.Content = $"**[Page {currPage + 1}/{pageCount}]**\n{description}\n{MakeOptionList(currPage)}");
}
}
@ -177,12 +179,12 @@ namespace PluralKit.Bot {
var _ = AddEmojis();
// Then wait for a reaction and return whichever one we found
var reaction = await ctx.AwaitReaction(msg, ctx.User,rx => indicators.Contains(rx.Emote.Name));
var reaction = await ctx.AwaitReaction(msg, ctx.Author,rx => indicators.Contains(rx.Emote.Name));
return items[Array.IndexOf(indicators, reaction.Emote.Name)];
}
}
public static async Task<ChannelPermissions> Permissions(this ICommandContext ctx) {
public static async Task<ChannelPermissions> Permissions(this Context ctx) {
if (ctx.Channel is IGuildChannel) {
var gu = await ctx.Guild.GetCurrentUserAsync();
return gu.GetPermissions(ctx.Channel as IGuildChannel);
@ -190,9 +192,9 @@ namespace PluralKit.Bot {
return ChannelPermissions.DM;
}
public static async Task<bool> HasPermission(this ICommandContext ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission);
public static async Task<bool> HasPermission(this Context ctx, ChannelPermission permission) => (await Permissions(ctx)).Has(permission);
public static async Task BusyIndicator(this ICommandContext ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */)
public static async Task BusyIndicator(this Context ctx, Func<Task> f, string emoji = "\u23f3" /* hourglass */)
{
await ctx.BusyIndicator<object>(async () =>
{
@ -201,10 +203,13 @@ namespace PluralKit.Bot {
}, emoji);
}
public static async Task<T> BusyIndicator<T>(this ICommandContext ctx, Func<Task<T>> f, string emoji = "\u23f3" /* hourglass */)
public static async Task<T> BusyIndicator<T>(this Context ctx, Func<Task<T>> f, string emoji = "\u23f3" /* hourglass */)
{
var task = f();
// If we don't have permission to add reactions, don't bother, and just await the task normally.
if (!await ctx.HasPermission(ChannelPermission.AddReactions)) return await task;
try
{
await Task.WhenAll(ctx.Message.AddReactionAsync(new Emoji(emoji)), task);
@ -212,8 +217,8 @@ namespace PluralKit.Bot {
}
finally
{
var _ = ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Client.CurrentUser);
var _ = ctx.Message.RemoveReactionAsync(new Emoji(emoji), ctx.Shard.CurrentUser);
}
}
}
}
}

View File

@ -9,6 +9,7 @@ using PluralKit.Core;
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?
// or should we just like... go back to inlining them? at least for the one-time-use commands
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.");
@ -22,15 +23,15 @@ namespace PluralKit.Bot {
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).");
public static PKError InvalidColorError(string color) => new PKError($"\"{color.Sanitize()}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000).");
public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday.Sanitize()}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\".");
public static PKError InvalidColorError(string color) => new PKError($"\"{color.SanitizeMentions()}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000).");
public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday.SanitizeMentions()}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\".");
public static PKError ProxyMustHaveText => new PKSyntaxError("Example proxy message must contain the string 'text'.");
public static PKError ProxyMultipleText => new PKSyntaxError("Example proxy message must contain the string 'text' exactly once.");
public static PKError MemberDeleteCancelled => new PKError($"Member deletion cancelled. Stay safe! {Emojis.ThumbsUp}");
public static PKError AvatarServerError(HttpStatusCode statusCode) => new PKError($"Server responded with status code {(int) statusCode}, are you sure your link is working?");
public static PKError AvatarFileSizeLimit(long size) => new PKError($"File size too large ({size.Bytes().ToString("#.#")} > {Limits.AvatarFileSizeLimit.Bytes().ToString("#.#")}), try shrinking or compressing the image.");
public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType.Sanitize()})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif).");
public static PKError AvatarNotAnImage(string mimeType) => new PKError($"The given link does not point to an image{(mimeType != null ? $" ({mimeType.SanitizeMentions()})" : "")}. Make sure you're using a direct link (ending in .jpg, .png, .gif).");
public static PKError AvatarDimensionsTooLarge(int width, int height) => new PKError($"Image too large ({width}x{height} > {Limits.AvatarDimensionLimit}x{Limits.AvatarDimensionLimit}), try resizing the image.");
public static PKError UserHasNoAvatar => new PKError("The given user has no avatar set.");
public static PKError InvalidUrl(string url) => new PKError($"The given URL is invalid.");
@ -38,43 +39,45 @@ namespace PluralKit.Bot {
public static PKError AccountAlreadyLinked => new PKError("That account is already linked to your system.");
public static PKError AccountNotLinked => new PKError("That account isn't linked to your system.");
public static PKError AccountInOtherSystem(PKSystem system) => new PKError($"The mentioned account is already linked to another system (see `pk;system {system.Hid}`).");
public static PKError UnlinkingLastAccount => new PKError("Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less).");
public static PKError UnlinkingLastAccount => new PKError("Since this is the only account linked to this system, you cannot unlink it (as that would leave your system account-less). If you would like to delete your system, use `pk;system delete`.");
public static PKError MemberLinkCancelled => new PKError("Member link cancelled.");
public static PKError MemberUnlinkCancelled => new PKError("Member unlink cancelled.");
public static PKError SameSwitch(ICollection<PKMember> members)
{
if (members.Count == 0) return new PKError("There's already no one in front.");
if (members.Count == 1) return new PKError($"Member {members.First().Name.Sanitize()} is already fronting.");
return new PKError($"Members {string.Join(", ", members.Select(m => m.Name.Sanitize()))} are already fronting.");
if (members.Count == 1) return new PKError($"Member {members.First().Name.SanitizeMentions()} is already fronting.");
return new PKError($"Members {string.Join(", ", members.Select(m => m.Name.SanitizeMentions()))} are already fronting.");
}
public static PKError DuplicateSwitchMembers => new PKError("Duplicate members in member list.");
public static PKError SwitchMemberNotInSystem => new PKError("One or more switch members aren't in your own system.");
public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str.Sanitize()}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago).");
public static PKError InvalidDateTime(string str) => new PKError($"Could not parse '{str.SanitizeMentions()}' as a valid date/time. Try using a syntax such as \"May 21, 12:30 PM\" or \"3d12h\" (ie. 3 days, 12 hours ago).");
public static PKError SwitchTimeInFuture => new PKError("Can't move switch to a time in the future.");
public static PKError NoRegisteredSwitches => new PKError("There are no registered switches for this system.");
public static PKError SwitchMoveBeforeSecondLast(ZonedDateTime time) => new PKError($"Can't move switch to before last switch time ({Formats.ZonedDateTimeFormat.Format(time)}), as it would cause conflicts.");
public static PKError SwitchMoveCancelled => new PKError("Switch move cancelled.");
public static PKError SwitchDeleteCancelled => new PKError("Switch deletion cancelled.");
public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone.Sanitize()}. Offset must be a value like 'UTC+5' or 'GMT-4:30'.");
public static PKError TimezoneParseError(string timezone) => new PKError($"Could not parse timezone offset {timezone.SanitizeMentions()}. Offset must be a value like 'UTC+5' or 'GMT-4:30'.");
public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr.Sanitize()}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>");
public static PKError InvalidTimeZone(string zoneStr) => new PKError($"Invalid time zone ID '{zoneStr.SanitizeMentions()}'. To find your time zone ID, use the following website: <https://xske.github.io/tz>");
public static PKError TimezoneChangeCancelled => new PKError("Time zone change cancelled.");
public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr.Sanitize()}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>");
public static PKError AmbiguousTimeZone(string zoneStr, int count) => new PKError($"The time zone query '{zoneStr.SanitizeMentions()}' resulted in **{count}** different time zone regions. Try being more specific - e.g. pass an exact time zone specifier from the following website: <https://xske.github.io/tz>");
public static PKError NoImportFilePassed => new PKError("You must either pass an URL to a file as a command parameter, or as an attachment to the message containing the command.");
public static PKError InvalidImportFile => new PKError("Imported data file invalid. Make sure this is a .json file directly exported from PluralKit or Tupperbox.");
public static PKError ImportCancelled => new PKError("Import cancelled.");
public static PKError MessageNotFound(ulong id) => new PKError($"Message with ID '{id}' not found. Are you sure it's a message proxied by PluralKit?");
public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr.Sanitize()}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`.");
public static PKError DurationParseError(string durationStr) => new PKError($"Could not parse '{durationStr.SanitizeMentions()}' as a valid duration. Try a format such as `30d`, `1d3h` or `20m30s`.");
public static PKError FrontPercentTimeInFuture => new PKError("Cannot get the front percent between now and a time in the future.");
public static PKError GuildNotFound(ulong guildId) => new PKError($"Guild with ID {guildId} not found.");
public static PKError DisplayNameTooLong(string displayName, int maxLength) => new PKError(
$"Display name too long ({displayName.Length} > {maxLength} characters). Use a shorter display name, or shorten your system tag.");
public static PKError ProxyNameTooShort(string name) => new PKError($"The webhook's name, `{name.SanitizeMentions()}`, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.");
public static PKError ProxyNameTooLong(string name) => new PKError($"The webhook's name, {name.SanitizeMentions()}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name or use a shorter system tag.");
}
}

View File

@ -10,12 +10,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Discord.Net.Commands" Version="2.1.1" />
<PackageReference Include="Discord.Net.Webhook" Version="2.1.1" />
<PackageReference Include="Discord.Net.WebSocket" Version="2.1.1" />
<PackageReference Include="Humanizer.Core" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.0.0" />
<PackageReference Include="Sentry" Version="2.0.0-beta2" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.0-beta0006" />
</ItemGroup>
</Project>

View File

@ -1,33 +0,0 @@
using System;
using System.Threading.Tasks;
using Discord.Commands;
namespace PluralKit.Bot {
class MustHaveSystem : PreconditionAttribute
{
public override Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
{
var c = context as PKCommandContext;
if (c == null) return Task.FromResult(PreconditionResult.FromError("Must be called on a PKCommandContext (should never happen!)")) ;
if (c.SenderSystem == null) return Task.FromResult(PreconditionResult.FromError(Errors.NoSystemError));
return Task.FromResult(PreconditionResult.FromSuccess());
}
}
class MustPassOwnMember : PreconditionAttribute
{
public override 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.SenderSystem == null) return Task.FromResult(PreconditionResult.FromError(Errors.NoSystemError));
if (c.GetContextEntity<PKMember>() == null) return Task.FromResult(PreconditionResult.FromError(Errors.MissingMemberError));
if (c.GetContextEntity<PKMember>().System != c.SenderSystem.Id) return Task.FromResult(PreconditionResult.FromError(Errors.NotOwnMemberError));
return Task.FromResult(PreconditionResult.FromSuccess());
}
}
}

View File

@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using Discord.WebSocket;
using Humanizer;
using NodaTime;
@ -33,8 +36,8 @@ namespace PluralKit.Bot {
.WithColor(Color.Blue)
.WithTitle(system.Name ?? null)
.WithThumbnailUrl(system.AvatarUrl ?? null)
.WithFooter($"System ID: {system.Hid}");
.WithFooter($"System ID: {system.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(system.Created.InZone(system.Zone))}");
var latestSwitch = await _switches.GetLatestSwitch(system);
if (latestSwitch != null)
{
@ -69,7 +72,18 @@ namespace PluralKit.Bot {
var name = member.Name;
if (system.Name != null) name = $"{member.Name} ({system.Name})";
var color = member.Color?.ToDiscordColor() ?? Color.Default;
Color color;
try
{
color = member.Color?.ToDiscordColor() ?? Color.Default;
}
catch (ArgumentException)
{
// Bad API use can cause an invalid color string
// TODO: fix that in the API
// for now we just default to a blank color, yolo
color = Color.Default;
}
var messageCount = await _members.MessageCount(member);
@ -77,7 +91,7 @@ namespace PluralKit.Bot {
// TODO: add URL of website when that's up
.WithAuthor(name, member.AvatarUrl)
.WithColor(color)
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid}");
.WithFooter($"System ID: {system.Hid} | Member ID: {member.Hid} | Created on {Formats.ZonedDateTimeFormat.Format(member.Created.InZone(system.Zone))}");
if (member.AvatarUrl != null) eb.WithThumbnailUrl(member.AvatarUrl);
@ -139,23 +153,44 @@ namespace PluralKit.Bot {
public async Task<Embed> CreateMessageInfoEmbed(MessageStore.StoredMessage msg)
{
var channel = (ITextChannel) await _client.GetChannelAsync(msg.Message.Channel);
var serverMsg = await channel.GetMessageAsync(msg.Message.Mid);
var channel = await _client.GetChannelAsync(msg.Message.Channel) as ITextChannel;
var serverMsg = channel != null ? await channel.GetMessageAsync(msg.Message.Mid) : null;
var memberStr = $"{msg.Member.Name} (`{msg.Member.Hid}`)";
if (msg.Member.Pronouns != null) memberStr += $"\n*(pronouns: **{msg.Member.Pronouns}**)*";
var user = await _client.GetUserAsync(msg.Message.Sender);
var userStr = user.NameAndMention() ?? $"*(deleted user {msg.Message.Sender})*";
var userStr = $"*(deleted user {msg.Message.Sender})*";
ICollection<IRole> roles = null;
return new EmbedBuilder()
if (channel != null)
{
// Look up the user with the REST client
// this ensures we'll still get the information even if the user's not cached,
// even if this means an extra API request (meh, it'll be fine)
var shard = ((DiscordShardedClient) _client).GetShardFor(channel.Guild);
var guildUser = await shard.Rest.GetGuildUserAsync(channel.Guild.Id, msg.Message.Sender);
if (guildUser != null)
{
roles = guildUser.RoleIds
.Select(roleId => channel.Guild.GetRole(roleId))
.Where(role => role.Name != "@everyone")
.OrderByDescending(role => role.Position)
.ToList();
userStr = guildUser.Nickname != null ? $"**Username:** {guildUser?.NameAndMention()}\n**Nickname:** {guildUser.Nickname}" : guildUser?.NameAndMention();
}
}
var eb = new EmbedBuilder()
.WithAuthor(msg.Member.Name, msg.Member.AvatarUrl)
.WithDescription(serverMsg?.Content ?? "*(message contents deleted or inaccessible)*")
.AddField("System", msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)
.AddField("System",
msg.System.Name != null ? $"{msg.System.Name} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`", true)
.AddField("Member", memberStr, true)
.AddField("Sent by", userStr, inline: true)
.WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid))
.Build();
.WithTimestamp(SnowflakeUtils.FromSnowflake(msg.Message.Mid));
if (roles != null) eb.AddField($"Account roles ({roles.Count})", string.Join(", ", roles.Select(role => role.Name)));
return eb.Build();
}
public Task<Embed> CreateFrontPercentEmbed(SwitchStore.PerMemberSwitchDuration frontpercent, DateTimeZone tz)

View File

@ -24,9 +24,11 @@ namespace PluralKit.Bot
private WebhookCacheService _webhookCache;
private DbConnectionCountHolder _countHolder;
private ILogger _logger;
public PeriodicStatCollector(IDiscordClient client, IMetrics metrics, SystemStore systems, MemberStore members, SwitchStore switches, MessageStore messages, ILogger logger, WebhookCacheService webhookCache)
public PeriodicStatCollector(IDiscordClient client, IMetrics metrics, SystemStore systems, MemberStore members, SwitchStore switches, MessageStore messages, ILogger logger, WebhookCacheService webhookCache, DbConnectionCountHolder countHolder)
{
_client = (DiscordShardedClient) client;
_metrics = metrics;
@ -35,6 +37,7 @@ namespace PluralKit.Bot
_switches = switches;
_messages = messages;
_webhookCache = webhookCache;
_countHolder = countHolder;
_logger = logger.ForContext<PeriodicStatCollector>();
}
@ -46,6 +49,7 @@ namespace PluralKit.Bot
// Aggregate guild/channel stats
_metrics.Measure.Gauge.SetValue(BotMetrics.Guilds, _client.Guilds.Count);
_metrics.Measure.Gauge.SetValue(BotMetrics.Channels, _client.Guilds.Sum(g => g.TextChannels.Count));
_metrics.Measure.Gauge.SetValue(BotMetrics.ShardsConnected, _client.Shards.Count(shard => shard.ConnectionState == ConnectionState.Connected));
// Aggregate member stats
var usersKnown = new HashSet<ulong>();
@ -75,6 +79,9 @@ namespace PluralKit.Bot
_metrics.Measure.Gauge.SetValue(CoreMetrics.ProcessHandles, process.HandleCount);
_metrics.Measure.Gauge.SetValue(CoreMetrics.CpuUsage, await EstimateCpuUsage());
// Database info
_metrics.Measure.Gauge.SetValue(CoreMetrics.DatabaseConnections, _countHolder.ConnectionCount);
// Other shiz
_metrics.Measure.Gauge.SetValue(BotMetrics.WebhookCacheSize, _webhookCache.CacheSize);

View File

@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Extensions.Caching.Memory;
using Serilog;
namespace PluralKit.Bot
{
public class ProxyCacheService
{
public class ProxyDatabaseResult
{
public PKSystem System;
public PKMember Member;
}
private DbConnectionFactory _conn;
private IMemoryCache _cache;
private ILogger _logger;
public ProxyCacheService(DbConnectionFactory conn, IMemoryCache cache, ILogger logger)
{
_conn = conn;
_cache = cache;
_logger = logger;
}
public Task<IEnumerable<ProxyDatabaseResult>> GetResultsFor(ulong account)
{
_logger.Debug("Looking up members for account {Account} in cache...", account);
return _cache.GetOrCreateAsync(GetKey(account), (entry) => FetchResults(account, entry));
}
public void InvalidateResultsFor(ulong account)
{
_logger.Information("Invalidating proxy cache for account {Account}", account);
_cache.Remove(GetKey(account));
}
public async Task InvalidateResultsForSystem(PKSystem system)
{
_logger.Information("Invalidating proxy cache for system {System}", system.Id);
using (var conn = await _conn.Obtain())
foreach (var accountId in await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", system))
_cache.Remove(GetKey(accountId));
}
private async Task<IEnumerable<ProxyDatabaseResult>> FetchResults(ulong account, ICacheEntry entry)
{
_logger.Information("Members for account {Account} not in cache, fetching", account);
using (var conn = await _conn.Obtain())
{
var results = (await conn.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 = account})).ToList();
if (results.Count == 0)
{
// Long expiry for accounts with no system registered
entry.SetSlidingExpiration(TimeSpan.FromMinutes(5));
entry.SetAbsoluteExpiration(TimeSpan.FromHours(1));
}
else
{
// Shorter expiry if they already have a system
entry.SetSlidingExpiration(TimeSpan.FromMinutes(1));
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
}
return results;
}
}
private object GetKey(ulong account)
{
return $"_proxy_account_{account}";
}
}
}

View File

@ -2,24 +2,18 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using App.Metrics;
using Dapper;
using Discord;
using Discord.Net;
using Discord.Webhook;
using Discord.WebSocket;
using PluralKit.Core;
using Serilog;
namespace PluralKit.Bot
{
class ProxyDatabaseResult
{
public PKSystem System;
public PKMember Member;
}
class ProxyMatch {
public PKMember Member;
public PKSystem System;
@ -28,38 +22,36 @@ namespace PluralKit.Bot
class ProxyService: IDisposable {
private IDiscordClient _client;
private DbConnectionFactory _conn;
private LogChannelService _logChannel;
private WebhookCacheService _webhookCache;
private MessageStore _messageStorage;
private EmbedService _embeds;
private IMetrics _metrics;
private ILogger _logger;
private WebhookExecutorService _webhookExecutor;
private ProxyCacheService _cache;
private HttpClient _httpClient;
public ProxyService(IDiscordClient client, WebhookCacheService webhookCache, DbConnectionFactory conn, LogChannelService logChannel, MessageStore messageStorage, EmbedService embeds, IMetrics metrics, ILogger logger)
public ProxyService(IDiscordClient client, LogChannelService logChannel, MessageStore messageStorage, EmbedService embeds, ILogger logger, ProxyCacheService cache, WebhookExecutorService webhookExecutor)
{
_client = client;
_webhookCache = webhookCache;
_conn = conn;
_logChannel = logChannel;
_messageStorage = messageStorage;
_embeds = embeds;
_metrics = metrics;
_cache = cache;
_webhookExecutor = webhookExecutor;
_logger = logger.ForContext<ProxyService>();
_httpClient = new HttpClient();
}
private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyDatabaseResult> potentials)
private ProxyMatch GetProxyTagMatch(string message, IEnumerable<ProxyCacheService.ProxyDatabaseResult> potentials)
{
// If the message starts with a @mention, and then proceeds to have proxy tags,
// extract the mention and place it inside the inner message
// eg. @Ske [text] => [@Ske text]
int matchStartPosition = 0;
string leadingMention = null;
if (Utils.HasMentionPrefix(message, ref matchStartPosition))
if (Utils.HasMentionPrefix(message, ref matchStartPosition, out _))
{
leadingMention = message.Substring(0, matchStartPosition);
message = message.Substring(matchStartPosition);
@ -87,16 +79,9 @@ namespace PluralKit.Bot
public async Task HandleMessageAsync(IMessage message)
{
// Bail early if this isn't in a guild channel
if (!(message.Channel is IGuildChannel)) return;
if (!(message.Channel is ITextChannel)) return;
IEnumerable<ProxyDatabaseResult> results;
using (var conn = await _conn.Obtain())
{
results = await conn.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});
}
var results = await _cache.GetResultsFor(message.Author.Id);
// Find a member with proxy tags matching the message
var match = GetProxyTagMatch(message.Content, results);
@ -109,15 +94,25 @@ namespace PluralKit.Bot
// Can't proxy a message with no content and no attachment
if (match.InnerText.Trim().Length == 0 && message.Attachments.Count == 0)
return;
// Get variables in order and all
var proxyName = match.Member.ProxyName(match.System.Tag);
var avatarUrl = match.Member.AvatarUrl ?? match.System.AvatarUrl;
// If the name's too long (or short), bail
if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName);
if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName);
// Sanitize @everyone, but only if the original user wouldn't have permission to
var messageContents = SanitizeEveryoneMaybe(message, match.InnerText);
// Fetch a webhook for this channel, and send the proxied message
var webhook = await _webhookCache.GetWebhook(message.Channel as ITextChannel);
var avatarUrl = match.Member.AvatarUrl ?? match.System.AvatarUrl;
var proxyName = match.Member.ProxyName(match.System.Tag);
var hookMessageId = await ExecuteWebhook(webhook, messageContents, proxyName, avatarUrl, message.Attachments.FirstOrDefault());
// Execute the webhook itself
var hookMessageId = await _webhookExecutor.ExecuteWebhook(
(ITextChannel) message.Channel,
proxyName, avatarUrl,
messageContents,
message.Attachments.FirstOrDefault()
);
// Store the message in the database, and log it in the log channel (if applicable)
await _messageStorage.Store(message.Author.Id, hookMessageId, message.Channel.Id, message.Id, match.Member);
@ -129,7 +124,12 @@ namespace PluralKit.Bot
try
{
await message.DeleteAsync();
} catch (HttpException) {} // If it's already deleted, we just swallow the exception
}
catch (HttpException)
{
// If it's already deleted, we just log and swallow the exception
_logger.Warning("Attempted to delete already deleted proxy trigger message {Message}", message.Id);
}
}
private static string SanitizeEveryoneMaybe(IMessage message, string messageContents)
@ -146,6 +146,7 @@ namespace PluralKit.Bot
if (!permissions.ManageWebhooks)
{
// todo: PKError-ify these
await channel.SendMessageAsync(
$"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages. Please contact a server administrator to remedy this.");
return false;
@ -161,68 +162,6 @@ namespace PluralKit.Bot
return true;
}
private async Task<ulong> ExecuteWebhook(IWebhook webhook, string text, string username, string avatarUrl, IAttachment attachment)
{
username = FixClyde(username);
// TODO: DiscordWebhookClient's ctor does a call to GetWebhook that may be unnecessary, see if there's a way to do this The Hard Way :tm:
// TODO: this will probably crash if there are multiple consecutive failures, perhaps have a loop instead?
DiscordWebhookClient client;
try
{
client = new DiscordWebhookClient(webhook);
}
catch (InvalidOperationException)
{
// TODO: does this leak internal stuff in the (now-invalid) client?
// webhook was deleted or invalid
webhook = await _webhookCache.InvalidateAndRefreshWebhook(webhook);
client = new DiscordWebhookClient(webhook);
}
// TODO: clean this entire block up
using (client)
{
ulong messageId;
try
{
if (attachment != null)
{
using (var stream = await _httpClient.GetStreamAsync(attachment.Url))
{
messageId = await client.SendFileAsync(stream, filename: attachment.Filename, text: text,
username: username, avatarUrl: avatarUrl);
}
}
else
{
messageId = await client.SendMessageAsync(text, username: username, avatarUrl: avatarUrl);
}
_logger.Information("Invoked webhook {Webhook} in channel {Channel}", webhook.Id,
webhook.ChannelId);
// Log it in the metrics
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied, "success");
}
catch (HttpException e)
{
_logger.Warning(e, "Error invoking webhook {Webhook} in channel {Channel}", webhook.Id,
webhook.ChannelId);
// Log failure in metrics and rethrow (we still need to cancel everything else)
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied, "failure");
throw;
}
// TODO: figure out a way to return the full message object (without doing a GetMessageAsync call, which
// doesn't work if there's no permission to)
return messageId;
}
}
public Task HandleReactionAddedAsync(Cacheable<IUserMessage, ulong> message, ISocketMessageChannel channel, SocketReaction reaction)
{
// Dispatch on emoji
@ -281,6 +220,10 @@ namespace PluralKit.Bot
public async Task HandleMessageDeletedAsync(Cacheable<IMessage, ulong> message, ISocketMessageChannel channel)
{
// Don't delete messages from the store if they aren't webhooks
// Non-webhook messages will never be stored anyway.
// If we're not sure (eg. message outside of cache), delete just to be sure.
if (message.HasValue && !message.Value.Author.IsWebhook) return;
await _messageStorage.Delete(message.Id);
}
@ -290,16 +233,6 @@ namespace PluralKit.Bot
await _messageStorage.BulkDelete(messages.Select(m => m.Id).ToList());
}
private string FixClyde(string name)
{
var match = Regex.Match(name, "clyde", RegexOptions.IgnoreCase);
if (!match.Success) return name;
// Put a hair space (\u200A) between the "c" and the "lyde" in the match to avoid Discord matching it
// since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
return name.Substring(0, match.Index + 1) + '\u200A' + name.Substring(match.Index + 1);
}
public void Dispose()
{
_httpClient.Dispose();

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Discord;
using Discord.Webhook;
using Serilog;
namespace PluralKit.Bot
@ -41,7 +43,7 @@ namespace PluralKit.Bot
// It's possible to "move" a webhook to a different channel after creation
// Here, we ensure it's actually still pointing towards the proper channel, and if not, wipe and refetch one.
var webhook = await lazyWebhookValue.Value;
if (webhook.Channel.Id != channel.Id) return await InvalidateAndRefreshWebhook(webhook);
if (webhook.ChannelId != channel.Id) return await InvalidateAndRefreshWebhook(webhook);
return webhook;
}
@ -50,22 +52,35 @@ namespace PluralKit.Bot
_logger.Information("Refreshing webhook for channel {Channel}", webhook.ChannelId);
_webhooks.TryRemove(webhook.ChannelId, out _);
return await GetWebhook(webhook.ChannelId);
return await GetWebhook(webhook.Channel);
}
private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel) =>
await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel);
private async Task<IWebhook> GetOrCreateWebhook(ITextChannel channel)
{
_logger.Debug("Webhook for channel {Channel} not found in cache, trying to fetch", channel.Id);
return await FindExistingWebhook(channel) ?? await DoCreateWebhook(channel);
}
private async Task<IWebhook> FindExistingWebhook(ITextChannel channel)
{
_logger.Debug("Finding webhook for channel {Channel}", channel.Id);
return (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine);
try
{
return (await channel.GetWebhooksAsync()).FirstOrDefault(IsWebhookMine);
}
catch (HttpRequestException e)
{
_logger.Warning(e, "Error occurred while fetching webhook list");
// This happens sometimes when Discord returns a malformed request for the webhook list
// Nothing we can do than just assume that none exist and return null.
return null;
}
}
private async Task<IWebhook> DoCreateWebhook(ITextChannel channel)
private Task<IWebhook> DoCreateWebhook(ITextChannel channel)
{
_logger.Information("Creating new webhook for channel {Channel}", channel.Id);
return await channel.CreateWebhookAsync(WebhookName);
return channel.CreateWebhookAsync(WebhookName);
}
private bool IsWebhookMine(IWebhook arg) => arg.Creator.Id == _client.CurrentUser.Id && arg.Name == WebhookName;

View File

@ -0,0 +1,133 @@
using System;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using App.Metrics;
using App.Metrics.Logging;
using Discord;
using Discord.Net;
using Discord.Webhook;
using Microsoft.Extensions.Caching.Memory;
using Serilog;
namespace PluralKit.Bot
{
public class WebhookExecutorService: IDisposable
{
private WebhookCacheService _webhookCache;
private IMemoryCache _cache;
private ILogger _logger;
private IMetrics _metrics;
private HttpClient _client;
public WebhookExecutorService(IMemoryCache cache, IMetrics metrics, WebhookCacheService webhookCache, ILogger logger)
{
_cache = cache;
_metrics = metrics;
_webhookCache = webhookCache;
_logger = logger.ForContext<WebhookExecutorService>();
_client = new HttpClient();
}
public async Task<ulong> ExecuteWebhook(ITextChannel channel, string name, string avatarUrl, string content, IAttachment attachment)
{
_logger.Debug("Invoking webhook in channel {Channel}", channel.Id);
// Get a webhook, execute it
var webhook = await _webhookCache.GetWebhook(channel);
var id = await ExecuteWebhookInner(webhook, name, avatarUrl, content, attachment);
// Log the relevant metrics
_metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied);
_logger.Information("Invoked webhook {Webhook} in channel {Channel}", webhook.Id,
channel.Id);
return id;
}
private async Task<ulong> ExecuteWebhookInner(IWebhook webhook, string name, string avatarUrl, string content,
IAttachment attachment, bool hasRetried = false)
{
var client = await GetClientFor(webhook);
try
{
// If we have an attachment, use the special SendFileAsync method
if (attachment != null)
using (var attachmentStream = await _client.GetStreamAsync(attachment.Url))
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
return await client.SendFileAsync(attachmentStream, attachment.Filename, content,
username: FixClyde(name),
avatarUrl: avatarUrl);
// Otherwise, send normally
return await client.SendMessageAsync(content, username: FixClyde(name), avatarUrl: avatarUrl);
}
catch (HttpException e)
{
// If we hit an error, just retry (if we haven't already)
if (e.DiscordCode == 10015 && !hasRetried) // Error 10015 = "Unknown Webhook"
{
_logger.Warning(e, "Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId);
return await ExecuteWebhookInner(await _webhookCache.InvalidateAndRefreshWebhook(webhook), name, avatarUrl, content, attachment, hasRetried: true);
}
throw;
}
}
private async Task<DiscordWebhookClient> GetClientFor(IWebhook webhook)
{
_logger.Debug("Looking for client for webhook {Webhook} in cache", webhook.Id);
return await _cache.GetOrCreateAsync($"_webhook_client_{webhook.Id}",
(entry) => MakeCachedClientFor(entry, webhook));
}
private Task<DiscordWebhookClient> MakeCachedClientFor(ICacheEntry entry, IWebhook webhook) {
_logger.Information("Client for {Webhook} not found in cache, creating", webhook.Id);
// Define expiration for the client cache
// 10 minutes *without a query* and it gets yoten
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
// IMemoryCache won't automatically dispose of its values when the cache gets evicted
// so we register a hook to do so here.
entry.RegisterPostEvictionCallback((key, value, reason, state) => (value as IDisposable)?.Dispose());
// DiscordWebhookClient has a sync network call in its constructor (!!!)
// and we want to punt that onto a task queue, so we do that.
return Task.Run(async () =>
{
try
{
return new DiscordWebhookClient(webhook);
}
catch (InvalidOperationException)
{
// TODO: does this leak stuff inside the DiscordWebhookClient created above?
// Webhook itself was found in cache, but has been deleted on the channel
// We request a new webhook instead
return new DiscordWebhookClient(await _webhookCache.InvalidateAndRefreshWebhook(webhook));
}
});
}
private string FixClyde(string name)
{
// Check if the name contains "Clyde" - if not, do nothing
var match = Regex.Match(name, "clyde", RegexOptions.IgnoreCase);
if (!match.Success) return name;
// Put a hair space (\u200A) between the "c" and the "lyde" in the match to avoid Discord matching it
// since Discord blocks webhooks containing the word "Clyde"... for some reason. /shrug
return name.Substring(0, match.Index + 1) + '\u200A' + name.Substring(match.Index + 1);
}
public void Dispose()
{
_client.Dispose();
}
}
}

View File

@ -5,10 +5,7 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
using Discord.Commands.Builders;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using PluralKit.Core;
using Image = SixLabors.ImageSharp.Image;
@ -68,20 +65,29 @@ namespace PluralKit.Bot
}
}
public static bool HasMentionPrefix(string content, ref int argPos)
public static bool HasMentionPrefix(string content, ref int argPos, out ulong mentionId)
{
mentionId = 0;
// Roughly ported from Discord.Commands.MessageExtensions.HasMentionPrefix
if (string.IsNullOrEmpty(content) || content.Length <= 3 || (content[0] != '<' || content[1] != '@'))
return false;
int num = content.IndexOf('>');
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out _))
if (num == -1 || content.Length < num + 2 || content[num + 1] != ' ' || !MentionUtils.TryParseUser(content.Substring(0, num + 1), out mentionId))
return false;
argPos = num + 2;
return true;
}
public static string Sanitize(this string input) =>
Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\\@$1>"), "@(everyone|here)", "@\u200B$1");
public static bool TryParseMention(this string potentialMention, out ulong id)
{
if (ulong.TryParse(potentialMention, out id)) return true;
if (MentionUtils.TryParseUser(potentialMention, out id)) return true;
return false;
}
public static string SanitizeMentions(this string input) =>
Regex.Replace(Regex.Replace(input, "<@[!&]?(\\d{17,19})>", "<\u200B@$1>"), "@(everyone|here)", "@\u200B$1");
public static string SanitizeEveryone(this string input) =>
Regex.Replace(input, "@(everyone|here)", "@\u200B$1");
@ -113,136 +119,9 @@ namespace PluralKit.Bot
(await PermissionsIn(channel)).Has(permission);
}
class PKSystemTypeReader : TypeReader
{
public override async Task<TypeReaderResult> ReadAsync(ICommandContext context, string input, IServiceProvider services)
{
var client = services.GetService<IDiscordClient>();
var systems = services.GetService<SystemStore>();
// 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, systems);
// Then, try mention parsing.
if (MentionUtils.TryParseUser(input, out var idFromMention)) return await FindSystemByAccountHelper(idFromMention, client, systems);
// Finally, try HID parsing
var res = await systems.GetByHid(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, SystemStore systems)
{
var foundByAccountId = await systems.GetByAccount(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 members = services.GetRequiredService<MemberStore>();
// 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 members.GetByName(ctx.SenderSystem, 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 members.GetByHid(input);
if (foundByHid != null) return TypeReaderResult.FromSuccess(foundByHid);
return TypeReaderResult.FromError(CommandError.ObjectNotFound, $"Member with ID `{input}` not found.");
}
}
/// Subclass of ICommandContext with PK-specific additional fields and functionality
public class PKCommandContext : ShardedCommandContext
{
public PKSystem SenderSystem { get; }
public IServiceProvider ServiceProvider { get; }
private object _entity;
public PKCommandContext(DiscordShardedClient client, SocketUserMessage msg, PKSystem system, IServiceProvider serviceProvider) : base(client, msg)
{
SenderSystem = system;
ServiceProvider = serviceProvider;
}
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 Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services)
{
// This stops the "delegating command" we define above from being called multiple times
// If we've already added a context object to the context, then we'll return with the same
// error you get when there's an invalid command - it's like it didn't exist
// This makes sure the user gets the proper error, instead of the command trying to parse things weirdly
if ((context as PKCommandContext)?.GetContextEntity<object>() == null) return Task.FromResult(PreconditionResult.FromSuccess());
return Task.FromResult(PreconditionResult.FromError(command.Module.Service.Search("<unknown>")));
}
}
/// <summary>
/// An exception class representing user-facing errors caused when parsing and executing commands.
/// </summary>
public class PKError : Exception
{
public PKError(string message) : base(message)
@ -250,6 +129,10 @@ namespace PluralKit.Bot
}
}
/// <summary>
/// A subclass of <see cref="PKError"/> that represent command syntax errors, meaning they'll have their command
/// usages printed in the message.
/// </summary>
public class PKSyntaxError : PKError
{
public PKSyntaxError(string message) : base(message)

View File

@ -1,6 +1,7 @@
using App.Metrics;
using App.Metrics.Gauge;
using App.Metrics.Meter;
using App.Metrics.Timer;
namespace PluralKit.Core
{
@ -17,5 +18,9 @@ namespace PluralKit.Core
public static GaugeOptions ProcessThreads => new GaugeOptions { Name = "Process Thread Count", MeasurementUnit = Unit.Threads, Context = "Process" };
public static GaugeOptions ProcessHandles => new GaugeOptions { Name = "Process Handle Count", MeasurementUnit = Unit.Items, Context = "Process" };
public static GaugeOptions CpuUsage => new GaugeOptions { Name = "CPU Usage", MeasurementUnit = Unit.Percent, Context = "Process" };
public static MeterOptions DatabaseRequests => new MeterOptions() { Name = "Database Requests", MeasurementUnit = Unit.Requests, Context = "Database", RateUnit = TimeUnit.Seconds};
public static TimerOptions DatabaseQuery => new TimerOptions() { Name = "Database Query", MeasurementUnit = Unit.Requests, DurationUnit = TimeUnit.Seconds, RateUnit = TimeUnit.Seconds, Context = "Database" };
public static GaugeOptions DatabaseConnections => new GaugeOptions() { Name = "Database Connections", MeasurementUnit = Unit.Connections, Context = "Database" };
}
}

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using NodaTime;
using NodaTime.Text;
using Serilog;
namespace PluralKit.Bot
@ -24,11 +26,34 @@ namespace PluralKit.Bot
public async Task<DataFileSystem> ExportSystem(PKSystem system)
{
// Export members
var members = new List<DataFileMember>();
foreach (var member in await _members.GetBySystem(system)) members.Add(await ExportMember(member));
var pkMembers = await _members.GetBySystem(system); // Read all members in the system
var messageCounts = await _members.MessageCountsPerMember(system); // Count messages proxied by all members in the system
members.AddRange(pkMembers.Select(m => new DataFileMember
{
Id = m.Hid,
Name = m.Name,
DisplayName = m.DisplayName,
Description = m.Description,
Birthday = m.Birthday != null ? Formats.DateExportFormat.Format(m.Birthday.Value) : null,
Pronouns = m.Pronouns,
Color = m.Color,
AvatarUrl = m.AvatarUrl,
Prefix = m.Prefix,
Suffix = m.Suffix,
Created = Formats.TimestampExportFormat.Format(m.Created),
MessageCount = messageCounts.Where(x => x.Member.Equals(m.Id)).Select(x => x.MessageCount).FirstOrDefault()
}));
// Export switches
var switches = new List<DataFileSwitch>();
foreach (var sw in await _switches.GetSwitches(system, 999999)) switches.Add(await ExportSwitch(sw));
var switchList = await _switches.GetTruncatedSwitchList(system, Instant.FromDateTimeUtc(DateTime.MinValue.ToUniversalTime()), SystemClock.Instance.GetCurrentInstant());
switches.AddRange(switchList.Select(x => new DataFileSwitch
{
Timestamp = Formats.TimestampExportFormat.Format(x.TimespanStart),
Members = x.Members.Select(m => m.Hid).ToList() // Look up member's HID using the member export from above
}));
return new DataFileSystem
{
@ -49,6 +74,7 @@ namespace PluralKit.Bot
{
Id = member.Hid,
Name = member.Name,
DisplayName = member.DisplayName,
Description = member.Description,
Birthday = member.Birthday != null ? Formats.DateExportFormat.Format(member.Birthday.Value) : null,
Pronouns = member.Pronouns,
@ -72,6 +98,7 @@ namespace PluralKit.Bot
// which probably means refactoring SystemStore.Save and friends etc
var result = new ImportResult {AddedNames = new List<string>(), ModifiedNames = new List<string>()};
var hidMapping = new Dictionary<string, PKMember>();
// If we don't already have a system to save to, create one
if (system == null) system = await _systems.Create(data.Name);
@ -115,8 +142,13 @@ namespace PluralKit.Bot
result.ModifiedNames.Add(dataMember.Name);
}
// Keep track of what the data file's member ID maps to for switch import
if (!hidMapping.ContainsKey(dataMember.Id))
hidMapping.Add(dataMember.Id, member);
// Apply member info
member.Name = dataMember.Name;
if (dataMember.DisplayName != null) member.DisplayName = dataMember.DisplayName;
if (dataMember.Description != null) member.Description = dataMember.Description;
if (dataMember.Color != null) member.Color = dataMember.Color;
if (dataMember.AvatarUrl != null) member.AvatarUrl = dataMember.AvatarUrl;
@ -134,10 +166,22 @@ namespace PluralKit.Bot
await _members.Save(member);
}
_logger.Information("Imported system {System}", system.Id);
// TODO: import switches, too?
// Re-map the switch members in the likely case IDs have changed
var mappedSwitches = new List<Tuple<Instant, ICollection<PKMember>>>();
foreach (var sw in data.Switches)
{
var timestamp = InstantPattern.ExtendedIso.Parse(sw.Timestamp).Value;
var swMembers = new List<PKMember>();
swMembers.AddRange(sw.Members.Select(x =>
hidMapping.FirstOrDefault(y => y.Key.Equals(x)).Value));
var mapped = new Tuple<Instant, ICollection<PKMember>>(timestamp, swMembers);
mappedSwitches.Add(mapped);
}
// Import switches
await _switches.RegisterSwitches(system, mappedSwitches);
_logger.Information("Imported system {System}", system.Id);
result.System = system;
return result;
@ -173,6 +217,7 @@ namespace PluralKit.Bot
{
[JsonProperty("id")] public string Id;
[JsonProperty("name")] public string Name;
[JsonProperty("display_name")] public string DisplayName;
[JsonProperty("description")] public string Description;
[JsonProperty("birthday")] public string Birthday;
[JsonProperty("pronouns")] public string Pronouns;
@ -262,8 +307,8 @@ namespace PluralKit.Bot
AvatarUrl = AvatarUrl,
Birthday = Birthday,
Description = Description,
Prefix = Brackets.FirstOrDefault(),
Suffix = Brackets.Skip(1).FirstOrDefault() // TODO: can Tupperbox members have no proxies at all?
Prefix = Brackets.FirstOrDefault().NullIfEmpty(),
Suffix = Brackets.Skip(1).FirstOrDefault().NullIfEmpty() // TODO: can Tupperbox members have no proxies at all?
};
}
}

View File

@ -1,9 +1,12 @@
namespace PluralKit.Core {
public static class Limits {
public static class Limits
{
public static readonly int MaxProxyNameLength = 80;
public static readonly int MaxSystemNameLength = 100;
public static readonly int MaxSystemTagLength = 31;
public static readonly int MaxSystemTagLength = MaxProxyNameLength - 1;
public static readonly int MaxDescriptionLength = 1000;
public static readonly int MaxMemberNameLength = 50;
public static readonly int MaxMemberNameLength = 100; // Fair bit larger than MaxProxyNameLength for bookkeeping
public static readonly int MaxPronounsLength = 100;
public static readonly long AvatarFileSizeLimit = 1024 * 1024;

View File

@ -3,6 +3,8 @@ using Newtonsoft.Json;
using NodaTime;
using NodaTime.Text;
using PluralKit.Core;
namespace PluralKit
{
public class PKSystem
@ -18,7 +20,7 @@ namespace PluralKit
[JsonProperty("created")] public Instant Created { get; set; }
[JsonProperty("tz")] public string UiTz { get; set; }
[JsonIgnore] public int MaxMemberNameLength => Tag != null ? 32 - Tag.Length - 1 : 32;
[JsonIgnore] public int MaxMemberNameLength => Tag != null ? Limits.MaxProxyNameLength - Tag.Length - 1 : Limits.MaxProxyNameLength;
[JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz);
}

View File

@ -1,444 +1,546 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using App.Metrics.Logging;
using Dapper;
using NodaTime;
using Serilog;
namespace PluralKit {
public class SystemStore {
private DbConnectionFactory _conn;
private ILogger _logger;
public SystemStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<SystemStore>();
}
public async Task<PKSystem> Create(string systemName = null) {
string hid;
do
{
hid = Utils.GenerateHid();
} while (await GetByHid(hid) != null);
PKSystem system;
using (var conn = await _conn.Obtain())
system = await conn.QuerySingleAsync<PKSystem>("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName });
_logger.Information("Created system {System}", system.Id);
return system;
}
public async Task Link(PKSystem system, ulong accountId) {
// We have "on conflict do nothing" since linking an account when it's already linked to the same system is idempotent
// This is used in import/export, although the pk;link command checks for this case beforehand
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId) on conflict do nothing", new { Id = accountId, SystemId = system.Id });
_logger.Information("Linked system {System} to account {Account}", system.Id, accountId);
}
public async Task Unlink(PKSystem system, ulong accountId) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id });
_logger.Information("Unlinked system {System} from account {Account}", system.Id, accountId);
}
public async Task<PKSystem> GetByAccount(ulong accountId) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId });
}
public async Task<PKSystem> GetByHid(string hid) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() });
}
public async Task<PKSystem> GetByToken(string token) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token });
}
public async Task<PKSystem> GetById(int id)
{
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id });
}
public async Task Save(PKSystem system) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system);
_logger.Information("Updated system {@System}", system);
}
public async Task Delete(PKSystem system) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from systems where id = @Id", system);
_logger.Information("Deleted system {System}", system.Id);
}
public async Task<IEnumerable<ulong>> GetLinkedAccountIds(PKSystem system)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", new { Id = system.Id });
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from systems");
}
}
public class MemberStore {
private DbConnectionFactory _conn;
private ILogger _logger;
public MemberStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<MemberStore>();
}
public async Task<PKMember> Create(PKSystem system, string name) {
string hid;
do
{
hid = Utils.GenerateHid();
} while (await GetByHid(hid) != null);
PKMember member;
using (var conn = await _conn.Obtain())
member = await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new {
Hid = hid,
SystemID = system.Id,
Name = name
});
_logger.Information("Created member {Member}", member.Id);
return member;
}
public async Task<PKMember> GetByHid(string hid) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() });
}
public async Task<PKMember> GetByName(PKSystem system, string name) {
// QueryFirst, since members can (in rare cases) share names
using (var conn = await _conn.Obtain())
return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id });
}
public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) {
return (await GetBySystem(system))
.Where((m) => {
var proxiedName = $"{m.Name} {system.Tag}";
return proxiedName.Length > 32 || proxiedName.Length < 2;
}).ToList();
}
public async Task<IEnumerable<PKMember>> GetBySystem(PKSystem system) {
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id });
}
public async Task Save(PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member);
_logger.Information("Updated member {@Member}", member);
}
public async Task Delete(PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from members where id = @Id", member);
_logger.Information("Deleted member {@Member}", member);
}
public async Task<int> MessageCount(PKMember member)
{
using (var conn = await _conn.Obtain())
return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member);
}
public async Task<int> MemberCount(PKSystem system)
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<int>("select count(*) from members where system = @Id", system);
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from members");
}
}
public class MessageStore {
public struct PKMessage
{
public ulong Mid;
public ulong Channel;
public ulong Sender;
public ulong? OriginalMid;
}
public class StoredMessage
{
public PKMessage Message;
public PKMember Member;
public PKSystem System;
}
private DbConnectionFactory _conn;
private ILogger _logger;
public MessageStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<MessageStore>();
}
public async Task Store(ulong senderId, ulong messageId, ulong channelId, ulong originalMessage, PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into messages(mid, channel, member, sender, original_mid) values(@MessageId, @ChannelId, @MemberId, @SenderId, @OriginalMid)", new {
MessageId = messageId,
ChannelId = channelId,
MemberId = member.Id,
SenderId = senderId,
OriginalMid = originalMessage
});
_logger.Information("Stored message {Message} in channel {Channel}", messageId, channelId);
}
public async Task<StoredMessage> Get(ulong id)
{
using (var conn = await _conn.Obtain())
return (await conn.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where (mid = @Id or original_mid = @Id) and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage
{
Message = msg,
System = system,
Member = member
}, new { Id = id })).FirstOrDefault();
}
public async Task Delete(ulong id) {
using (var conn = await _conn.Obtain())
if (await conn.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }) > 0)
_logger.Information("Deleted message {Message}", id);
}
public async Task BulkDelete(IReadOnlyCollection<ulong> ids)
{
using (var conn = await _conn.Obtain())
{
// Npgsql doesn't support ulongs in general - we hacked around it for plain ulongs but tbh not worth it for collections of ulong
// Hence we map them to single longs, which *are* supported (this is ok since they're Technically (tm) stored as signed longs in the db anyway)
var foundCount = await conn.ExecuteAsync("delete from messages where mid = any(@Ids)", new {Ids = ids.Select(id => (long) id).ToArray()});
if (foundCount > 0)
_logger.Information("Bulk deleted messages {Messages}, {FoundCount} found", ids, foundCount);
}
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(mid) from messages");
}
}
public class SwitchStore
{
private DbConnectionFactory _conn;
private ILogger _logger;
public SwitchStore(DbConnectionFactory conn, ILogger logger)
{
_conn = conn;
_logger = logger.ForContext<SwitchStore>();
}
public async Task RegisterSwitch(PKSystem system, ICollection<PKMember> members)
{
// Use a transaction here since we're doing multiple executed commands in one
using (var conn = await _conn.Obtain())
using (var tx = conn.BeginTransaction())
{
// First, we insert the switch itself
var sw = await conn.QuerySingleAsync<PKSwitch>("insert into switches(system) values (@System) returning *",
new {System = system.Id});
// Then we insert each member in the switch in the switch_members table
// TODO: can we parallelize this or send it in bulk somehow?
foreach (var member in members)
{
await conn.ExecuteAsync(
"insert into switch_members(switch, member) values(@Switch, @Member)",
new {Switch = sw.Id, Member = member.Id});
}
// Finally we commit the tx, since the using block will otherwise rollback it
tx.Commit();
_logger.Information("Registered switch {Switch} in system {System} with members {@Members}", sw.Id, system.Id, members.Select(m => m.Id));
}
}
public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count = 9999999)
{
// TODO: refactor the PKSwitch data structure to somehow include a hydrated member list
// (maybe when we get caching in?)
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count});
}
public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch order by switch_members.id",
new {Switch = sw.Id});
}
public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKMember>(
"select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch order by switch_members.id",
new {Switch = sw.Id});
}
public async Task<PKSwitch> GetLatestSwitch(PKSystem system) => (await GetSwitches(system, 1)).FirstOrDefault();
public async Task MoveSwitch(PKSwitch sw, Instant time)
{
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update switches set timestamp = @Time where id = @Id",
new {Time = time, Id = sw.Id});
_logger.Information("Moved switch {Switch} to {Time}", sw.Id, time);
}
public async Task DeleteSwitch(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id});
_logger.Information("Deleted switch {Switch}");
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from switches");
}
public struct SwitchListEntry
{
public ICollection<PKMember> Members;
public Instant TimespanStart;
public Instant TimespanEnd;
}
public async Task<IEnumerable<SwitchListEntry>> GetTruncatedSwitchList(PKSystem system, Instant periodStart, Instant periodEnd)
{
// TODO: only fetch the necessary switches here
// todo: this is in general not very efficient LOL
// returns switches in chronological (newest first) order
var switches = await GetSwitches(system);
// we skip all switches that happened later than the range end, and taking all the ones that happened after the range start
// *BUT ALSO INCLUDING* the last switch *before* the range (that partially overlaps the range period)
var switchesInRange = switches.SkipWhile(sw => sw.Timestamp >= periodEnd).TakeWhileIncluding(sw => sw.Timestamp > periodStart).ToList();
// query DB for all members involved in any of the switches above and collect into a dictionary for future use
// this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary
// key used in GetPerMemberSwitchDuration below
Dictionary<int, PKMember> memberObjects;
using (var conn = await _conn.Obtain())
{
memberObjects = (await conn.QueryAsync<PKMember>(
"select distinct members.* from members, switch_members where switch_members.switch = any(@Switches) and switch_members.member = members.id", // lol postgres specific `= any()` syntax
new {Switches = switchesInRange.Select(sw => sw.Id).ToList()}))
.ToDictionary(m => m.Id);
}
// we create the entry objects
var outList = new List<SwitchListEntry>();
// loop through every switch that *occurred* in-range and add it to the list
// end time is the switch *after*'s timestamp - we cheat and start it out at the range end so the first switch in-range "ends" there instead of the one after's start point
var endTime = periodEnd;
foreach (var switchInRange in switchesInRange)
{
// find the start time of the switch, but clamp it to the range (only applicable to the Last Switch Before Range we include in the TakeWhileIncluding call above)
var switchStartClamped = switchInRange.Timestamp;
if (switchStartClamped < periodStart) switchStartClamped = periodStart;
outList.Add(new SwitchListEntry
{
Members = (await GetSwitchMemberIds(switchInRange)).Select(id => memberObjects[id]).ToList(),
TimespanStart = switchStartClamped,
TimespanEnd = endTime
});
// next switch's end is this switch's start
endTime = switchInRange.Timestamp;
}
return outList;
}
public struct PerMemberSwitchDuration
{
public Dictionary<PKMember, Duration> MemberSwitchDurations;
public Duration NoFronterDuration;
public Instant RangeStart;
public Instant RangeEnd;
}
public async Task<PerMemberSwitchDuration> GetPerMemberSwitchDuration(PKSystem system, Instant periodStart,
Instant periodEnd)
{
var dict = new Dictionary<PKMember, Duration>();
var noFronterDuration = Duration.Zero;
// Sum up all switch durations for each member
// switches with multiple members will result in the duration to add up to more than the actual period range
var actualStart = periodEnd; // will be "pulled" down
var actualEnd = periodStart; // will be "pulled" up
foreach (var sw in await GetTruncatedSwitchList(system, periodStart, periodEnd))
{
var span = sw.TimespanEnd - sw.TimespanStart;
foreach (var member in sw.Members)
{
if (!dict.ContainsKey(member)) dict.Add(member, span);
else dict[member] += span;
}
if (sw.Members.Count == 0) noFronterDuration += span;
if (sw.TimespanStart < actualStart) actualStart = sw.TimespanStart;
if (sw.TimespanEnd > actualEnd) actualEnd = sw.TimespanEnd;
}
return new PerMemberSwitchDuration
{
MemberSwitchDurations = dict,
NoFronterDuration = noFronterDuration,
RangeStart = actualStart,
RangeEnd = actualEnd
};
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using App.Metrics.Logging;
using Dapper;
using NodaTime;
using PluralKit.Core;
using Serilog;
namespace PluralKit {
public class SystemStore {
private DbConnectionFactory _conn;
private ILogger _logger;
public SystemStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<SystemStore>();
}
public async Task<PKSystem> Create(string systemName = null) {
string hid;
do
{
hid = Utils.GenerateHid();
} while (await GetByHid(hid) != null);
PKSystem system;
using (var conn = await _conn.Obtain())
system = await conn.QuerySingleAsync<PKSystem>("insert into systems (hid, name) values (@Hid, @Name) returning *", new { Hid = hid, Name = systemName });
_logger.Information("Created system {System}", system.Id);
return system;
}
public async Task Link(PKSystem system, ulong accountId) {
// We have "on conflict do nothing" since linking an account when it's already linked to the same system is idempotent
// This is used in import/export, although the pk;link command checks for this case beforehand
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into accounts (uid, system) values (@Id, @SystemId) on conflict do nothing", new { Id = accountId, SystemId = system.Id });
_logger.Information("Linked system {System} to account {Account}", system.Id, accountId);
}
public async Task Unlink(PKSystem system, ulong accountId) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from accounts where uid = @Id and system = @SystemId", new { Id = accountId, SystemId = system.Id });
_logger.Information("Unlinked system {System} from account {Account}", system.Id, accountId);
}
public async Task<PKSystem> GetByAccount(ulong accountId) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select systems.* from systems, accounts where accounts.system = systems.id and accounts.uid = @Id", new { Id = accountId });
}
public async Task<PKSystem> GetByHid(string hid) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where systems.hid = @Hid", new { Hid = hid.ToLower() });
}
public async Task<PKSystem> GetByToken(string token) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where token = @Token", new { Token = token });
}
public async Task<PKSystem> GetById(int id)
{
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKSystem>("select * from systems where id = @Id", new { Id = id });
}
public async Task Save(PKSystem system) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update systems set name = @Name, description = @Description, tag = @Tag, avatar_url = @AvatarUrl, token = @Token, ui_tz = @UiTz where id = @Id", system);
_logger.Information("Updated system {@System}", system);
}
public async Task Delete(PKSystem system) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from systems where id = @Id", system);
_logger.Information("Deleted system {System}", system.Id);
}
public async Task<IEnumerable<ulong>> GetLinkedAccountIds(PKSystem system)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<ulong>("select uid from accounts where system = @Id", new { Id = system.Id });
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from systems");
}
}
public class MemberStore {
private DbConnectionFactory _conn;
private ILogger _logger;
public MemberStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<MemberStore>();
}
public async Task<PKMember> Create(PKSystem system, string name) {
string hid;
do
{
hid = Utils.GenerateHid();
} while (await GetByHid(hid) != null);
PKMember member;
using (var conn = await _conn.Obtain())
member = await conn.QuerySingleAsync<PKMember>("insert into members (hid, system, name) values (@Hid, @SystemId, @Name) returning *", new {
Hid = hid,
SystemID = system.Id,
Name = name
});
_logger.Information("Created member {Member}", member.Id);
return member;
}
public async Task<PKMember> GetByHid(string hid) {
using (var conn = await _conn.Obtain())
return await conn.QuerySingleOrDefaultAsync<PKMember>("select * from members where hid = @Hid", new { Hid = hid.ToLower() });
}
public async Task<PKMember> GetByName(PKSystem system, string name) {
// QueryFirst, since members can (in rare cases) share names
using (var conn = await _conn.Obtain())
return await conn.QueryFirstOrDefaultAsync<PKMember>("select * from members where lower(name) = lower(@Name) and system = @SystemID", new { Name = name, SystemID = system.Id });
}
public async Task<ICollection<PKMember>> GetUnproxyableMembers(PKSystem system) {
return (await GetBySystem(system))
.Where((m) => {
var proxiedName = $"{m.Name} {system.Tag}";
return proxiedName.Length > Limits.MaxProxyNameLength || proxiedName.Length < 2;
}).ToList();
}
public async Task<IEnumerable<PKMember>> GetBySystem(PKSystem system) {
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKMember>("select * from members where system = @SystemID", new { SystemID = system.Id });
}
public async Task Save(PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update members set name = @Name, display_name = @DisplayName, description = @Description, color = @Color, avatar_url = @AvatarUrl, birthday = @Birthday, pronouns = @Pronouns, prefix = @Prefix, suffix = @Suffix where id = @Id", member);
_logger.Information("Updated member {@Member}", member);
}
public async Task Delete(PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from members where id = @Id", member);
_logger.Information("Deleted member {@Member}", member);
}
public async Task<int> MessageCount(PKMember member)
{
using (var conn = await _conn.Obtain())
return await conn.QuerySingleAsync<int>("select count(*) from messages where member = @Id", member);
}
public struct MessageBreakdownListEntry
{
public int Member;
public int MessageCount;
}
public async Task<IEnumerable<MessageBreakdownListEntry>> MessageCountsPerMember(PKSystem system)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<MessageBreakdownListEntry>(
@"SELECT messages.member, COUNT(messages.member) messagecount
FROM members
JOIN messages
ON members.id = messages.member
WHERE members.system = @System
GROUP BY messages.member",
new { System = system.Id });
}
public async Task<int> MemberCount(PKSystem system)
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<int>("select count(*) from members where system = @Id", system);
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from members");
}
}
public class MessageStore {
public struct PKMessage
{
public ulong Mid;
public ulong Channel;
public ulong Sender;
public ulong? OriginalMid;
}
public class StoredMessage
{
public PKMessage Message;
public PKMember Member;
public PKSystem System;
}
private DbConnectionFactory _conn;
private ILogger _logger;
public MessageStore(DbConnectionFactory conn, ILogger logger)
{
this._conn = conn;
_logger = logger.ForContext<MessageStore>();
}
public async Task Store(ulong senderId, ulong messageId, ulong channelId, ulong originalMessage, PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into messages(mid, channel, member, sender, original_mid) values(@MessageId, @ChannelId, @MemberId, @SenderId, @OriginalMid)", new {
MessageId = messageId,
ChannelId = channelId,
MemberId = member.Id,
SenderId = senderId,
OriginalMid = originalMessage
});
_logger.Information("Stored message {Message} in channel {Channel}", messageId, channelId);
}
public async Task<StoredMessage> Get(ulong id)
{
using (var conn = await _conn.Obtain())
return (await conn.QueryAsync<PKMessage, PKMember, PKSystem, StoredMessage>("select messages.*, members.*, systems.* from messages, members, systems where (mid = @Id or original_mid = @Id) and messages.member = members.id and systems.id = members.system", (msg, member, system) => new StoredMessage
{
Message = msg,
System = system,
Member = member
}, new { Id = id })).FirstOrDefault();
}
public async Task Delete(ulong id) {
using (var conn = await _conn.Obtain())
if (await conn.ExecuteAsync("delete from messages where mid = @Id", new { Id = id }) > 0)
_logger.Information("Deleted message {Message}", id);
}
public async Task BulkDelete(IReadOnlyCollection<ulong> ids)
{
using (var conn = await _conn.Obtain())
{
// Npgsql doesn't support ulongs in general - we hacked around it for plain ulongs but tbh not worth it for collections of ulong
// Hence we map them to single longs, which *are* supported (this is ok since they're Technically (tm) stored as signed longs in the db anyway)
var foundCount = await conn.ExecuteAsync("delete from messages where mid = any(@Ids)", new {Ids = ids.Select(id => (long) id).ToArray()});
if (foundCount > 0)
_logger.Information("Bulk deleted messages {Messages}, {FoundCount} found", ids, foundCount);
}
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(mid) from messages");
}
}
public class SwitchStore
{
private DbConnectionFactory _conn;
private ILogger _logger;
public SwitchStore(DbConnectionFactory conn, ILogger logger)
{
_conn = conn;
_logger = logger.ForContext<SwitchStore>();
}
public async Task RegisterSwitch(PKSystem system, ICollection<PKMember> members)
{
// Use a transaction here since we're doing multiple executed commands in one
using (var conn = await _conn.Obtain())
using (var tx = conn.BeginTransaction())
{
// First, we insert the switch itself
var sw = await conn.QuerySingleAsync<PKSwitch>("insert into switches(system) values (@System) returning *",
new {System = system.Id});
// Then we insert each member in the switch in the switch_members table
// TODO: can we parallelize this or send it in bulk somehow?
foreach (var member in members)
{
await conn.ExecuteAsync(
"insert into switch_members(switch, member) values(@Switch, @Member)",
new {Switch = sw.Id, Member = member.Id});
}
// Finally we commit the tx, since the using block will otherwise rollback it
tx.Commit();
_logger.Information("Registered switch {Switch} in system {System} with members {@Members}", sw.Id, system.Id, members.Select(m => m.Id));
}
}
public async Task RegisterSwitches(PKSystem system, ICollection<Tuple<Instant, ICollection<PKMember>>> switches)
{
// Use a transaction here since we're doing multiple executed commands in one
using (var conn = await _conn.Obtain())
using (var tx = conn.BeginTransaction())
{
foreach (var s in switches)
{
// First, we insert the switch itself
var sw = await conn.QueryFirstOrDefaultAsync<PKSwitch>(
@"insert into switches(system, timestamp)
select @System, @Timestamp
where not exists (
select * from switches
where system = @System and timestamp::timestamp(0) = @Timestamp
limit 1
)
returning *",
new { System = system.Id, Timestamp = s.Item1 });
// If we inserted a switch, also insert each member in the switch in the switch_members table
if (sw != null && s.Item2.Any())
await conn.ExecuteAsync(
"insert into switch_members(switch, member) select @Switch, * FROM unnest(@Members)",
new { Switch = sw.Id, Members = s.Item2.Select(x => x.Id).ToArray() });
}
// Finally we commit the tx, since the using block will otherwise rollback it
tx.Commit();
_logger.Information("Registered {SwitchCount} switches in system {System}", switches.Count, system.Id);
}
}
public async Task<IEnumerable<PKSwitch>> GetSwitches(PKSystem system, int count = 9999999)
{
// TODO: refactor the PKSwitch data structure to somehow include a hydrated member list
// (maybe when we get caching in?)
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKSwitch>("select * from switches where system = @System order by timestamp desc limit @Count", new {System = system.Id, Count = count});
}
public struct SwitchMembersListEntry
{
public int Member;
public Instant Timestamp;
}
public async Task<IEnumerable<SwitchMembersListEntry>> GetSwitchMembersList(PKSystem system, Instant start, Instant end)
{
// Wrap multiple commands in a single transaction for performance
using (var conn = await _conn.Obtain())
using (var tx = conn.BeginTransaction())
{
// Find the time of the last switch outside the range as it overlaps the range
// If no prior switch exists, the lower bound of the range remains the start time
var lastSwitch = await conn.QuerySingleOrDefaultAsync<Instant>(
@"SELECT COALESCE(MAX(timestamp), @Start)
FROM switches
WHERE switches.system = @System
AND switches.timestamp < @Start",
new { System = system.Id, Start = start });
// Then collect the time and members of all switches that overlap the range
var switchMembersEntries = await conn.QueryAsync<SwitchMembersListEntry>(
@"SELECT switch_members.member, switches.timestamp
FROM switches
LEFT JOIN switch_members
ON switches.id = switch_members.switch
WHERE switches.system = @System
AND (
switches.timestamp >= @Start
OR switches.timestamp = @LastSwitch
)
AND switches.timestamp < @End
ORDER BY switches.timestamp DESC",
new { System = system.Id, Start = start, End = end, LastSwitch = lastSwitch });
// Commit and return the list
tx.Commit();
return switchMembersEntries;
}
}
public async Task<IEnumerable<int>> GetSwitchMemberIds(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<int>("select member from switch_members where switch = @Switch order by switch_members.id",
new {Switch = sw.Id});
}
public async Task<IEnumerable<PKMember>> GetSwitchMembers(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
return await conn.QueryAsync<PKMember>(
"select * from switch_members, members where switch_members.member = members.id and switch_members.switch = @Switch order by switch_members.id",
new {Switch = sw.Id});
}
public async Task<PKSwitch> GetLatestSwitch(PKSystem system) => (await GetSwitches(system, 1)).FirstOrDefault();
public async Task MoveSwitch(PKSwitch sw, Instant time)
{
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("update switches set timestamp = @Time where id = @Id",
new {Time = time, Id = sw.Id});
_logger.Information("Moved switch {Switch} to {Time}", sw.Id, time);
}
public async Task DeleteSwitch(PKSwitch sw)
{
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("delete from switches where id = @Id", new {Id = sw.Id});
_logger.Information("Deleted switch {Switch}");
}
public async Task<ulong> Count()
{
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from switches");
}
public struct SwitchListEntry
{
public ICollection<PKMember> Members;
public Instant TimespanStart;
public Instant TimespanEnd;
}
public async Task<IEnumerable<SwitchListEntry>> GetTruncatedSwitchList(PKSystem system, Instant periodStart, Instant periodEnd)
{
// Returns the timestamps and member IDs of switches overlapping the range, in chronological (newest first) order
var switchMembers = await GetSwitchMembersList(system, periodStart, periodEnd);
// query DB for all members involved in any of the switches above and collect into a dictionary for future use
// this makes sure the return list has the same instances of PKMember throughout, which is important for the dictionary
// key used in GetPerMemberSwitchDuration below
Dictionary<int, PKMember> memberObjects;
using (var conn = await _conn.Obtain())
{
memberObjects = (
await conn.QueryAsync<PKMember>(
"select * from members where id = any(@Switches)", // lol postgres specific `= any()` syntax
new { Switches = switchMembers.Select(m => m.Member).Distinct().ToList() })
).ToDictionary(m => m.Id);
}
// Initialize entries - still need to loop to determine the TimespanEnd below
var entries =
from item in switchMembers
group item by item.Timestamp into g
select new SwitchListEntry
{
TimespanStart = g.Key,
Members = g.Where(x => x.Member != 0).Select(x => memberObjects[x.Member]).ToList()
};
// Loop through every switch that overlaps the range and add it to the output list
// end time is the *FOLLOWING* switch's timestamp - we cheat by working backwards from the range end, so no dates need to be compared
var endTime = periodEnd;
var outList = new List<SwitchListEntry>();
foreach (var e in entries)
{
// Override the start time of the switch if it's outside the range (only true for the "out of range" switch we included above)
var switchStartClamped = e.TimespanStart < periodStart
? periodStart
: e.TimespanStart;
outList.Add(new SwitchListEntry
{
Members = e.Members,
TimespanStart = switchStartClamped,
TimespanEnd = endTime
});
// next switch's end is this switch's start (we're working backward in time)
endTime = e.TimespanStart;
}
return outList;
}
public struct PerMemberSwitchDuration
{
public Dictionary<PKMember, Duration> MemberSwitchDurations;
public Duration NoFronterDuration;
public Instant RangeStart;
public Instant RangeEnd;
}
public async Task<PerMemberSwitchDuration> GetPerMemberSwitchDuration(PKSystem system, Instant periodStart,
Instant periodEnd)
{
var dict = new Dictionary<PKMember, Duration>();
var noFronterDuration = Duration.Zero;
// Sum up all switch durations for each member
// switches with multiple members will result in the duration to add up to more than the actual period range
var actualStart = periodEnd; // will be "pulled" down
var actualEnd = periodStart; // will be "pulled" up
foreach (var sw in await GetTruncatedSwitchList(system, periodStart, periodEnd))
{
var span = sw.TimespanEnd - sw.TimespanStart;
foreach (var member in sw.Members)
{
if (!dict.ContainsKey(member)) dict.Add(member, span);
else dict[member] += span;
}
if (sw.Members.Count == 0) noFronterDuration += span;
if (sw.TimespanStart < actualStart) actualStart = sw.TimespanStart;
if (sw.TimespanEnd > actualEnd) actualEnd = sw.TimespanEnd;
}
return new PerMemberSwitchDuration
{
MemberSwitchDurations = dict,
NoFronterDuration = noFronterDuration,
RangeStart = actualStart,
RangeEnd = actualEnd
};
}
}
}

View File

@ -1,11 +1,16 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using App.Metrics;
using App.Metrics.Timer;
using Dapper;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
@ -13,8 +18,13 @@ using NodaTime;
using NodaTime.Serialization.JsonNet;
using NodaTime.Text;
using Npgsql;
using PluralKit.Core;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting.Compact;
using Serilog.Formatting.Display;
using Serilog.Formatting.Json;
using Serilog.Sinks.SystemConsole.Themes;
@ -236,6 +246,11 @@ namespace PluralKit
yield break;
}
}
public static string NullIfEmpty(this string input)
{
return input.Trim().Length == 0 ? null : input;
}
}
public static class Emojis {
@ -297,20 +312,32 @@ namespace PluralKit
SqlMapper.AddTypeHandler(new PassthroughTypeHandler<LocalDate>());
}
public static ILogger InitLogger(CoreConfig config, string component)
public static ILogger InitLogger(CoreConfig config, string component)
{
return new LoggerConfiguration()
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)
.MinimumLevel.Debug()
.WriteTo.File(
new CompactJsonFormatter(),
new RenderedCompactJsonFormatter(),
(config.LogDir ?? "logs") + $"/pluralkit.{component}.log",
rollingInterval: RollingInterval.Day,
flushToDiskInterval: TimeSpan.FromSeconds(10),
restrictedToMinimumLevel: LogEventLevel.Information,
buffered: true)
.WriteTo.Console(theme: AnsiConsoleTheme.Code)
.WriteTo.Console(theme: AnsiConsoleTheme.Code, outputTemplate:"[{Timestamp:HH:mm:ss}] [{EventId}] {Level:u3} {Message:lj}{NewLine}{Exception}")
.CreateLogger();
}
public static IMetrics InitMetrics(CoreConfig config, string onlyContext = null)
{
var builder = AppMetrics.CreateDefaultBuilder();
if (config.InfluxUrl != null && config.InfluxDb != null)
builder.Report.ToInfluxDb(config.InfluxUrl, config.InfluxDb);
if (onlyContext != null)
builder.Filter.ByIncludingOnlyContext(onlyContext);
return builder.Build();
}
public static JsonSerializerSettings BuildSerializerSettings() => new JsonSerializerSettings().BuildSerializerSettings();
public static JsonSerializerSettings BuildSerializerSettings(this JsonSerializerSettings settings)
@ -319,7 +346,19 @@ namespace PluralKit
return settings;
}
}
public class LoggerProvider
{
private CoreConfig _config;
public ILogger RootLogger { get; }
public LoggerProvider(CoreConfig config, string component)
{
_config = config;
RootLogger = InitUtils.InitLogger(_config, component);
}
}
public class UlongEncodeAsLongHandler : SqlMapper.TypeHandler<ulong>
{
public override ulong Parse(object value)
@ -349,18 +388,265 @@ namespace PluralKit
public class DbConnectionFactory
{
private string _connectionString;
private CoreConfig _config;
private ILogger _logger;
private IMetrics _metrics;
private DbConnectionCountHolder _countHolder;
public DbConnectionFactory(string connectionString)
public DbConnectionFactory(CoreConfig config, DbConnectionCountHolder countHolder, ILogger logger, IMetrics metrics)
{
_connectionString = connectionString;
_config = config;
_countHolder = countHolder;
_metrics = metrics;
_logger = logger;
}
public async Task<IDbConnection> Obtain()
{
var conn = new NpgsqlConnection(_connectionString);
// Mark the request (for a handle, I guess) in the metrics
_metrics.Measure.Meter.Mark(CoreMetrics.DatabaseRequests);
// Actually create and try to open the connection
var conn = new NpgsqlConnection(_config.Database);
await conn.OpenAsync();
return conn;
// Increment the count
_countHolder.Increment();
// Return a wrapped connection which will decrement the counter on dispose
return new PerformanceTrackingConnection(conn, _countHolder, _logger, _metrics);
}
}
public class DbConnectionCountHolder
{
private int _connectionCount;
public int ConnectionCount => _connectionCount;
public void Increment()
{
Interlocked.Increment(ref _connectionCount);
}
public void Decrement()
{
Interlocked.Decrement(ref _connectionCount);
}
}
public class PerformanceTrackingConnection: IDbConnection
{
// Simple delegation of everything.
private NpgsqlConnection _impl;
private DbConnectionCountHolder _countHolder;
private ILogger _logger;
private IMetrics _metrics;
public PerformanceTrackingConnection(NpgsqlConnection impl, DbConnectionCountHolder countHolder,
ILogger logger, IMetrics metrics)
{
_impl = impl;
_countHolder = countHolder;
_logger = logger;
_metrics = metrics;
}
public void Dispose()
{
_impl.Dispose();
_countHolder.Decrement();
}
public IDbTransaction BeginTransaction()
{
return _impl.BeginTransaction();
}
public IDbTransaction BeginTransaction(IsolationLevel il)
{
return _impl.BeginTransaction(il);
}
public void ChangeDatabase(string databaseName)
{
_impl.ChangeDatabase(databaseName);
}
public void Close()
{
_impl.Close();
}
public IDbCommand CreateCommand()
{
return new PerformanceTrackingCommand(_impl.CreateCommand(), _logger, _metrics);
}
public void Open()
{
_impl.Open();
}
public string ConnectionString
{
get => _impl.ConnectionString;
set => _impl.ConnectionString = value;
}
public int ConnectionTimeout => _impl.ConnectionTimeout;
public string Database => _impl.Database;
public ConnectionState State => _impl.State;
}
public class PerformanceTrackingCommand : DbCommand
{
private NpgsqlCommand _impl;
private ILogger _logger;
private IMetrics _metrics;
public PerformanceTrackingCommand(NpgsqlCommand impl, ILogger logger, IMetrics metrics)
{
_impl = impl;
_metrics = metrics;
_logger = logger;
}
public override void Cancel()
{
_impl.Cancel();
}
public override int ExecuteNonQuery()
{
return _impl.ExecuteNonQuery();
}
public override object ExecuteScalar()
{
return _impl.ExecuteScalar();
}
public override void Prepare()
{
_impl.Prepare();
}
public override string CommandText
{
get => _impl.CommandText;
set => _impl.CommandText = value;
}
public override int CommandTimeout
{
get => _impl.CommandTimeout;
set => _impl.CommandTimeout = value;
}
public override CommandType CommandType
{
get => _impl.CommandType;
set => _impl.CommandType = value;
}
public override UpdateRowSource UpdatedRowSource
{
get => _impl.UpdatedRowSource;
set => _impl.UpdatedRowSource = value;
}
protected override DbConnection DbConnection
{
get => _impl.Connection;
set => _impl.Connection = (NpgsqlConnection) value;
}
protected override DbParameterCollection DbParameterCollection => _impl.Parameters;
protected override DbTransaction DbTransaction
{
get => _impl.Transaction;
set => _impl.Transaction = (NpgsqlTransaction) value;
}
public override bool DesignTimeVisible
{
get => _impl.DesignTimeVisible;
set => _impl.DesignTimeVisible = value;
}
protected override DbParameter CreateDbParameter()
{
return _impl.CreateParameter();
}
protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
return _impl.ExecuteReader(behavior);
}
private IDisposable LogQuery()
{
return new QueryLogger(_logger, _metrics, CommandText);
}
protected override async Task<DbDataReader> ExecuteDbDataReaderAsync(CommandBehavior behavior, CancellationToken cancellationToken)
{
using (LogQuery())
return await _impl.ExecuteReaderAsync(behavior, cancellationToken);
}
public override async Task<int> ExecuteNonQueryAsync(CancellationToken cancellationToken)
{
using (LogQuery())
return await _impl.ExecuteNonQueryAsync(cancellationToken);
}
public override async Task<object> ExecuteScalarAsync(CancellationToken cancellationToken)
{
using (LogQuery())
return await _impl.ExecuteScalarAsync(cancellationToken);
}
}
public class QueryLogger : IDisposable
{
private ILogger _logger;
private IMetrics _metrics;
private string _commandText;
private Stopwatch _stopwatch;
public QueryLogger(ILogger logger, IMetrics metrics, string commandText)
{
_metrics = metrics;
_commandText = commandText;
_logger = logger;
_stopwatch = new Stopwatch();
_stopwatch.Start();
}
public void Dispose()
{
_stopwatch.Stop();
_logger.Debug("Executed query {Query} in {ElapsedTime}", _commandText, _stopwatch.Elapsed);
// One tick is 100 nanoseconds
_metrics.Provider.Timer.Instance(CoreMetrics.DatabaseQuery, new MetricTags("query", _commandText))
.Record(_stopwatch.ElapsedTicks / 10, TimeUnit.Microseconds, _commandText);
}
}
public class EventIdProvider
{
public Guid EventId { get; }
public EventIdProvider()
{
EventId = Guid.NewGuid();
}
}
}

View File

@ -49,6 +49,10 @@ create table if not exists switches
system serial not null references systems (id) on delete cascade,
timestamp timestamp not null default (current_timestamp at time zone 'utc')
);
CREATE INDEX IF NOT EXISTS idx_switches_system
ON switches USING btree (
system ASC NULLS LAST
) INCLUDE ("timestamp");
create table if not exists switch_members
(
@ -56,6 +60,10 @@ create table if not exists switch_members
switch serial not null references switches (id) on delete cascade,
member serial not null references members (id) on delete cascade
);
CREATE INDEX IF NOT EXISTS idx_switch_members_switch
ON switch_members USING btree (
switch ASC NULLS LAST
) INCLUDE (member);
create table if not exists webhooks
(

View File

@ -44,6 +44,18 @@ $ docker-compose up -d
* Create and fill in a `pluralkit.conf` file in the same directory as `docker-compose.yml`
* Run the bot: `dotnet run --project PluralKit.Bot`
# Building the docs
The website and documentation are automatically built by GitHub Pages when pushed to the `master` branch. They use [Jekyll 3](https://jekyllrb.com), which requires [Ruby](https://www.ruby-lang.org) and [Bundler](https://bundler.io/).
To build the docs locally, run:
```
$ cd docs/
$ bundle install --path vendor/bundle
$ bundle exec jekyll build
```
To run an auto-reloading server, substitute the last command with:
$ bundle exec jekyll serve
# License
This project is under the Apache License, Version 2.0. It is available at the following link: https://www.apache.org/licenses/LICENSE-2.0

View File

@ -1,10 +1,11 @@
version: "3"
services:
bot:
build: .
entrypoint: ["dotnet", "run", "--project", "PluralKit.Bot"]
image: pluralkit # This image is reused in the other containers due to the
build: . # build instruction right here
command: ["PluralKit.Bot.dll"]
environment:
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres"
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres;Maximum Pool Size=1000"
- "PluralKit:InfluxUrl=http://influx:8086"
- "PluralKit:InfluxDb=pluralkit"
- "PluralKit:LogDir=/var/log/pluralkit"
@ -16,20 +17,20 @@ services:
- influx
restart: always
web:
build: .
entrypoint: ["dotnet", "run", "--project", "PluralKit.Web"]
image: pluralkit
command: ["PluralKit.Web.dll"]
environment:
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres"
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres;Maximum Pool Size=1000"
links:
- db
ports:
- 2837:5000
restart: always
api:
build: .
entrypoint: ["dotnet", "run", "--project", "PluralKit.API"]
image: pluralkit
command: ["PluralKit.API.dll"]
environment:
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres"
- "PluralKit:Database=Host=db;Username=postgres;Password=postgres;Database=postgres;Maximum Pool Size=1000"
links:
- db
ports:

View File

@ -2,6 +2,7 @@
layout: default
title: User Guide
permalink: /guide
description: PluralKit's user guide contains a walkthrough of the bot's features, as well as how to use them.
---
# User Guide

View File

@ -2,10 +2,11 @@
layout: default
title: Command List
permalink: /commands
description: The full list of all commands in PluralKit, and a short description of what they do.
---
# How to read this
Words in \<angle brackets> are *required parameters*. Words in [square brackets] are *optional parameters*. Words with ellipses... indicate multiple repeating parameters.
Words in \<angle brackets> are *required parameters*. Words in [square brackets] are *optional parameters*. Words with ellipses... indicate multiple repeating parameters. Note that **you should not include the brackets in the actual command**.
# Commands
## System commands

View File

@ -2,6 +2,7 @@
layout: default
title: API documentation
permalink: /api
description: PluralKit's API documentation.
---
# API documentation
@ -56,7 +57,7 @@ The following three models (usually represented in JSON format) represent the va
### Message model
|Key|Type|Notes|
|---|---|---
|---|---|---|
|timestamp|datetime||
|id|snowflake|The ID of the message sent by the webhook. Encoded as string for precision reasons.|
|sender|snowflake|The user ID of the account that triggered the proxy. Encoded as string for precision reasons.|

View File

@ -2,6 +2,7 @@
layout: default
title: Privacy Policy
permalink: /privacy
description: I'm not a lawyer. I don't want to write a 50 page document no one wants to (or can) read. It's short, I promise.
---
# Privacy Policy

21
docs/5-faq.md Normal file
View File

@ -0,0 +1,21 @@
---
layout: default
title: FAQ
permalink: /faq
description: Frequently asked questions (and the answers to them).
---
# Frequently asked questions
{: .no_toc }
1. TOC
{:toc}
## Can I use this bot for kin/roleplay/other non-plurality uses? Can I use it if I'm not plural myself? Is that appropriating?
Although this bot is designed with plural systems and their use cases in mind, the bot's feature set is still useful for many other types of communities, including role-playing and otherkin. By all means go ahead and use it for those communities, too. We don't gatekeep, and neither should you.
## Who's the mascot?
[Our lovely bot mascot](https://imgur.com/a/LTqQHHL)'s name is Myriad! They were drawn by the lovely [Layl](https://twitter.com/braindemons). Yes, there are fictives.
## How can I support the bot's development?
I (the bot author, [Ske](https://twitter.com/floofstrid) have a Patreon. The income from there goes towards server hosting, domains, infrastructure, my Monster Energy addiction, et cetera. There are no benefits. There might never be any. But nevertheless, it can be found here: [https://www.patreon.com/floofstrid](https://www.patreon.com/floofstrid)

View File

@ -1,2 +1,3 @@
source "https://rubygems.org"
gem 'github-pages', group: :jekyll_plugins
gem 'github-pages', group: :jekyll_plugins
gem 'wdm', '>= 0.1.0' if Gem.win_platform?

BIN
docs/assets/myriad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

BIN
docs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB