This commit is contained in:
dawn 2026-01-26 04:15:54 +00:00 committed by GitHub
commit 6299718aa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 8932 additions and 4828 deletions

View file

@ -1,23 +1,23 @@
name: Build and push Docker image
on:
workflow_dispatch:
push:
paths:
- '.dockerignore'
- '.github/workflows/dotnet-docker.yml'
- 'ci/Dockerfile.dotnet'
- 'ci/dotnet-version.sh'
- 'Myriad/**'
- 'PluralKit.API/**'
- 'PluralKit.Bot/**'
- 'PluralKit.Core/**'
- ".dockerignore"
- ".github/workflows/dotnet-docker.yml"
- "ci/Dockerfile.dotnet"
- "ci/dotnet-version.sh"
- "Myriad/**"
- "PluralKit.API/**"
- "PluralKit.Bot/**"
- "PluralKit.Core/**"
jobs:
build:
name: '.net docker build'
name: ".net docker build"
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
with:

View file

@ -3,22 +3,22 @@ name: .net checks
on:
push:
paths:
- .github/workflows/dotnet.yml
- 'Myriad/**'
- 'PluralKit.API/**'
- 'PluralKit.Bot/**'
- 'PluralKit.Core/**'
- .github/workflows/dotnet.yml
- "Myriad/**"
- "PluralKit.API/**"
- "PluralKit.Bot/**"
- "PluralKit.Core/**"
pull_request:
paths:
- .github/workflows/dotnet.yml
- 'Myriad/**'
- 'PluralKit.API/**'
- 'PluralKit.Bot/**'
- 'PluralKit.Core/**'
- .github/workflows/dotnet.yml
- "Myriad/**"
- "PluralKit.API/**"
- "PluralKit.Bot/**"
- "PluralKit.Core/**"
jobs:
test:
name: 'run .net tests'
name: "run .net tests"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -30,6 +30,19 @@ jobs:
with:
dotnet-version: 8.0.x
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""
- name: install uniffi
run: cargo install uniffi-bindgen-cs --git https://github.com/90-008/uniffi-bindgen-cs
- name: generate command parser bindings
run: |
cargo -Z unstable-options build --package commands --lib --release --artifact-dir obj/
uniffi-bindgen-cs "obj/libcommands.so" --library --out-dir="./PluralKit.Bot"
cargo run --package commands --bin write_cs_glue -- "./PluralKit.Bot"/commandtypes.cs
- name: Run automated tests
run: dotnet test --configuration Release

View file

@ -1,22 +1,22 @@
name: Build and push Rust service Docker images
on:
workflow_dispatch:
push:
paths:
- 'crates/**'
- '.dockerignore'
- '.github/workflows/rust.yml'
- 'ci/Dockerfile.rust'
- 'ci/rust-docker-target.sh'
- 'Cargo.toml'
- 'Cargo.lock'
- "crates/**"
- ".dockerignore"
- ".github/workflows/rust.yml"
- "ci/Dockerfile.rust"
- "ci/rust-docker-target.sh"
- "Cargo.toml"
- "Cargo.lock"
jobs:
build:
name: 'rust docker build'
name: "rust docker build"
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
if: ${{ !env.ACT }}
@ -35,7 +35,7 @@ jobs:
# https://github.com/docker/build-push-action/issues/378
context: .
file: ci/Dockerfile.rust
push: false
push: false
cache-from: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust
cache-to: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust,mode=max
outputs: .docker-bin

3
.gitignore vendored
View file

@ -9,6 +9,7 @@ target/
.idea/
.run/
.vscode/
.zed/
.mono/
tags/
.DS_Store
@ -31,6 +32,8 @@ logs/
.version
recipe.json
.docker-bin/
PluralKit.Bot/commands.cs
PluralKit.Bot/commandtypes.cs
# nix
.nix-process-compose

1842
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
[workspace]
resolver = "2"
members = [
"./crates/*"
]
resolver = "2"
[workspace.dependencies]
anyhow = "1"

View file

@ -1,58 +1,25 @@
using Humanizer;
using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot;
public partial class CommandTree
{
private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands)
private async Task PrintCommandList(Context ctx, string subject, string commands)
{
var commandListStr = CreatePotentialCommandList(ctx.DefaultPrefix, potentialCommands);
await ctx.Reply(
$"{Emojis.Error} Unknown command `{ctx.DefaultPrefix}{ctx.FullCommand().Truncate(100)}`. 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(ctx.DefaultPrefix, 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(string prefix, params Command[] potentialCommands)
{
return string.Join("\n", potentialCommands.Select(cmd => $"- **{prefix}{cmd.Usage}** - *{cmd.Description}*"));
}
private async Task PrintCommandList(Context ctx, string subject, params Command[] commands)
{
var str = CreatePotentialCommandList(ctx.DefaultPrefix, commands);
await ctx.Reply(
$"Here is a list of commands related to {subject}:",
embed: new Embed()
{
Description = $"{str}\nFor a full list of possible commands, see <https://pluralkit.me/commands>.",
Color = DiscordUtils.Blue,
}
);
}
private async Task<string> CreateSystemNotFoundError(Context ctx)
{
var input = ctx.PopArgument();
if (input.TryParseMention(out var id))
if (commands.Length == 0)
{
// Try to resolve the user ID to find the associated account,
// so we can print their username.
var user = await ctx.Rest.GetUser(id);
if (user != null)
return $"Account **{user.Username}#{user.Discriminator}** does not have a system registered.";
return $"Account with ID `{id}` not found.";
await ctx.Reply($"No commands related to `{subject}` was found. For the full list of commands, see the website: <https://pluralkit.me/commands>");
return;
}
return $"System with ID {input.AsCode()} not found.";
await ctx.Reply(
components: [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"Here is a list of commands related to `{subject}`:\n{commands}\nFor a full list of possible commands, see <https://pluralkit.me/commands>.",
}
]
);
}
}

View file

@ -4,646 +4,326 @@ namespace PluralKit.Bot;
public partial class CommandTree
{
public Task ExecuteCommand(Context ctx)
public Task ExecuteCommand(Context ctx, Commands command)
{
if (ctx.Match("system", "s", "account", "acc"))
return HandleSystemCommand(ctx);
if (ctx.Match("member", "m"))
return HandleMemberCommand(ctx);
if (ctx.Match("group", "g"))
return HandleGroupCommand(ctx);
if (ctx.Match("switch", "sw"))
return HandleSwitchCommand(ctx);
if (ctx.Match("commands", "cmd", "c"))
return CommandHelpRoot(ctx);
if (ctx.Match("ap", "autoproxy", "auto"))
return HandleAutoproxyCommand(ctx);
if (ctx.Match("config", "cfg", "configure"))
return HandleConfigCommand(ctx);
if (ctx.Match("serverconfig", "guildconfig", "scfg"))
return HandleServerConfigCommand(ctx);
if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls"))
return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
if (ctx.Match("link"))
return ctx.Execute<SystemLink>(Link, m => m.LinkSystem(ctx));
if (ctx.Match("unlink"))
return ctx.Execute<SystemLink>(Unlink, m => m.UnlinkAccount(ctx));
if (ctx.Match("token"))
if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen"))
return ctx.Execute<Api>(TokenRefresh, m => m.RefreshToken(ctx));
else
return ctx.Execute<Api>(TokenGet, m => m.GetToken(ctx));
if (ctx.Match("import"))
return ctx.Execute<ImportExport>(Import, m => m.Import(ctx));
if (ctx.Match("export"))
return ctx.Execute<ImportExport>(Export, m => m.Export(ctx));
if (ctx.Match("help", "h"))
if (ctx.Match("commands"))
return ctx.Reply("For the list of commands, see the website: <https://pluralkit.me/commands>");
else if (ctx.Match("proxy"))
return ctx.Reply(
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying");
else return ctx.Execute<Help>(Help, m => m.HelpRoot(ctx));
if (ctx.Match("explain"))
return ctx.Execute<Help>(Explain, m => m.Explain(ctx));
if (ctx.Match("message", "msg", "messageinfo"))
return ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx));
if (ctx.Match("edit", "e"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx, false));
if (ctx.Match("x"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx, true));
if (ctx.Match("reproxy", "rp", "crimes", "crime"))
return ctx.Execute<ProxiedMessage>(MessageReproxy, m => m.ReproxyMessage(ctx));
if (ctx.Match("log"))
if (ctx.Match("channel"))
return ctx.Execute<ServerConfig>(LogChannel, m => m.SetLogChannel(ctx), true);
else if (ctx.Match("enable", "on"))
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true), true);
else if (ctx.Match("disable", "off"))
return ctx.Execute<ServerConfig>(LogDisable, m => m.SetLogEnabled(ctx, false), true);
else if (ctx.Match("list", "show"))
return ctx.Execute<ServerConfig>(LogShow, m => m.ShowLogDisabledChannels(ctx), true);
else
return ctx.Reply($"{Emojis.Warn} Message logging commands have moved to `{ctx.DefaultPrefix}serverconfig`.");
if (ctx.Match("logclean"))
return ctx.Execute<ServerConfig>(ServerConfigLogClean, m => m.SetLogCleanup(ctx), true);
if (ctx.Match("blacklist", "bl"))
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(BlacklistAdd, m => m.SetProxyBlacklisted(ctx, true), true);
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(BlacklistRemove, m => m.SetProxyBlacklisted(ctx, false), true);
else if (ctx.Match("list", "show"))
return ctx.Execute<ServerConfig>(BlacklistShow, m => m.ShowProxyBlacklisted(ctx), true);
else
return ctx.Reply($"{Emojis.Warn} Blacklist commands have moved to `{ctx.DefaultPrefix}serverconfig`.");
if (ctx.Match("proxy"))
if (ctx.Match("debug"))
return ctx.Execute<Checks>(ProxyCheck, m => m.MessageProxyCheck(ctx));
else
return ctx.Execute<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx));
if (ctx.Match("invite")) return ctx.Execute<Misc>(Invite, m => m.Invite(ctx));
if (ctx.Match("mn")) return ctx.Execute<Fun>(null, m => m.Mn(ctx));
if (ctx.Match("fire")) return ctx.Execute<Fun>(null, m => m.Fire(ctx));
if (ctx.Match("thunder")) return ctx.Execute<Fun>(null, m => m.Thunder(ctx));
if (ctx.Match("freeze")) return ctx.Execute<Fun>(null, m => m.Freeze(ctx));
if (ctx.Match("starstorm")) return ctx.Execute<Fun>(null, m => m.Starstorm(ctx));
if (ctx.Match("flash")) return ctx.Execute<Fun>(null, m => m.Flash(ctx));
if (ctx.Match("rool")) return ctx.Execute<Fun>(null, m => m.Rool(ctx));
if (ctx.Match("sus")) return ctx.Execute<Fun>(null, m => m.Sus(ctx));
if (ctx.Match("error")) return ctx.Execute<Fun>(null, m => m.Error(ctx));
if (ctx.Match("stats", "status")) return ctx.Execute<Misc>(null, m => m.Stats(ctx));
if (ctx.Match("permcheck"))
return ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx));
if (ctx.Match("proxycheck"))
return ctx.Execute<Checks>(ProxyCheck, m => m.MessageProxyCheck(ctx));
if (ctx.Match("debug"))
return HandleDebugCommand(ctx);
if (ctx.Match("admin"))
return HandleAdminCommand(ctx);
if (ctx.Match("random", "rand", "r"))
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
return ctx.Execute<Random>(GroupRandom, r => r.Group(ctx, ctx.System));
else
return ctx.Execute<Random>(MemberRandom, m => m.Member(ctx, ctx.System));
if (ctx.Match("dashboard", "dash"))
return ctx.Execute<Help>(Dashboard, m => m.Dashboard(ctx));
// don't send an "invalid command" response if the guild has those turned off
if (ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true)
return Task.CompletedTask;
// remove compiler warning
return ctx.Reply(
$"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
}
private async Task HandleAdminAbuseLogCommand(Context ctx)
{
ctx.AssertBotAdmin();
if (ctx.Match("n", "new", "create"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogCreate(ctx));
else
return command switch
{
AbuseLog? abuseLog = null!;
var account = await ctx.MatchUser();
if (account != null)
Commands.CommandsList(var param, _) => PrintCommandList(ctx, param.subject, Parameters.GetRelatedCommands(ctx.DefaultPrefix, param.subject)),
Commands.Dashboard => ctx.Execute<Help>(Dashboard, m => m.Dashboard(ctx)),
Commands.Explain => ctx.Execute<Help>(Explain, m => m.Explain(ctx)),
Commands.Help(_, var flags) => ctx.Execute<Help>(Help, m => m.HelpRoot(ctx, flags.show_embed)),
Commands.HelpCommands => ctx.Reply(
"For the list of commands, see the website: <https://pluralkit.me/commands>"),
Commands.HelpProxy => ctx.Reply(
"The proxy help page has been moved! See the website: https://pluralkit.me/guide#proxying"),
Commands.Invite => ctx.Execute<Misc>(Invite, m => m.Invite(ctx)),
Commands.Stats => ctx.Execute<Misc>(null, m => m.Stats(ctx)),
Commands.MemberShow(var param, var flags) => ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, param.target, flags.show_embed)),
Commands.MemberNew(var param, var flags) => ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx, param.name, flags.yes)),
Commands.MemberSoulscream(var param, _) => ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx, param.target)),
Commands.MemberAvatarShow(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ShowAvatar(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberAvatarClear(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ClearAvatar(ctx, param.target, flags.yes)),
Commands.MemberAvatarUpdate(var param, _) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ChangeAvatar(ctx, param.target, param.avatar)),
Commands.MemberWebhookAvatarShow(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ShowWebhookAvatar(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberWebhookAvatarClear(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ClearWebhookAvatar(ctx, param.target, flags.yes)),
Commands.MemberWebhookAvatarUpdate(var param, _) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ChangeWebhookAvatar(ctx, param.target, param.avatar)),
Commands.MemberServerAvatarShow(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ShowServerAvatar(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberServerAvatarClear(var param, var flags) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ClearServerAvatar(ctx, param.target, flags.yes)),
Commands.MemberServerAvatarUpdate(var param, _) => ctx.Execute<MemberAvatar>(MemberAvatar, m => m.ChangeServerAvatar(ctx, param.target, param.avatar)),
Commands.MemberPronounsShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberPronouns, m => m.ShowPronouns(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberPronounsClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberPronouns, m => m.ClearPronouns(ctx, param.target, flags.yes)),
Commands.MemberPronounsUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberPronouns, m => m.ChangePronouns(ctx, param.target, param.pronouns)),
Commands.MemberDescShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberDesc, m => m.ShowDescription(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberDescClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberDesc, m => m.ClearDescription(ctx, param.target, flags.yes)),
Commands.MemberDescUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberDesc, m => m.ChangeDescription(ctx, param.target, param.description)),
Commands.MemberNameShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberInfo, m => m.ShowName(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberNameUpdate(var param, var flags) => ctx.Execute<MemberEdit>(MemberInfo, m => m.ChangeName(ctx, param.target, param.name, flags.yes)),
Commands.MemberBannerShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberBannerImage, m => m.ShowBannerImage(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberBannerClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberBannerImage, m => m.ClearBannerImage(ctx, param.target, flags.yes)),
Commands.MemberBannerUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberBannerImage, m => m.ChangeBannerImage(ctx, param.target, param.banner)),
Commands.MemberColorShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberColor, m => m.ShowColor(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberColorClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberColor, m => m.ClearColor(ctx, param.target, flags.yes)),
Commands.MemberColorUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberColor, m => m.ChangeColor(ctx, param.target, param.color)),
Commands.MemberBirthdayShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberBirthday, m => m.ShowBirthday(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberBirthdayClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberBirthday, m => m.ClearBirthday(ctx, param.target, flags.yes)),
Commands.MemberBirthdayUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberBirthday, m => m.ChangeBirthday(ctx, param.target, param.birthday)),
Commands.MemberDisplaynameShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberDisplayName, m => m.ShowDisplayName(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberDisplaynameClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberDisplayName, m => m.ClearDisplayName(ctx, param.target, flags.yes)),
Commands.MemberDisplaynameUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberDisplayName, m => m.ChangeDisplayName(ctx, param.target, param.name)),
Commands.MemberServernameShow(var param, var flags) => ctx.Execute<MemberEdit>(MemberServerName, m => m.ShowServerName(ctx, param.target, flags.GetReplyFormat())),
Commands.MemberServernameClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberServerName, m => m.ClearServerName(ctx, param.target, flags.yes)),
Commands.MemberServernameUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberServerName, m => m.ChangeServerName(ctx, param.target, param.name)),
Commands.MemberKeepproxyShow(var param, _) => ctx.Execute<MemberEdit>(MemberKeepProxy, m => m.ShowKeepProxy(ctx, param.target)),
Commands.MemberKeepproxyUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberKeepProxy, m => m.ChangeKeepProxy(ctx, param.target, param.value)),
Commands.MemberServerKeepproxyShow(var param, _) => ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ShowServerKeepProxy(ctx, param.target)),
Commands.MemberServerKeepproxyUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ChangeServerKeepProxy(ctx, param.target, param.value)),
Commands.MemberServerKeepproxyClear(var param, var flags) => ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ClearServerKeepProxy(ctx, param.target, flags.yes)),
Commands.MemberProxyShow(var param, _) => ctx.Execute<MemberProxy>(MemberProxy, m => m.ShowProxy(ctx, param.target)),
Commands.MemberProxyClear(var param, var flags) => ctx.Execute<MemberProxy>(MemberProxy, m => m.ClearProxy(ctx, param.target, flags.yes)),
Commands.MemberProxyAdd(var param, var flags) => ctx.Execute<MemberProxy>(MemberProxy, m => m.AddProxy(ctx, param.target, param.tag, flags.yes)),
Commands.MemberProxyRemove(var param, _) => ctx.Execute<MemberProxy>(MemberProxy, m => m.RemoveProxy(ctx, param.target, param.tag)),
Commands.MemberProxySet(var param, var flags) => ctx.Execute<MemberProxy>(MemberProxy, m => m.SetProxy(ctx, param.target, param.tags, flags.yes)),
Commands.MemberTtsShow(var param, _) => ctx.Execute<MemberEdit>(MemberTts, m => m.ShowTts(ctx, param.target)),
Commands.MemberTtsUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberTts, m => m.ChangeTts(ctx, param.target, param.value)),
Commands.MemberAutoproxyShow(var param, _) => ctx.Execute<MemberEdit>(MemberAutoproxy, m => m.ShowAutoproxy(ctx, param.target)),
Commands.MemberAutoproxyUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberAutoproxy, m => m.ChangeAutoproxy(ctx, param.target, param.value)),
Commands.MemberDelete(var param, _) => ctx.Execute<MemberEdit>(MemberDelete, m => m.Delete(ctx, param.target)),
Commands.MemberPrivacyShow(var param, _) => ctx.Execute<MemberEdit>(MemberPrivacy, m => m.ShowPrivacy(ctx, param.target)),
Commands.MemberPrivacyUpdate(var param, _) => ctx.Execute<MemberEdit>(MemberPrivacy, m => m.ChangePrivacy(ctx, param.target, param.member_privacy_target, param.new_privacy_level)),
Commands.MemberGroupAdd(var param, _) => ctx.Execute<GroupMember>(MemberGroupAdd, m => m.AddRemoveGroups(ctx, param.target, param.groups, Groups.AddRemoveOperation.Add)),
Commands.MemberGroupRemove(var param, _) => ctx.Execute<GroupMember>(MemberGroupRemove, m => m.AddRemoveGroups(ctx, param.target, param.groups, Groups.AddRemoveOperation.Remove)),
Commands.MemberId(var param, _) => ctx.Execute<Member>(MemberId, m => m.DisplayId(ctx, param.target)),
Commands.CfgShow => ctx.Execute<Config>(null, m => m.ShowConfig(ctx)),
Commands.CfgApAccountShow => ctx.Execute<Config>(null, m => m.ViewAutoproxyAccount(ctx)),
Commands.CfgApAccountUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditAutoproxyAccount(ctx, param.toggle)),
Commands.CfgApTimeoutShow => ctx.Execute<Config>(null, m => m.ViewAutoproxyTimeout(ctx)),
Commands.CfgApTimeoutOff => ctx.Execute<Config>(null, m => m.DisableAutoproxyTimeout(ctx)),
Commands.CfgApTimeoutReset => ctx.Execute<Config>(null, m => m.ResetAutoproxyTimeout(ctx)),
Commands.CfgApTimeoutUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditAutoproxyTimeout(ctx, param.timeout)),
Commands.CfgTimezoneShow => ctx.Execute<Config>(null, m => m.ViewSystemTimezone(ctx)),
Commands.CfgTimezoneReset => ctx.Execute<Config>(null, m => m.ResetSystemTimezone(ctx)),
Commands.CfgTimezoneUpdate(var param, var flags) => ctx.Execute<Config>(null, m => m.EditSystemTimezone(ctx, param.timezone, flags.yes)),
Commands.CfgPingShow => ctx.Execute<Config>(null, m => m.ViewSystemPing(ctx)),
Commands.CfgPingUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditSystemPing(ctx, param.toggle)),
Commands.CfgMemberPrivacyShow => ctx.Execute<Config>(null, m => m.ViewMemberDefaultPrivacy(ctx)),
Commands.CfgMemberPrivacyUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditMemberDefaultPrivacy(ctx, param.toggle)),
Commands.CfgGroupPrivacyShow => ctx.Execute<Config>(null, m => m.ViewGroupDefaultPrivacy(ctx)),
Commands.CfgGroupPrivacyUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditGroupDefaultPrivacy(ctx, param.toggle)),
Commands.CfgShowPrivateInfoShow => ctx.Execute<Config>(null, m => m.ViewShowPrivateInfo(ctx)),
Commands.CfgShowPrivateInfoUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditShowPrivateInfo(ctx, param.toggle)),
Commands.CfgCaseSensitiveProxyTagsShow => ctx.Execute<Config>(null, m => m.ViewCaseSensitiveProxyTags(ctx)),
Commands.CfgCaseSensitiveProxyTagsUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditCaseSensitiveProxyTags(ctx, param.toggle)),
Commands.CfgProxyErrorMessageShow => ctx.Execute<Config>(null, m => m.ViewProxyErrorMessageEnabled(ctx)),
Commands.CfgProxyErrorMessageUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditProxyErrorMessageEnabled(ctx, param.toggle)),
Commands.CfgHidSplitShow => ctx.Execute<Config>(null, m => m.ViewHidDisplaySplit(ctx)),
Commands.CfgHidSplitUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditHidDisplaySplit(ctx, param.toggle)),
Commands.CfgHidCapsShow => ctx.Execute<Config>(null, m => m.ViewHidDisplayCaps(ctx)),
Commands.CfgHidCapsUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditHidDisplayCaps(ctx, param.toggle)),
Commands.CfgHidPaddingShow => ctx.Execute<Config>(null, m => m.ViewHidListPadding(ctx)),
Commands.CfgHidPaddingUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditHidListPadding(ctx, param.padding)),
Commands.CfgCardShowColorHexShow => ctx.Execute<Config>(null, m => m.ViewCardShowColorHex(ctx)),
Commands.CfgCardShowColorHexUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditCardShowColorHex(ctx, param.toggle)),
Commands.CfgProxySwitchShow => ctx.Execute<Config>(null, m => m.ViewProxySwitch(ctx)),
Commands.CfgProxySwitchUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditProxySwitch(ctx, param.proxy_switch_action)),
Commands.CfgNameFormatShow => ctx.Execute<Config>(null, m => m.ViewNameFormat(ctx)),
Commands.CfgNameFormatReset => ctx.Execute<Config>(null, m => m.ResetNameFormat(ctx)),
Commands.CfgNameFormatUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditNameFormat(ctx, param.format)),
Commands.CfgServerNameFormatShow(_, var flags) => ctx.Execute<Config>(null, m => m.ViewServerNameFormat(ctx, flags.GetReplyFormat())),
Commands.CfgServerNameFormatReset => ctx.Execute<Config>(null, m => m.ResetServerNameFormat(ctx)),
Commands.CfgServerNameFormatUpdate(var param, _) => ctx.Execute<Config>(null, m => m.EditServerNameFormat(ctx, param.format)),
Commands.CfgLimitsUpdate => ctx.Execute<Config>(null, m => m.LimitUpdate(ctx)),
Commands.FunThunder => ctx.Execute<Fun>(null, m => m.Thunder(ctx)),
Commands.FunMeow => ctx.Execute<Fun>(null, m => m.Meow(ctx)),
Commands.FunPokemon => ctx.Execute<Fun>(null, m => m.Mn(ctx)),
Commands.FunFire => ctx.Execute<Fun>(null, m => m.Fire(ctx)),
Commands.FunFreeze => ctx.Execute<Fun>(null, m => m.Freeze(ctx)),
Commands.FunStarstorm => ctx.Execute<Fun>(null, m => m.Starstorm(ctx)),
Commands.FunFlash => ctx.Execute<Fun>(null, m => m.Flash(ctx)),
Commands.FunRool => ctx.Execute<Fun>(null, m => m.Rool(ctx)),
Commands.Amogus => ctx.Execute<Fun>(null, m => m.Sus(ctx)),
Commands.FunError => ctx.Execute<Fun>(null, m => m.Error(ctx)),
Commands.SystemInfo(var param, var flags) => ctx.Execute<System>(SystemInfo, m => m.Query(ctx, param.target ?? ctx.System, flags.all, flags.@public, flags.@private)),
Commands.SystemNew(var param, _) => ctx.Execute<System>(SystemNew, m => m.New(ctx, param.name)),
Commands.SystemShowName(var param, var flags) => ctx.Execute<SystemEdit>(SystemRename, m => m.ShowName(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemRename(var param, _) => ctx.Execute<SystemEdit>(SystemRename, m => m.Rename(ctx, ctx.System, param.name)),
Commands.SystemShowServerName(var param, var flags) => ctx.Execute<SystemEdit>(SystemServerName, m => m.ShowServerName(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearServerName(var param, var flags) => ctx.Execute<SystemEdit>(SystemServerName, m => m.ClearServerName(ctx, ctx.System, flags.yes)),
Commands.SystemRenameServerName(var param, _) => ctx.Execute<SystemEdit>(SystemServerName, m => m.RenameServerName(ctx, ctx.System, param.name)),
Commands.SystemShowDescription(var param, var flags) => ctx.Execute<SystemEdit>(SystemDesc, m => m.ShowDescription(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearDescription(var param, var flags) => ctx.Execute<SystemEdit>(SystemDesc, m => m.ClearDescription(ctx, ctx.System, flags.yes)),
Commands.SystemChangeDescription(var param, _) => ctx.Execute<SystemEdit>(SystemDesc, m => m.ChangeDescription(ctx, ctx.System, param.description)),
Commands.SystemShowColor(var param, var flags) => ctx.Execute<SystemEdit>(SystemColor, m => m.ShowColor(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearColor(var param, var flags) => ctx.Execute<SystemEdit>(SystemColor, m => m.ClearColor(ctx, ctx.System, flags.yes)),
Commands.SystemChangeColor(var param, _) => ctx.Execute<SystemEdit>(SystemColor, m => m.ChangeColor(ctx, ctx.System, param.color)),
Commands.SystemShowTag(var param, var flags) => ctx.Execute<SystemEdit>(SystemTag, m => m.ShowTag(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearTag(var param, var flags) => ctx.Execute<SystemEdit>(SystemTag, m => m.ClearTag(ctx, ctx.System, flags.yes)),
Commands.SystemChangeTag(var param, _) => ctx.Execute<SystemEdit>(SystemTag, m => m.ChangeTag(ctx, ctx.System, param.tag)),
Commands.SystemShowServerTag(var param, var flags) => ctx.Execute<SystemEdit>(SystemServerTag, m => m.ShowServerTag(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearServerTag(var param, var flags) => ctx.Execute<SystemEdit>(SystemServerTag, m => m.ClearServerTag(ctx, ctx.System, flags.yes)),
Commands.SystemChangeServerTag(var param, _) => ctx.Execute<SystemEdit>(SystemServerTag, m => m.ChangeServerTag(ctx, ctx.System, param.tag)),
Commands.SystemShowPronouns(var param, var flags) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ShowPronouns(ctx, param.target ?? ctx.System, flags.GetReplyFormat())),
Commands.SystemClearPronouns(var param, var flags) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ClearPronouns(ctx, ctx.System, flags.yes)),
Commands.SystemChangePronouns(var param, _) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ChangePronouns(ctx, ctx.System, param.pronouns)),
Commands.SystemShowAvatar(var param, var flags) => ((Func<Task>)(() =>
{
abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id);
}
else
{
abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(ctx.PopArgument()));
}
if (abuseLog == null)
{
await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query.");
return;
}
if (!ctx.HasNext())
await ctx.Execute<Admin>(Admin, a => a.AbuseLogShow(ctx, abuseLog));
else if (ctx.Match("au", "adduser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogAddUser(ctx, abuseLog));
else if (ctx.Match("ru", "removeuser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogRemoveUser(ctx, abuseLog));
else if (ctx.Match("desc", "description"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDescription(ctx, abuseLog));
else if (ctx.Match("deny", "deny-bot-usage"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogFlagDeny(ctx, abuseLog));
else if (ctx.Match("yeet", "remove", "delete"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDelete(ctx, abuseLog));
else
await ctx.Reply($"{Emojis.Error} Unknown subcommand {ctx.PeekArgument().AsCode()}.");
}
}
private async Task HandleAdminCommand(Context ctx)
{
if (ctx.Match("usid", "updatesystemid"))
await ctx.Execute<Admin>(Admin, a => a.UpdateSystemId(ctx));
else if (ctx.Match("umid", "updatememberid"))
await ctx.Execute<Admin>(Admin, a => a.UpdateMemberId(ctx));
else if (ctx.Match("ugid", "updategroupid"))
await ctx.Execute<Admin>(Admin, a => a.UpdateGroupId(ctx));
else if (ctx.Match("rsid", "rerollsystemid"))
await ctx.Execute<Admin>(Admin, a => a.RerollSystemId(ctx));
else if (ctx.Match("rmid", "rerollmemberid"))
await ctx.Execute<Admin>(Admin, a => a.RerollMemberId(ctx));
else if (ctx.Match("rgid", "rerollgroupid"))
await ctx.Execute<Admin>(Admin, a => a.RerollGroupId(ctx));
else if (ctx.Match("uml", "updatememberlimit"))
await ctx.Execute<Admin>(Admin, a => a.SystemMemberLimit(ctx));
else if (ctx.Match("ugl", "updategrouplimit"))
await ctx.Execute<Admin>(Admin, a => a.SystemGroupLimit(ctx));
else if (ctx.Match("sr", "systemrecover"))
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx));
else if (ctx.Match("sendmsg", "sendmessage"))
await ctx.Execute<Admin>(Admin, a => a.SendAdminMessage(ctx));
else if (ctx.Match("al", "abuselog"))
await HandleAdminAbuseLogCommand(ctx);
else
await ctx.Reply($"{Emojis.Error} Unknown command.");
}
private async Task HandleDebugCommand(Context ctx)
{
var availableCommandsStr = "Available debug targets: `permissions`, `proxying`";
if (ctx.Match("permissions", "perms", "permcheck"))
if (ctx.Match("channel", "ch"))
await ctx.Execute<Checks>(PermCheck, m => m.PermCheckChannel(ctx));
else
await ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx));
else if (ctx.Match("channel"))
await ctx.Execute<Checks>(PermCheck, m => m.PermCheckChannel(ctx));
else if (ctx.Match("proxy", "proxying", "proxycheck"))
await ctx.Execute<Checks>(ProxyCheck, m => m.MessageProxyCheck(ctx));
else if (!ctx.HasNext())
await ctx.Reply($"{Emojis.Error} You need to pass a command. {availableCommandsStr}");
else
await ctx.Reply(
$"{Emojis.Error} Unknown debug command {ctx.PeekArgument().AsCode()}. {availableCommandsStr}");
}
private async Task HandleSystemCommand(Context ctx)
{
// these commands never take a system target
if (ctx.Match("new", "create", "make", "add", "register", "init", "n"))
await ctx.Execute<System>(SystemNew, m => m.New(ctx));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "systems", SystemCommands);
// todo: these aren't deprecated but also shouldn't be here
else if (ctx.Match("webhook", "hook"))
await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx));
else if (ctx.Match("proxy"))
await ctx.Execute<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx));
// finally, parse commands that *can* take a system target
else
{
// try matching a system ID
var target = await ctx.MatchSystem();
var previousPtr = ctx.Parameters._ptr;
// if we have a parsed target and no more commands, don't bother with the command flow
// we skip the `target != null` check here since the argument isn't be popped if it's not a system
if (!ctx.HasNext())
{
await ctx.Execute<System>(SystemInfo, m => m.Query(ctx, target ?? ctx.System));
return;
}
// hacky, but we need to CheckSystem(target) which throws a PKError
// normally PKErrors are only handled in ctx.Execute
try
{
await HandleSystemCommandTargeted(ctx, target ?? ctx.System);
}
catch (PKError e)
{
await ctx.Reply($"{Emojis.Error} {e.Message}");
return;
}
// if we *still* haven't matched anything, the user entered an invalid command name or system reference
if (ctx.Parameters._ptr == previousPtr)
{
if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _))
if (param.target == null)
{
await PrintCommandNotFoundError(ctx, SystemCommands);
return;
// we want to change avatar if an attached image is passed
// we can't have a separate parsed command for this since the parser can't be aware of any attachments
var attachedImage = ctx.ExtractImageFromAttachment();
if (attachedImage is { } image)
return ctx.Execute<SystemEdit>(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, image));
}
var list = CreatePotentialCommandList(ctx.DefaultPrefix, SystemCommands);
await ctx.Reply($"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\n"
+ $"Perhaps you meant to use one of the following commands?\n{list}");
}
}
}
private async Task HandleSystemCommandTargeted(Context ctx, PKSystem target)
{
if (ctx.Match("name", "rename", "changename", "rn"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemRename, m => m.Name(ctx, target));
else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname",
"serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn"))
await ctx.Execute<SystemEdit>(SystemServerName, m => m.ServerName(ctx, target));
else if (ctx.Match("tag", "t"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemTag, m => m.Tag(ctx, target));
else if (ctx.Match("servertag", "st", "stag", "deer"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemServerTag, m => m.ServerTag(ctx, target));
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDesc, m => m.Description(ctx, target));
else if (ctx.Match("pronouns", "pronoun", "prns", "pn"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemPronouns, m => m.Pronouns(ctx, target));
else if (ctx.Match("color", "colour"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemColor, m => m.Color(ctx, target));
else if (ctx.Match("banner", "splash", "cover"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemBannerImage, m => m.BannerImage(ctx, target));
else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemAvatar, m => m.Avatar(ctx, target));
else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic",
"guildavatar", "guildpic", "guildicon", "sicon", "spfp"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemServerAvatar, m => m.ServerAvatar(ctx, target));
else if (ctx.Match("list", "l", "members", "ls"))
await ctx.CheckSystem(target).Execute<SystemList>(SystemList, m => m.MemberList(ctx, target));
else if (ctx.Match("find", "search", "query", "fd", "s"))
await ctx.CheckSystem(target).Execute<SystemList>(SystemFind, m => m.MemberList(ctx, target));
else if (ctx.Match("f", "front", "fronter", "fronters"))
{
if (ctx.Match("h", "history"))
await ctx.CheckSystem(target).Execute<SystemFront>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target));
else if (ctx.Match("p", "percent", "%"))
await ctx.CheckSystem(target).Execute<SystemFront>(SystemFrontPercent, m => m.FrontPercent(ctx, system: target));
else
await ctx.CheckSystem(target).Execute<SystemFront>(SystemFronter, m => m.SystemFronter(ctx, target));
}
else if (ctx.Match("fh", "fronthistory", "history", "switches"))
await ctx.CheckSystem(target).Execute<SystemFront>(SystemFrontHistory, m => m.SystemFrontHistory(ctx, target));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.CheckSystem(target).Execute<SystemFront>(SystemFrontPercent, m => m.FrontPercent(ctx, system: target));
else if (ctx.Match("info", "view", "show"))
await ctx.CheckSystem(target).Execute<System>(SystemInfo, m => m.Query(ctx, target));
else if (ctx.Match("groups", "gs"))
await ctx.CheckSystem(target).Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, target));
else if (ctx.Match("privacy"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemPrivacy, m => m.SystemPrivacy(ctx, target));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx, target));
else if (ctx.Match("id"))
await ctx.CheckSystem(target).Execute<System>(SystemId, m => m.DisplayId(ctx, target));
else if (ctx.Match("random", "rand", "r"))
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
await ctx.CheckSystem(target).Execute<Random>(GroupRandom, r => r.Group(ctx, target));
else
await ctx.CheckSystem(target).Execute<Random>(MemberRandom, m => m.Member(ctx, target));
}
private async Task HandleMemberCommand(Context ctx)
{
if (ctx.Match("new", "n", "add", "create", "register"))
await ctx.Execute<Member>(MemberNew, m => m.NewMember(ctx));
else if (ctx.Match("list"))
await ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "members", MemberCommands);
else if (await ctx.MatchMember() is PKMember target)
await HandleMemberCommandTargeted(ctx, target);
else if (!ctx.HasNext())
await PrintCommandExpectedError(ctx, MemberNew, MemberInfo, MemberRename, MemberDisplayName,
MemberServerName, MemberDesc, MemberPronouns,
MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar);
else
await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Member", 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", "rn"))
await ctx.Execute<MemberEdit>(MemberRename, m => m.Name(ctx, target));
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.Execute<MemberEdit>(MemberDesc, m => m.Description(ctx, target));
else if (ctx.Match("pronouns", "pronoun", "prns", "pn"))
await ctx.Execute<MemberEdit>(MemberPronouns, m => m.Pronouns(ctx, target));
else if (ctx.Match("color", "colour"))
await ctx.Execute<MemberEdit>(MemberColor, m => m.Color(ctx, target));
else if (ctx.Match("birthday", "birth", "bday", "birthdate", "cakeday", "bdate", "bd"))
await ctx.Execute<MemberEdit>(MemberBirthday, m => m.Birthday(ctx, target));
else if (ctx.Match("proxy", "tags", "proxytags", "brackets"))
await ctx.Execute<MemberProxy>(MemberProxy, m => m.Proxy(ctx, target));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.Execute<MemberEdit>(MemberDelete, m => m.Delete(ctx, target));
else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic"))
await ctx.Execute<MemberAvatar>(MemberAvatar, m => m.Avatar(ctx, target));
else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa", "pavatar", "ppfp"))
await ctx.Execute<MemberAvatar>(MemberAvatar, m => m.WebhookAvatar(ctx, target));
else if (ctx.Match("banner", "splash", "cover"))
await ctx.Execute<MemberEdit>(MemberBannerImage, m => m.BannerImage(ctx, target));
else if (ctx.Match("group", "groups", "g"))
if (ctx.Match("add", "a"))
await ctx.Execute<GroupMember>(MemberGroupAdd,
m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem"))
await ctx.Execute<GroupMember>(MemberGroupRemove,
m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Remove));
else
await ctx.Execute<GroupMember>(MemberGroups, m => m.ListMemberGroups(ctx, target));
else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic",
"guildavatar", "guildpic", "guildicon", "sicon", "spfp"))
await ctx.Execute<MemberAvatar>(MemberServerAvatar, m => m.ServerAvatar(ctx, target));
else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname"))
await ctx.Execute<MemberEdit>(MemberDisplayName, m => m.DisplayName(ctx, target));
else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname",
"serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn"))
await ctx.Execute<MemberEdit>(MemberServerName, m => m.ServerName(ctx, target));
else if (ctx.Match("autoproxy", "ap"))
await ctx.Execute<MemberEdit>(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target));
else if (ctx.Match("keepproxy", "keeptags", "showtags", "kp"))
await ctx.Execute<MemberEdit>(MemberKeepProxy, m => m.KeepProxy(ctx, target));
else if (ctx.Match("texttospeech", "text-to-speech", "tts"))
await ctx.Execute<MemberEdit>(MemberTts, m => m.Tts(ctx, target));
else if (ctx.Match("serverkeepproxy", "servershowtags", "guildshowtags", "guildkeeptags", "serverkeeptags", "skp"))
await ctx.Execute<MemberEdit>(MemberServerKeepProxy, m => m.ServerKeepProxy(ctx, target));
else if (ctx.Match("id"))
await ctx.Execute<Member>(MemberId, m => m.DisplayId(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null));
else if (ctx.Match("private", "hidden", "hide"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("public", "shown", "show", "unhide", "unhidden"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("soulscream"))
await ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx, target));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, target));
else
await PrintCommandNotFoundError(ctx, MemberInfo, MemberRename, MemberDisplayName, MemberServerName,
MemberDesc, MemberPronouns, MemberColor, MemberBirthday, MemberProxy, MemberDelete, MemberAvatar,
SystemList);
}
private async Task HandleGroupCommand(Context ctx)
{
// Commands with no group argument
if (ctx.Match("n", "new"))
await ctx.Execute<Groups>(GroupNew, g => g.CreateGroup(ctx));
else if (ctx.Match("list", "l"))
await ctx.Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, null));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "groups", GroupCommands);
else if (await ctx.MatchGroup() is { } target)
{
// Commands with group argument
if (ctx.Match("rename", "name", "changename", "setname", "rn"))
await ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, target));
else if (ctx.Match("nick", "dn", "displayname", "nickname"))
await ctx.Execute<Groups>(GroupDisplayName, g => g.GroupDisplayName(ctx, target));
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.Execute<Groups>(GroupDesc, g => g.GroupDescription(ctx, target));
else if (ctx.Match("add", "a"))
await ctx.Execute<GroupMember>(GroupAdd,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem"))
await ctx.Execute<GroupMember>(GroupRemove,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove));
else if (ctx.Match("members", "list", "ms", "l", "ls"))
await ctx.Execute<GroupMember>(GroupMemberList, g => g.ListGroupMembers(ctx, target));
else if (ctx.Match("random", "rand", "r"))
await ctx.Execute<Random>(GroupMemberRandom, r => r.GroupMember(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null));
else if (ctx.Match("public", "pub"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("private", "priv"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("delete", "destroy", "erase", "yeet"))
await ctx.Execute<Groups>(GroupDelete, g => g.DeleteGroup(ctx, target));
else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp"))
await ctx.Execute<Groups>(GroupIcon, g => g.GroupIcon(ctx, target));
else if (ctx.Match("banner", "splash", "cover"))
await ctx.Execute<Groups>(GroupBannerImage, g => g.GroupBannerImage(ctx, target));
else if (ctx.Match("fp", "frontpercent", "front%", "frontbreakdown"))
await ctx.Execute<SystemFront>(GroupFrontPercent, g => g.FrontPercent(ctx, group: target));
else if (ctx.Match("color", "colour"))
await ctx.Execute<Groups>(GroupColor, g => g.GroupColor(ctx, target));
else if (ctx.Match("id"))
await ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, target));
else if (!ctx.HasNext())
await ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, target));
else
await PrintCommandNotFoundError(ctx, GroupCommandsTargeted);
}
else if (!ctx.HasNext())
await PrintCommandExpectedError(ctx, GroupCommands);
else
await ctx.Reply($"{Emojis.Error} {ctx.CreateNotFoundError("Group", ctx.PopArgument())}");
}
private async Task HandleSwitchCommand(Context ctx)
{
if (ctx.Match("out"))
await ctx.Execute<Switch>(SwitchOut, m => m.SwitchOut(ctx));
else if (ctx.Match("move", "m", "shift", "offset"))
await ctx.Execute<Switch>(SwitchMove, m => m.SwitchMove(ctx));
else if (ctx.Match("edit", "e", "replace"))
if (ctx.Match("out"))
await ctx.Execute<Switch>(SwitchEditOut, m => m.SwitchEditOut(ctx));
else
await ctx.Execute<Switch>(SwitchEdit, m => m.SwitchEdit(ctx));
else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet"))
await ctx.Execute<Switch>(SwitchDelete, m => m.SwitchDelete(ctx));
else if (ctx.Match("copy", "add", "duplicate", "dupe"))
await ctx.Execute<Switch>(SwitchCopy, m => m.SwitchEdit(ctx, true));
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "switching", SwitchCommands);
else if (ctx.HasNext()) // there are following arguments
await ctx.Execute<Switch>(Switch, m => m.SwitchDo(ctx));
else
await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut,
SwitchDelete, SwitchCopy, SystemFronter, SystemFrontHistory);
}
private async Task CommandHelpRoot(Context ctx)
{
if (!ctx.HasNext())
{
await ctx.Reply(
"Available command help targets: `system`, `member`, `group`, `switch`, `config`, `autoproxy`, `log`, `blacklist`."
+ $"\n- **{ctx.DefaultPrefix}commands <target>** - *View commands related to a help target.*"
+ "\n\nFor the full list of commands, see the website: <https://pluralkit.me/commands>");
return;
}
switch (ctx.PeekArgument())
{
case "system":
case "systems":
case "s":
case "account":
case "acc":
await PrintCommandList(ctx, "systems", SystemCommands);
break;
case "member":
case "members":
case "m":
await PrintCommandList(ctx, "members", MemberCommands);
break;
case "group":
case "groups":
case "g":
await PrintCommandList(ctx, "groups", GroupCommands);
break;
case "switch":
case "switches":
case "switching":
case "sw":
await PrintCommandList(ctx, "switching", SwitchCommands);
break;
case "log":
await PrintCommandList(ctx, "message logging", LogCommands);
break;
case "blacklist":
case "bl":
await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands);
break;
case "config":
case "cfg":
await PrintCommandList(ctx, "settings", ConfigCommands);
break;
case "serverconfig":
case "guildconfig":
case "scfg":
await PrintCommandList(ctx, "server settings", ServerConfigCommands);
break;
case "autoproxy":
case "ap":
await PrintCommandList(ctx, "autoproxy", AutoproxyCommands);
break;
default:
await ctx.Reply("For the full list of commands, see the website: <https://pluralkit.me/commands>");
break;
}
}
private Task HandleAutoproxyCommand(Context ctx)
{
// ctx.CheckSystem();
// oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything.
// so we just emulate checking and throwing an error.
if (ctx.System == null)
return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError(ctx.DefaultPrefix).Message}");
return ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx));
}
private Task HandleConfigCommand(Context ctx)
{
if (ctx.System == null)
return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError(ctx.DefaultPrefix).Message}");
if (!ctx.HasNext())
return ctx.Execute<Config>(null, m => m.ShowConfig(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" }))
return ctx.Execute<Config>(null, m => m.AutoproxyAccount(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" }))
return ctx.Execute<Config>(null, m => m.AutoproxyTimeout(ctx));
if (ctx.Match("timezone", "zone", "tz"))
return ctx.Execute<Config>(null, m => m.SystemTimezone(ctx));
if (ctx.Match("ping"))
return ctx.Execute<Config>(null, m => m.SystemPing(ctx));
if (ctx.MatchMultiple(new[] { "private" }, new[] { "member" }) || ctx.Match("mp"))
return ctx.Execute<Config>(null, m => m.MemberDefaultPrivacy(ctx));
if (ctx.MatchMultiple(new[] { "private" }, new[] { "group" }) || ctx.Match("gp"))
return ctx.Execute<Config>(null, m => m.GroupDefaultPrivacy(ctx));
if (ctx.MatchMultiple(new[] { "show" }, new[] { "private" }) || ctx.Match("sp"))
return ctx.Execute<Config>(null, m => m.ShowPrivateInfo(ctx));
if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "case" }))
return ctx.Execute<Config>(null, m => m.CaseSensitiveProxyTags(ctx));
if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "error" }) || ctx.Match("pe"))
return ctx.Execute<Config>(null, m => m.ProxyErrorMessageEnabled(ctx));
if (ctx.MatchMultiple(new[] { "split" }, new[] { "id", "ids" }) || ctx.Match("sid", "sids"))
return ctx.Execute<Config>(null, m => m.HidDisplaySplit(ctx));
if (ctx.MatchMultiple(new[] { "cap", "caps", "capitalize", "capitalise" }, new[] { "id", "ids" }) || ctx.Match("capid", "capids"))
return ctx.Execute<Config>(null, m => m.HidDisplayCaps(ctx));
if (ctx.MatchMultiple(new[] { "pad" }, new[] { "id", "ids" }) || ctx.MatchMultiple(new[] { "id" }, new[] { "pad", "padding" }) || ctx.Match("idpad", "padid", "padids"))
return ctx.Execute<Config>(null, m => m.HidListPadding(ctx));
if (ctx.MatchMultiple(new[] { "show" }, new[] { "color", "colour", "colors", "colours" }) || ctx.Match("showcolor", "showcolour", "showcolors", "showcolours", "colorcode", "colorhex"))
return ctx.Execute<Config>(null, m => m.CardShowColorHex(ctx));
if (ctx.MatchMultiple(new[] { "name" }, new[] { "format" }) || ctx.Match("nameformat", "nf"))
return ctx.Execute<Config>(null, m => m.NameFormat(ctx));
if (ctx.MatchMultiple(new[] { "member", "group" }, new[] { "limit" }) || ctx.Match("limit"))
return ctx.Execute<Config>(null, m => m.LimitUpdate(ctx));
if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "switch" }) || ctx.Match("proxyswitch", "ps"))
return ctx.Execute<Config>(null, m => m.ProxySwitch(ctx));
if (ctx.MatchMultiple(new[] { "server" }, new[] { "name" }, new[] { "format" }) || ctx.MatchMultiple(new[] { "server", "servername" }, new[] { "format", "nameformat", "nf" }) || ctx.Match("snf", "servernf", "servernameformat", "snameformat"))
return ctx.Execute<Config>(null, m => m.ServerNameFormat(ctx));
// todo: maybe add the list of configuration keys here?
return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `{ctx.DefaultPrefix}commands config` for the list of possible config settings.");
}
private Task HandleServerConfigCommand(Context ctx)
{
if (!ctx.HasNext())
return ctx.Execute<ServerConfig>(null, m => m.ShowConfig(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "cleanup", "clean" }) || ctx.Match("logclean"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogCleanup(ctx));
if (ctx.MatchMultiple(new[] { "invalid", "unknown" }, new[] { "command" }, new[] { "error", "response" }) || ctx.Match("invalidcommanderror", "unknowncommanderror"))
return ctx.Execute<ServerConfig>(null, m => m.InvalidCommandResponse(ctx));
if (ctx.MatchMultiple(new[] { "require", "enforce" }, new[] { "tag", "systemtag" }) || ctx.Match("requiretag", "enforcetag"))
return ctx.Execute<ServerConfig>(null, m => m.RequireSystemTag(ctx));
if (ctx.MatchMultiple(new[] { "suppress" }, new[] { "notifications" }) || ctx.Match("proxysilent"))
return ctx.Execute<ServerConfig>(null, m => m.SuppressNotifications(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "channel" }))
return ctx.Execute<ServerConfig>(null, m => m.SetLogChannel(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "blacklist" }))
{
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogBlacklisted(ctx, true));
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogBlacklisted(ctx, false));
else
return ctx.Execute<ServerConfig>(null, m => m.ShowLogDisabledChannels(ctx));
}
if (ctx.MatchMultiple(new[] { "proxy", "proxying" }, new[] { "blacklist" }))
{
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(null, m => m.SetProxyBlacklisted(ctx, true));
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(null, m => m.SetProxyBlacklisted(ctx, false));
else
return ctx.Execute<ServerConfig>(null, m => m.ShowProxyBlacklisted(ctx));
}
// todo: maybe add the list of configuration keys here?
return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `{ctx.DefaultPrefix}commands serverconfig` for the list of possible config settings.");
// if no attachment show the avatar like intended
return ctx.Execute<SystemEdit>(SystemAvatar, m => m.ShowAvatar(ctx, param.target ?? ctx.System, flags.GetReplyFormat()));
}))(),
Commands.SystemClearAvatar(var param, var flags) => ctx.Execute<SystemEdit>(SystemAvatar, m => m.ClearAvatar(ctx, ctx.System, flags.yes)),
Commands.SystemChangeAvatar(var param, _) => ctx.Execute<SystemEdit>(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, param.avatar)),
Commands.SystemShowServerAvatar(var param, var flags) => ((Func<Task>)(() =>
{
if (param.target == null)
{
// we want to change avatar if an attached image is passed
// we can't have a separate parsed command for this since the parser can't be aware of any attachments
var attachedImage = ctx.ExtractImageFromAttachment();
if (attachedImage is { } image)
return ctx.Execute<SystemEdit>(SystemServerAvatar, m => m.ChangeServerAvatar(ctx, ctx.System, image));
}
// if no attachment show the avatar like intended
return ctx.Execute<SystemEdit>(SystemServerAvatar, m => m.ShowServerAvatar(ctx, param.target ?? ctx.System, flags.GetReplyFormat()));
}))(),
Commands.SystemClearServerAvatar(var param, var flags) => ctx.Execute<SystemEdit>(SystemServerAvatar, m => m.ClearServerAvatar(ctx, ctx.System, flags.yes)),
Commands.SystemChangeServerAvatar(var param, _) => ctx.Execute<SystemEdit>(SystemServerAvatar, m => m.ChangeServerAvatar(ctx, ctx.System, param.avatar)),
Commands.SystemShowBanner(var param, var flags) => ((Func<Task>)(() =>
{
if (param.target == null)
{
// we want to change banner if an attached image is passed
// we can't have a separate parsed command for this since the parser can't be aware of any attachments
var attachedImage = ctx.ExtractImageFromAttachment();
if (attachedImage is { } image)
return ctx.Execute<SystemEdit>(SystemBannerImage, m => m.ChangeBannerImage(ctx, ctx.System, image));
}
// if no attachment show the banner like intended
return ctx.Execute<SystemEdit>(SystemBannerImage, m => m.ShowBannerImage(ctx, param.target ?? ctx.System, flags.GetReplyFormat()));
}))(),
Commands.SystemClearBanner(var param, var flags) => ctx.Execute<SystemEdit>(SystemBannerImage, m => m.ClearBannerImage(ctx, ctx.System, flags.yes)),
Commands.SystemChangeBanner(var param, _) => ctx.Execute<SystemEdit>(SystemBannerImage, m => m.ChangeBannerImage(ctx, ctx.System, param.banner)),
Commands.SystemDelete(_, var flags) => ctx.Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx, ctx.System, flags.no_export)),
Commands.SystemShowProxyCurrent(_, _) => ctx.Execute<SystemEdit>(SystemProxy, m => m.ShowSystemProxy(ctx, ctx.Guild)),
Commands.SystemShowProxy(var param, _) => ctx.Execute<SystemEdit>(SystemProxy, m => m.ShowSystemProxy(ctx, param.target)),
Commands.SystemToggleProxyCurrent(var param, _) => ctx.Execute<SystemEdit>(SystemProxy, m => m.ToggleSystemProxy(ctx, ctx.Guild, param.toggle)),
Commands.SystemToggleProxy(var param, _) => ctx.Execute<SystemEdit>(SystemProxy, m => m.ToggleSystemProxy(ctx, param.target, param.toggle)),
Commands.SystemShowPrivacy(var param, _) => ctx.Execute<SystemEdit>(SystemPrivacy, m => m.ShowSystemPrivacy(ctx, ctx.System)),
Commands.SystemChangePrivacyAll(var param, _) => ctx.Execute<SystemEdit>(SystemPrivacy, m => m.ChangeSystemPrivacyAll(ctx, ctx.System, param.level)),
Commands.SystemChangePrivacy(var param, _) => ctx.Execute<SystemEdit>(SystemPrivacy, m => m.ChangeSystemPrivacy(ctx, ctx.System, param.privacy, param.level)),
Commands.SwitchOut(_, _) => ctx.Execute<Switch>(SwitchOut, m => m.SwitchOut(ctx)),
Commands.SwitchDo(var param, _) => ctx.Execute<Switch>(Switch, m => m.SwitchDo(ctx, param.targets)),
Commands.SwitchMove(var param, var flags) => ctx.Execute<Switch>(SwitchMove, m => m.SwitchMove(ctx, param.@string, flags.yes)),
Commands.SwitchEdit(var param, var flags) => ctx.Execute<Switch>(SwitchEdit, m => m.SwitchEdit(ctx, param.targets, false, flags.first, flags.remove, flags.append, flags.prepend, flags.yes)),
Commands.SwitchEditOut(_, var flags) => ctx.Execute<Switch>(SwitchEditOut, m => m.SwitchEditOut(ctx, flags.yes)),
Commands.SwitchDelete(var param, var flags) => ctx.Execute<Switch>(SwitchDelete, m => m.SwitchDelete(ctx, flags.all, flags.yes)),
Commands.SwitchCopy(var param, var flags) => ctx.Execute<Switch>(SwitchCopy, m => m.SwitchEdit(ctx, param.targets, true, flags.first, flags.remove, flags.append, flags.prepend, false)),
Commands.SystemFronter(var param, var flags) => ctx.Execute<SystemFront>(SystemFronter, m => m.Fronter(ctx, param.target ?? ctx.System)),
Commands.SystemFronterHistory(var param, var flags) => ctx.Execute<SystemFront>(SystemFrontHistory, m => m.FrontHistory(ctx, param.target ?? ctx.System, flags.clear)),
Commands.SystemFronterPercent(var param, var flags) => ctx.Execute<SystemFront>(SystemFrontPercent, m => m.FrontPercent(ctx, param.target ?? ctx.System, flags.duration, flags.fronters_only, flags.flat)),
Commands.SystemDisplayId(var param, _) => ctx.Execute<System>(SystemId, m => m.DisplayId(ctx, param.target ?? ctx.System)),
Commands.SystemWebhookShow => ctx.Execute<Api>(null, m => m.GetSystemWebhook(ctx)),
Commands.SystemWebhookClear(_, var flags) => ctx.Execute<Api>(null, m => m.ClearSystemWebhook(ctx, flags.yes)),
Commands.SystemWebhookSet(var param, _) => ctx.Execute<Api>(null, m => m.SetSystemWebhook(ctx, param.url)),
Commands.RandomSelf(_, var flags) =>
flags.group
? ctx.Execute<Random>(GroupRandom, m => m.Group(ctx, ctx.System, flags.all, flags.show_embed))
: ctx.Execute<Random>(MemberRandom, m => m.Member(ctx, ctx.System, flags.all, flags.show_embed)),
Commands.RandomGroupSelf(_, var flags) => ctx.Execute<Random>(GroupRandom, m => m.Group(ctx, ctx.System, flags.all, flags.show_embed)),
Commands.RandomGroupMemberSelf(var param, var flags) => ctx.Execute<Random>(GroupMemberRandom, m => m.GroupMember(ctx, param.target, flags.all, flags.show_embed, flags)),
Commands.SystemRandom(var param, var flags) =>
flags.group
? ctx.Execute<Random>(GroupRandom, m => m.Group(ctx, param.target, flags.all, flags.show_embed))
: ctx.Execute<Random>(MemberRandom, m => m.Member(ctx, param.target, flags.all, flags.show_embed)),
Commands.SystemRandomGroup(var param, var flags) =>
ctx.Execute<Random>(GroupRandom, m => m.Group(ctx, param.target, flags.all, flags.show_embed)),
Commands.GroupRandomMember(var param, var flags) => ctx.Execute<Random>(GroupMemberRandom, m => m.GroupMember(ctx, param.target, flags.all, flags.show_embed, flags)),
Commands.SystemLink(var param, var flags) => ctx.Execute<SystemLink>(Link, m => m.LinkSystem(ctx, param.account, flags.yes)),
Commands.SystemUnlink(var param, var flags) => ctx.Execute<SystemLink>(Unlink, m => m.UnlinkAccount(ctx, param.account, flags.yes)),
Commands.SystemMembers(var param, var flags) => ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, param.target ?? ctx.System, param.query, flags)),
Commands.MemberGroups(var param, var flags) => ctx.Execute<GroupMember>(MemberGroups, m => m.ListMemberGroups(ctx, param.target, param.query, flags, flags.all)),
Commands.GroupMembers(var param, var flags) => ctx.Execute<GroupMember>(GroupMemberList, m => m.ListGroupMembers(ctx, param.target, param.query, flags)),
Commands.SystemGroups(var param, var flags) => ctx.Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, param.target ?? ctx.System, param.query, flags, flags.all)),
Commands.GroupsSelf(var param, var flags) => ctx.Execute<Groups>(GroupList, g => g.ListSystemGroups(ctx, ctx.System, param.query, flags, flags.all)),
Commands.GroupNew(var param, var flags) => ctx.Execute<Groups>(GroupNew, g => g.CreateGroup(ctx, param.name, flags.yes)),
Commands.GroupInfo(var param, var flags) => ctx.Execute<Groups>(GroupInfo, g => g.ShowGroupCard(ctx, param.target, flags.show_embed, flags.all)),
Commands.GroupShowName(var param, var flags) => ctx.Execute<Groups>(GroupRename, g => g.ShowGroupDisplayName(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearName(var param, var flags) => ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, param.target, null)),
Commands.GroupRename(var param, var flags) => ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, param.target, param.name, flags.yes)),
Commands.GroupShowDisplayName(var param, var flags) => ctx.Execute<Groups>(GroupDisplayName, g => g.ShowGroupDisplayName(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearDisplayName(var param, var flags) => ctx.Execute<Groups>(GroupDisplayName, g => g.ClearGroupDisplayName(ctx, param.target, flags.yes)),
Commands.GroupChangeDisplayName(var param, _) => ctx.Execute<Groups>(GroupDisplayName, g => g.ChangeGroupDisplayName(ctx, param.target, param.name)),
Commands.GroupShowDescription(var param, var flags) => ctx.Execute<Groups>(GroupDesc, g => g.ShowGroupDescription(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearDescription(var param, var flags) => ctx.Execute<Groups>(GroupDesc, g => g.ClearGroupDescription(ctx, param.target, flags.yes)),
Commands.GroupChangeDescription(var param, _) => ctx.Execute<Groups>(GroupDesc, g => g.ChangeGroupDescription(ctx, param.target, param.description)),
Commands.GroupShowIcon(var param, var flags) => ctx.Execute<Groups>(GroupIcon, g => g.ShowGroupIcon(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearIcon(var param, var flags) => ctx.Execute<Groups>(GroupIcon, g => g.ClearGroupIcon(ctx, param.target, flags.yes)),
Commands.GroupChangeIcon(var param, _) => ctx.Execute<Groups>(GroupIcon, g => g.ChangeGroupIcon(ctx, param.target, param.icon)),
Commands.GroupShowBanner(var param, var flags) => ctx.Execute<Groups>(GroupBannerImage, g => g.ShowGroupBanner(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearBanner(var param, var flags) => ctx.Execute<Groups>(GroupBannerImage, g => g.ClearGroupBanner(ctx, param.target, flags.yes)),
Commands.GroupChangeBanner(var param, _) => ctx.Execute<Groups>(GroupBannerImage, g => g.ChangeGroupBanner(ctx, param.target, param.banner)),
Commands.GroupShowColor(var param, var flags) => ctx.Execute<Groups>(GroupColor, g => g.ShowGroupColor(ctx, param.target, flags.GetReplyFormat())),
Commands.GroupClearColor(var param, var flags) => ctx.Execute<Groups>(GroupColor, g => g.ClearGroupColor(ctx, param.target, flags.yes)),
Commands.GroupChangeColor(var param, _) => ctx.Execute<Groups>(GroupColor, g => g.ChangeGroupColor(ctx, param.target, param.color)),
Commands.GroupAddMember(var param, var flags) => ctx.Execute<GroupMember>(GroupAdd, g => g.AddRemoveMembers(ctx, param.target, param.targets, Groups.AddRemoveOperation.Add, flags.all)),
Commands.GroupRemoveMember(var param, var flags) => ctx.Execute<GroupMember>(GroupRemove, g => g.AddRemoveMembers(ctx, param.target, param.targets, Groups.AddRemoveOperation.Remove, flags.all, flags.yes)),
Commands.GroupShowPrivacy(var param, _) => ctx.Execute<Groups>(GroupPrivacy, g => g.ShowGroupPrivacy(ctx, param.target)),
Commands.GroupChangePrivacyAll(var param, _) => ctx.Execute<Groups>(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, param.level)),
Commands.GroupChangePrivacy(var param, _) => ctx.Execute<Groups>(GroupPrivacy, g => g.SetGroupPrivacy(ctx, param.target, param.privacy, param.level)),
Commands.GroupSetPublic(var param, _) => ctx.Execute<Groups>(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, PrivacyLevel.Public)),
Commands.GroupSetPrivate(var param, _) => ctx.Execute<Groups>(GroupPrivacy, g => g.SetAllGroupPrivacy(ctx, param.target, PrivacyLevel.Private)),
Commands.GroupDelete(var param, var flags) => ctx.Execute<Groups>(GroupDelete, g => g.DeleteGroup(ctx, param.target)),
Commands.GroupId(var param, _) => ctx.Execute<Groups>(GroupId, g => g.DisplayId(ctx, param.target)),
Commands.GroupFronterPercent(var param, var flags) => ctx.Execute<SystemFront>(GroupFrontPercent, g => g.FrontPercent(ctx, null, flags.duration, flags.fronters_only, flags.flat, param.target)),
Commands.TokenDisplay => ctx.Execute<Api>(TokenGet, m => m.GetToken(ctx)),
Commands.TokenRefresh => ctx.Execute<Api>(TokenRefresh, m => m.RefreshToken(ctx)),
Commands.AutoproxyShow => ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx, null)),
Commands.AutoproxyOff => ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Off())),
Commands.AutoproxyLatch => ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Latch())),
Commands.AutoproxyFront => ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Front())),
Commands.AutoproxyMember(var param, _) => ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx, new Autoproxy.Mode.Member(param.target))),
Commands.PermcheckChannel(var param, _) => ctx.Execute<Checks>(PermCheck, m => m.PermCheckChannel(ctx, param.target)),
Commands.PermcheckGuild(var param, _) => ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx, param.target)),
Commands.MessageProxyCheck(var param, _) => ctx.Execute<Checks>(ProxyCheck, m => m.MessageProxyCheck(ctx, param.target)),
Commands.MessageInfo(var param, var flags) => ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), flags.delete, flags.author, flags.show_embed)),
Commands.MessageAuthor(var param, var flags) => ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), false, true, flags.show_embed)),
Commands.MessageDelete(var param, var flags) => ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx, param.target, flags.GetReplyFormat(), true, false, flags.show_embed)),
Commands.MessageEdit(var param, var flags) => ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx, param.target, param.new_content, flags.regex, flags.no_space, flags.append, flags.prepend, flags.clear_embeds, flags.clear_attachments)),
Commands.MessageReproxy(var param, _) => ctx.Execute<ProxiedMessage>(MessageReproxy, m => m.ReproxyMessage(ctx, param.msg, param.member)),
Commands.Import(var param, var flags) => ctx.Execute<ImportExport>(Import, m => m.Import(ctx, param.url, flags.yes)),
Commands.Export(_, _) => ctx.Execute<ImportExport>(Export, m => m.Export(ctx)),
Commands.ServerConfigShow => ctx.Execute<ServerConfig>(null, m => m.ShowConfig(ctx)),
Commands.ServerConfigLogChannelShow => ctx.Execute<ServerConfig>(null, m => m.ShowLogChannel(ctx)),
Commands.ServerConfigLogChannelSet(var param, _) => ctx.Execute<ServerConfig>(null, m => m.SetLogChannel(ctx, param.channel)),
Commands.ServerConfigLogChannelClear(_, var flags) => ctx.Execute<ServerConfig>(null, m => m.ClearLogChannel(ctx, flags.yes)),
Commands.ServerConfigLogCleanupShow => ctx.Execute<ServerConfig>(null, m => m.ShowLogCleanup(ctx)),
Commands.ServerConfigLogCleanupSet(var param, _) => ctx.Execute<ServerConfig>(null, m => m.SetLogCleanup(ctx, param.toggle)),
Commands.ServerConfigLogBlacklistShow => ctx.Execute<ServerConfig>(null, m => m.ShowLogBlacklist(ctx)),
Commands.ServerConfigLogBlacklistAdd(var param, var flags) => ctx.Execute<ServerConfig>(null, m => m.AddLogBlacklist(ctx, param.channel, flags.all)),
Commands.ServerConfigLogBlacklistRemove(var param, var flags) => ctx.Execute<ServerConfig>(null, m => m.RemoveLogBlacklist(ctx, param.channel, flags.all)),
Commands.ServerConfigProxyBlacklistShow => ctx.Execute<ServerConfig>(null, m => m.ShowProxyBlacklist(ctx)),
Commands.ServerConfigProxyBlacklistAdd(var param, var flags) => ctx.Execute<ServerConfig>(null, m => m.AddProxyBlacklist(ctx, param.channel, flags.all)),
Commands.ServerConfigProxyBlacklistRemove(var param, var flags) => ctx.Execute<ServerConfig>(null, m => m.RemoveProxyBlacklist(ctx, param.channel, flags.all)),
Commands.ServerConfigInvalidCommandResponseShow => ctx.Execute<ServerConfig>(null, m => m.ShowInvalidCommandResponse(ctx)),
Commands.ServerConfigInvalidCommandResponseSet(var param, _) => ctx.Execute<ServerConfig>(null, m => m.SetInvalidCommandResponse(ctx, param.toggle)),
Commands.ServerConfigRequireSystemTagShow => ctx.Execute<ServerConfig>(null, m => m.ShowRequireSystemTag(ctx)),
Commands.ServerConfigRequireSystemTagSet(var param, _) => ctx.Execute<ServerConfig>(null, m => m.SetRequireSystemTag(ctx, param.toggle)),
Commands.ServerConfigSuppressNotificationsShow => ctx.Execute<ServerConfig>(null, m => m.ShowSuppressNotifications(ctx)),
Commands.ServerConfigSuppressNotificationsSet(var param, _) => ctx.Execute<ServerConfig>(null, m => m.SetSuppressNotifications(ctx, param.toggle)),
Commands.AdminUpdateSystemId(var param, var flags) => ctx.Execute<Admin>(null, m => m.UpdateSystemId(ctx, param.target, param.new_hid, flags.yes)),
Commands.AdminUpdateMemberId(var param, var flags) => ctx.Execute<Admin>(null, m => m.UpdateMemberId(ctx, param.target, param.new_hid, flags.yes)),
Commands.AdminUpdateGroupId(var param, var flags) => ctx.Execute<Admin>(null, m => m.UpdateGroupId(ctx, param.target, param.new_hid, flags.yes)),
Commands.AdminRerollSystemId(var param, var flags) => ctx.Execute<Admin>(null, m => m.RerollSystemId(ctx, param.target, flags.yes)),
Commands.AdminRerollMemberId(var param, var flags) => ctx.Execute<Admin>(null, m => m.RerollMemberId(ctx, param.target, flags.yes)),
Commands.AdminRerollGroupId(var param, var flags) => ctx.Execute<Admin>(null, m => m.RerollGroupId(ctx, param.target, flags.yes)),
Commands.AdminSystemMemberLimit(var param, var flags) => ctx.Execute<Admin>(null, m => m.SystemMemberLimit(ctx, param.target, param.limit, flags.yes)),
Commands.AdminSystemGroupLimit(var param, var flags) => ctx.Execute<Admin>(null, m => m.SystemGroupLimit(ctx, param.target, param.limit, flags.yes)),
Commands.AdminSystemRecover(var param, var flags) => ctx.Execute<Admin>(null, m => m.SystemRecover(ctx, param.token, param.account, flags.reroll_token, flags.yes)),
Commands.AdminSystemDelete(var param, _) => ctx.Execute<Admin>(null, m => m.SystemDelete(ctx, param.target)),
Commands.AdminSendMessage(var param, _) => ctx.Execute<Admin>(null, m => m.SendAdminMessage(ctx, param.account, param.content)),
Commands.AdminAbuselogCreate(var param, var flags) => ctx.Execute<Admin>(null, m => m.AbuseLogCreate(ctx, param.account, flags.deny_boy_usage, param.description)),
Commands.AdminAbuselogShowAccount(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogShow(ctx, param.account, null)),
Commands.AdminAbuselogFlagDenyAccount(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogFlagDeny(ctx, param.account, null, param.value)),
Commands.AdminAbuselogDescriptionAccount(var param, var flags) => ctx.Execute<Admin>(null, m => m.AbuseLogDescription(ctx, param.account, null, param.desc, flags.clear, flags.yes)),
Commands.AdminAbuselogAddUserAccount(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogAddUser(ctx, param.account, null, ctx.Author)),
Commands.AdminAbuselogRemoveUserAccount(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogRemoveUser(ctx, param.account, null, ctx.Author)),
Commands.AdminAbuselogDeleteAccount(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogDelete(ctx, param.account, null)),
Commands.AdminAbuselogShowLogId(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogShow(ctx, null, param.log_id)),
Commands.AdminAbuselogFlagDenyLogId(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogFlagDeny(ctx, null, param.log_id, param.value)),
Commands.AdminAbuselogDescriptionLogId(var param, var flags) => ctx.Execute<Admin>(null, m => m.AbuseLogDescription(ctx, null, param.log_id, param.desc, flags.clear, flags.yes)),
Commands.AdminAbuselogAddUserLogId(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogAddUser(ctx, null, param.log_id, ctx.Author)),
Commands.AdminAbuselogRemoveUserLogId(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogRemoveUser(ctx, null, param.log_id, ctx.Author)),
Commands.AdminAbuselogDeleteLogId(var param, _) => ctx.Execute<Admin>(null, m => m.AbuseLogDelete(ctx, null, param.log_id)),
_ =>
// this should only ever occur when deving if commands are not implemented...
ctx.Reply(
$"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"),
};
}
}

View file

@ -26,11 +26,9 @@ public class Context
private readonly IMetrics _metrics;
private readonly CommandMessageService _commandMessageService;
private Command? _currentCommand;
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
int commandParseOffset, PKSystem senderSystem, SystemConfig config,
GuildConfig? guildConfig, string[] prefixes)
GuildConfig? guildConfig, string[] prefixes, Parameters parameters)
{
Message = (Message)message;
ShardId = shardId;
@ -48,9 +46,9 @@ public class Context
_commandMessageService = provider.Resolve<CommandMessageService>();
CommandPrefix = message.Content?.Substring(0, commandParseOffset);
DefaultPrefix = prefixes[0];
Parameters = new Parameters(message.Content?.Substring(commandParseOffset));
Rest = provider.Resolve<DiscordApiClient>();
Cluster = provider.Resolve<Cluster>();
Parameters = parameters;
}
public readonly IDiscordCache Cache;
@ -156,8 +154,6 @@ public class Context
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler, bool deprecated = false)
{
_currentCommand = commandDef;
if (deprecated && commandDef != null)
{
await Reply($"{Emojis.Warn} Server configuration has moved to `{DefaultPrefix}serverconfig`. The command you are trying to run is now `{DefaultPrefix}{commandDef.Key}`.");
@ -196,10 +192,11 @@ public class Context
public LookupContext DirectLookupContextFor(SystemId systemId)
=> System?.Id == systemId ? LookupContext.ByOwner : LookupContext.ByNonOwner;
public LookupContext LookupContextFor(SystemId systemId)
public LookupContext LookupContextFor(SystemId systemId, bool? _hasPrivateOverride = null, bool? _hasPublicOverride = null)
{
var hasPrivateOverride = this.MatchFlag("private", "priv");
var hasPublicOverride = this.MatchFlag("public", "pub");
// todo(dusk): these should be passed as a parameter ideally
bool hasPrivateOverride = _hasPrivateOverride ?? Parameters.HasFlag("private", "priv");
bool hasPublicOverride = _hasPublicOverride ?? Parameters.HasFlag("public", "pub");
if (hasPrivateOverride && hasPublicOverride)
throw new PKError("Cannot match both public and private flags at the same time.");

View file

@ -2,201 +2,30 @@ using System.Text.RegularExpressions;
using Myriad.Types;
using PluralKit.Core;
namespace PluralKit.Bot;
public static class ContextArgumentsExt
{
public static string PopArgument(this Context ctx) =>
ctx.Parameters.Pop();
public static string PeekArgument(this Context ctx) =>
ctx.Parameters.Peek();
public static string RemainderOrNull(this Context ctx, bool skipFlags = true) =>
ctx.Parameters.Remainder(skipFlags).Length == 0 ? null : ctx.Parameters.Remainder(skipFlags);
public static bool HasNext(this Context ctx, bool skipFlags = true) =>
ctx.RemainderOrNull(skipFlags) != null;
public static string FullCommand(this Context ctx) =>
ctx.Parameters.FullCommand;
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords and pops it from the stack. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, ref string used, params string[] potentialMatches)
{
var arg = ctx.PeekArgument();
foreach (var match in potentialMatches)
if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase))
{
used = ctx.PopArgument();
return true;
}
return false;
}
/// <summary>
/// Checks if the next parameter is equal to one of the given keywords. Case-insensitive.
/// </summary>
public static bool Match(this Context ctx, params string[] potentialMatches)
{
string used = null; // Unused and unreturned, we just yeet it
return ctx.Match(ref used, potentialMatches);
}
/// <summary>
/// Checks if the next parameter (starting from `ptr`) is equal to one of the given keywords, and leaves it on the stack. Case-insensitive.
/// </summary>
public static bool PeekMatch(this Context ctx, ref int ptr, string[] potentialMatches)
{
var arg = ctx.Parameters.PeekWithPtr(ref ptr);
foreach (var match in potentialMatches)
if (arg.Equals(match, StringComparison.InvariantCultureIgnoreCase))
return true;
return false;
}
/// <summary>
/// Matches the next *n* parameters against each parameter consecutively.
/// <br />
/// Note that this is handled differently than single-parameter Match:
/// each method parameter is an array of potential matches for the *n*th command string parameter.
/// </summary>
public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches)
{
int ptr = ctx.Parameters._ptr;
foreach (var param in potentialParametersMatches)
if (!ctx.PeekMatch(ref ptr, param)) return false;
ctx.Parameters._ptr = ptr;
return true;
}
public static bool MatchFlag(this Context ctx, params string[] potentialMatches)
{
// Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here.
// Can assume the caller array only contains lowercase *and* the set below only contains lowercase
var flags = ctx.Parameters.Flags();
return potentialMatches.Any(potentialMatch => flags.Contains(potentialMatch));
}
public static bool MatchClear(this Context ctx)
=> ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear");
public static ReplyFormat MatchFormat(this Context ctx)
{
if (ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw;
if (ctx.Match("pt", "plaintext") || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext;
return ReplyFormat.Standard;
}
public static ReplyFormat PeekMatchFormat(this Context ctx)
{
int ptr1 = ctx.Parameters._ptr;
int ptr2 = ctx.Parameters._ptr;
if (ctx.PeekMatch(ref ptr1, new[] { "r", "raw" }) || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw;
if (ctx.PeekMatch(ref ptr2, new[] { "pt", "plaintext" }) || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext;
return ReplyFormat.Standard;
}
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
{
var value = ctx.MatchToggleOrNull(defaultValue);
if (value == null) throw new PKError("You must pass either \"on\" or \"off\" to this command.");
return value.Value;
}
public static bool? MatchToggleOrNull(this Context ctx, bool? defaultValue = null)
{
if (defaultValue != null && ctx.MatchClear())
return defaultValue.Value;
var yesToggles = new[] { "yes", "on", "enable", "enabled", "true" };
var noToggles = new[] { "no", "off", "disable", "disabled", "false" };
if (ctx.Match(yesToggles) || ctx.MatchFlag(yesToggles))
return true;
else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles))
return false;
else return null;
}
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)
public static Message.Reference? GetRepliedTo(this Context ctx)
{
if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null)
return (ctx.Message.MessageReference.MessageId, ctx.Message.MessageReference.ChannelId);
return ctx.Message.MessageReference;
return null;
}
var word = ctx.PeekArgument();
if (word == null)
return (null, null);
if (parseRawMessageId && ulong.TryParse(word, out var mid))
public static (ulong? messageId, ulong? channelId) ParseMessage(this Context ctx, string maybeMessageRef, bool parseRawMessageId)
{
if (parseRawMessageId && ulong.TryParse(maybeMessageRef, out var mid))
return (mid, null);
var match = Regex.Match(word, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)");
var match = Regex.Match(maybeMessageRef, "https://(?:\\w+.)?discord(?:app)?.com/channels/\\d+/(\\d+)/(\\d+)");
if (!match.Success)
return (null, null);
var channelId = ulong.Parse(match.Groups[1].Value);
var messageId = ulong.Parse(match.Groups[2].Value);
ctx.PopArgument();
return (messageId, channelId);
}
public static async Task<List<PKMember>> ParseMemberList(this Context ctx, SystemId? restrictToSystem)
{
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(restrictToSystem);
if (member == null)
// if we can't, big error. Every member name must be valid.
throw new PKError(ctx.CreateNotFoundError("Member", ctx.PopArgument()));
members.Add(member); // Then add to the final output list
}
if (members.Count == 0) throw new PKSyntaxError("You must input at least one member.");
return members;
}
public static async Task<List<PKGroup>> ParseGroupList(this Context ctx, SystemId? restrictToSystem)
{
var groups = new List<PKGroup>();
// Loop through all the given arguments
while (ctx.HasNext())
{
// and attempt to match a group
var group = await ctx.MatchGroup(restrictToSystem);
if (group == null)
// if we can't, big error. Every group name must be valid.
throw new PKError(ctx.CreateNotFoundError("Group", ctx.PopArgument()));
// todo: remove this, the database query enforces the restriction
if (restrictToSystem != null && group.System != restrictToSystem)
throw Errors.NotOwnGroupError; // TODO: name *which* group?
groups.Add(group); // Then add to the final output list
}
if (groups.Count == 0) throw new PKSyntaxError("You must input at least one group.");
return groups;
}
}
public enum ReplyFormat

View file

@ -6,34 +6,8 @@ namespace PluralKit.Bot;
public static class ContextAvatarExt
{
public static async Task<ParsedImage?> MatchImage(this Context ctx)
public static ParsedImage? ExtractImageFromAttachment(this Context ctx)
{
// If we have a user @mention/ID, use their avatar
if (await ctx.MatchUser() is { } user)
{
var url = user.AvatarUrl("png", 256);
return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user };
}
// If we have raw or plaintext, don't try to parse as a URL
if (ctx.PeekMatchFormat() != ReplyFormat.Standard)
return null;
// If we have a positional argument, try to parse it as a URL
var arg = ctx.RemainderOrNull();
if (arg != null)
{
// Allow surrounding the URL with <angle brackets> to "de-embed"
if (arg.StartsWith("<") && arg.EndsWith(">"))
arg = arg.Substring(1, arg.Length - 2);
if (!Core.MiscUtils.TryMatchUri(arg, out var uri))
throw Errors.InvalidUrl;
// ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't
return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url };
}
// If we have an attachment, use that
if (ctx.Message.Attachments.FirstOrDefault() is { } attachment)
{
@ -51,6 +25,29 @@ public static class ContextAvatarExt
// and if there are no attachments (which would have been caught just before)
return null;
}
public static async Task<ParsedImage?> GetUserPfp(this Context ctx, string arg)
{
// If we have a user @mention/ID, use their avatar
if (await ctx.ParseUser(arg) is { } user)
{
var url = user.AvatarUrl("png", 256);
return new ParsedImage { Url = url, Source = AvatarSource.User, SourceUser = user };
}
return null;
}
public static ParsedImage ParseImage(this Context ctx, string arg)
{
// Allow surrounding the URL with <angle brackets> to "de-embed"
if (arg.StartsWith("<") && arg.EndsWith(">"))
arg = arg.Substring(1, arg.Length - 2);
if (!Core.MiscUtils.TryMatchUri(arg, out var uri))
throw Errors.InvalidUrl;
// ToString URL-decodes, which breaks URLs to spaces; AbsoluteUri doesn't
return new ParsedImage { Url = uri.AbsoluteUri, Source = AvatarSource.Url };
}
}
public struct ParsedImage

View file

@ -1,52 +1,22 @@
using System.Text.RegularExpressions;
using Myriad.Extensions;
using Myriad.Types;
using PluralKit.Bot.Utils;
using PluralKit.Core;
namespace PluralKit.Bot;
public static class ContextEntityArgumentsExt
{
public static async Task<User> MatchUser(this Context ctx)
public static async Task<User> ParseUser(this Context ctx, string arg)
{
var text = ctx.PeekArgument();
if (text.TryParseMention(out var id))
{
var user = await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
if (user != null) ctx.PopArgument();
return user;
}
if (arg.TryParseMention(out var id))
return await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
return null;
}
public static bool MatchUserRaw(this Context ctx, out ulong id)
public static async Task<PKSystem> ParseSystem(this Context ctx, string input)
{
id = 0;
var text = ctx.PeekArgument();
if (text.TryParseMention(out var mentionId))
id = mentionId;
return id != 0;
}
public static Task<PKSystem> PeekSystem(this Context ctx) => ctx.MatchSystemInner();
public static async Task<PKSystem> MatchSystem(this Context ctx)
{
var system = await ctx.MatchSystemInner();
if (system != null) ctx.PopArgument();
return system;
}
private static async Task<PKSystem> MatchSystemInner(this Context ctx)
{
var input = ctx.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>)
@ -63,10 +33,8 @@ public static class ContextEntityArgumentsExt
return null;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
public static async Task<PKMember?> ParseMember(this Context ctx, string input, bool byId)
{
var input = ctx.PeekArgument();
// Member references can have one of three forms, depending on
// whether you're in a system or not:
// - A member hid
@ -75,7 +43,7 @@ public static class ContextEntityArgumentsExt
// Skip name / display name matching if the user does not have a system
// or if they specifically request by-HID matching
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
if (ctx.System != null && !byId)
{
// First, try finding by member name in system
if (await ctx.Repository.GetMemberByName(ctx.System.Id, input) is PKMember memberByName)
@ -98,55 +66,25 @@ public static class ContextEntityArgumentsExt
// If we are supposed to restrict it to a system anyway we can just do that
PKMember memberByHid = null;
if (restrictToSystem != null)
{
memberByHid = await ctx.Repository.GetMemberByHid(hid, restrictToSystem);
if (memberByHid != null)
return memberByHid;
}
// otherwise we try the querier's system and if that doesn't work we do global
else
{
memberByHid = await ctx.Repository.GetMemberByHid(hid, ctx.System?.Id);
if (memberByHid != null)
return memberByHid;
memberByHid = await ctx.Repository.GetMemberByHid(hid, ctx.System?.Id);
if (memberByHid != null)
return memberByHid;
// ff ctx.System was null then this would be a duplicate of above and we don't want to run it again
if (ctx.System != null)
{
memberByHid = await ctx.Repository.GetMemberByHid(hid);
if (memberByHid != null)
return memberByHid;
}
// ff ctx.System was null then this would be a duplicate of above and we don't want to run it again
if (ctx.System != null)
{
memberByHid = await ctx.Repository.GetMemberByHid(hid);
if (memberByHid != null)
return memberByHid;
}
// 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 static async Task<PKMember> MatchMember(this Context ctx, SystemId? restrictToSystem = null)
public static async Task<PKGroup> ParseGroup(this Context ctx, string input, bool byId, SystemId? restrictToSystem = null)
{
// First, peek a member
var member = await ctx.PeekMember(restrictToSystem);
// 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) ctx.PopArgument();
// Finally, we return the member value.
return member;
}
public static async Task<PKGroup> PeekGroup(this Context ctx, SystemId? restrictToSystem = null)
{
var input = ctx.PeekArgument();
// see PeekMember for an explanation of the logic used here
if (ctx.System != null && !ctx.MatchFlag("id", "by-id"))
if (ctx.System != null && !byId)
{
if (await ctx.Repository.GetGroupByName(ctx.System.Id, input) is { } byName)
return byName;
@ -163,16 +101,9 @@ public static class ContextEntityArgumentsExt
return null;
}
public static async Task<PKGroup> MatchGroup(this Context ctx, SystemId? restrictToSystem = null)
public static string CreateNotFoundError(this Context ctx, string entity, string input, bool byId = false)
{
var group = await ctx.PeekGroup(restrictToSystem);
if (group != null) ctx.PopArgument();
return group;
}
public static string CreateNotFoundError(this Context ctx, string entity, string input)
{
var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id");
var isIDOnlyQuery = ctx.System == null || byId;
var inputIsHid = HidUtils.ParseHid(input) != null;
if (isIDOnlyQuery)
@ -186,35 +117,4 @@ public static class ContextEntityArgumentsExt
return $"{entity} with ID or name \"{input}\" not found.";
return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 or 6 characters long.";
}
public static async Task<Channel> MatchChannel(this Context ctx)
{
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
return null;
// todo: match channels in other guilds
var channel = await ctx.Cache.TryGetChannel(ctx.Guild!.Id, id);
if (channel == null)
channel = await ctx.Rest.GetChannelOrNull(id);
if (channel == null)
return null;
if (!DiscordUtils.IsValidGuildChannel(channel))
return null;
ctx.PopArgument();
return channel;
}
public static async Task<Guild> MatchGuild(this Context ctx)
{
if (!ulong.TryParse(ctx.PeekArgument(), out var id))
return null;
var guild = await ctx.Rest.GetGuildOrNull(id);
if (guild != null)
ctx.PopArgument();
return guild;
}
}

View file

@ -0,0 +1,54 @@
using PluralKit.Core;
namespace PluralKit.Bot;
public static class ContextFlagsExt
{
public static async Task<string?> FlagResolveOpaque(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.Opaque)?.value
);
}
public static async Task<PKMember?> FlagResolveMember(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.MemberRef)?.member
);
}
public static async Task<PKSystem?> FlagResolveSystem(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.SystemRef)?.system
);
}
public static async Task<MemberPrivacySubject?> FlagResolveMemberPrivacyTarget(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.MemberPrivacyTarget)?.target
);
}
public static async Task<PrivacyLevel?> FlagResolvePrivacyLevel(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.PrivacyLevel)?.level
);
}
public static async Task<bool?> FlagResolveToggle(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveFlag(
ctx, param_name,
param => (param as Parameter.Toggle)?.value
);
}
}

View file

@ -0,0 +1,151 @@
using PluralKit.Core;
using Myriad.Types;
namespace PluralKit.Bot;
public static class ContextParametersExt
{
public static async Task<string?> ParamResolveOpaque(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Opaque)?.value
);
}
public static async Task<int?> ParamResolveNumber(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Number)?.value
);
}
public static async Task<PKMember?> ParamResolveMember(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MemberRef)?.member
);
}
public static async Task<List<PKMember>?> ParamResolveMembers(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MemberRefs)?.members
);
}
public static async Task<PKGroup?> ParamResolveGroup(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.GroupRef)?.group
);
}
public static async Task<List<PKGroup>?> ParamResolveGroups(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.GroupRefs)?.groups
);
}
public static async Task<PKSystem?> ParamResolveSystem(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.SystemRef)?.system
);
}
public static async Task<User?> ParamResolveUser(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.UserRef)?.user
);
}
public static async Task<MemberPrivacySubject?> ParamResolveMemberPrivacyTarget(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MemberPrivacyTarget)?.target
);
}
public static async Task<GroupPrivacySubject?> ParamResolveGroupPrivacyTarget(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.GroupPrivacyTarget)?.target
);
}
public static async Task<SystemPrivacySubject?> ParamResolveSystemPrivacyTarget(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.SystemPrivacyTarget)?.target
);
}
public static async Task<PrivacyLevel?> ParamResolvePrivacyLevel(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.PrivacyLevel)?.level
);
}
public static async Task<SystemConfig.ProxySwitchAction?> ParamResolveProxySwitchAction(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.ProxySwitchAction)?.action
);
}
public static async Task<bool?> ParamResolveToggle(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Toggle)?.value
);
}
public static async Task<ParsedImage?> ParamResolveAvatar(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.Avatar)?.avatar
);
}
public static async Task<Myriad.Types.Message.Reference?> ParamResolveMessage(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.MessageRef)?.message
);
}
public static async Task<Myriad.Types.Channel?> ParamResolveChannel(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.ChannelRef)?.channel
);
}
public static async Task<Myriad.Types.Guild?> ParamResolveGuild(this Context ctx, string param_name)
{
return await ctx.Parameters.ResolveParameter(
ctx, param_name,
param => (param as Parameter.GuildRef)?.guild
);
}
}

View file

@ -1,51 +0,0 @@
using PluralKit.Core;
namespace PluralKit.Bot;
public static class ContextPrivacyExt
{
public static PrivacyLevel PopPrivacyLevel(this Context ctx)
{
if (ctx.Match("public", "pub", "show", "shown", "visible", "unhide", "unhidden"))
return PrivacyLevel.Public;
if (ctx.Match("private", "priv", "hide", "hidden"))
return PrivacyLevel.Private;
if (!ctx.HasNext())
throw new PKSyntaxError("You must pass a privacy level (`public` or `private`)");
throw new PKSyntaxError(
$"Invalid privacy level {ctx.PopArgument().AsCode()} (must be `public` or `private`).");
}
public static SystemPrivacySubject PopSystemPrivacySubject(this Context ctx)
{
if (!SystemPrivacyUtils.TryParseSystemPrivacy(ctx.PeekArgument(), out var subject))
throw new PKSyntaxError(
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `description`, `members`, `front`, `fronthistory`, `groups`, or `all`).");
ctx.PopArgument();
return subject;
}
public static MemberPrivacySubject PopMemberPrivacySubject(this Context ctx)
{
if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject))
throw new PKSyntaxError(
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `proxy`, `metadata`, `visibility`, or `all`).");
ctx.PopArgument();
return subject;
}
public static GroupPrivacySubject PopGroupPrivacySubject(this Context ctx)
{
if (!GroupPrivacyUtils.TryParseGroupPrivacy(ctx.PeekArgument(), out var subject))
throw new PKSyntaxError(
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `icon`, `metadata`, `visibility`, or `all`).");
ctx.PopArgument();
return subject;
}
}

View file

@ -1,185 +1,205 @@
using Humanizer;
using Myriad.Types;
using Myriad.Extensions;
using PluralKit.Core;
using uniffi.commands;
namespace PluralKit.Bot;
// corresponds to the ffi Paramater type, but with stricter types (also avoiding exposing ffi types!)
public abstract record Parameter()
{
public record MemberRef(PKMember member): Parameter;
public record MemberRefs(List<PKMember> members): Parameter;
public record GroupRef(PKGroup group): Parameter;
public record GroupRefs(List<PKGroup> groups): Parameter;
public record SystemRef(PKSystem system): Parameter;
public record UserRef(User user): Parameter;
public record MessageRef(Message.Reference message): Parameter;
public record ChannelRef(Channel channel): Parameter;
public record GuildRef(Guild guild): Parameter;
public record MemberPrivacyTarget(MemberPrivacySubject target): Parameter;
public record GroupPrivacyTarget(GroupPrivacySubject target): Parameter;
public record SystemPrivacyTarget(SystemPrivacySubject target): Parameter;
public record PrivacyLevel(Core.PrivacyLevel level): Parameter;
public record Toggle(bool value): Parameter;
public record Opaque(string value): Parameter;
public record Number(int value): Parameter;
public record Avatar(ParsedImage avatar): Parameter;
public record ProxySwitchAction(SystemConfig.ProxySwitchAction action): Parameter;
}
public class Parameters
{
// Dictionary of (left, right) quote pairs
// Each char in the string is an individual quote, multi-char strings imply "one of the following chars"
private static readonly Dictionary<string, string> _quotePairs = new()
private string _cb { get; init; }
private Dictionary<string, uniffi.commands.Parameter?> _flags { get; init; }
private Dictionary<string, uniffi.commands.Parameter> _params { get; init; }
// just used for errors, temporarily
public string FullCommand { get; init; }
public Parameters(string prefix, string cmd)
{
// Basic
{ "'", "'" }, // ASCII single quotes
{ "\"", "\"" }, // ASCII double quotes
// "Smart quotes"
// Specifically ignore the left/right status of the quotes and match any combination of them
// Left string also includes "low" quotes to allow for the low-high style used in some locales
{ "\u201C\u201D\u201F\u201E", "\u201C\u201D\u201F" }, // double quotes
{ "\u2018\u2019\u201B\u201A", "\u2018\u2019\u201B" }, // single quotes
// Chevrons (normal and "fullwidth" variants)
{ "\u00AB\u300A", "\u00BB\u300B" }, // double chevrons, pointing away (<<text>>)
{ "\u00BB\u300B", "\u00AB\u300A" }, // double chevrons, pointing together (>>text<<)
{ "\u2039\u3008", "\u203A\u3009" }, // single chevrons, pointing away (<text>)
{ "\u203A\u3009", "\u2039\u3008" }, // single chevrons, pointing together (>text<)
// Other
{ "\u300C\u300E", "\u300D\u300F" } // corner brackets (Japanese/Chinese)
};
private ISet<string> _flags; // Only parsed when requested first time
public int _ptr;
public string FullCommand { get; }
private struct WordPosition
{
// Start of the word
internal readonly int startPos;
// End of the word
internal readonly int endPos;
// How much to advance word pointer afterwards to point at the start of the *next* word
internal readonly int advanceAfterWord;
internal readonly bool wasQuoted;
public WordPosition(int startPos, int endPos, int advanceAfterWord, bool wasQuoted)
FullCommand = cmd;
var result = CommandsMethods.ParseCommand(prefix, cmd);
if (result is CommandResult.Ok)
{
this.startPos = startPos;
this.endPos = endPos;
this.advanceAfterWord = advanceAfterWord;
this.wasQuoted = wasQuoted;
var command = ((CommandResult.Ok)result).@command;
_cb = command.@commandRef;
_flags = command.@flags;
_params = command.@params;
}
else
{
throw new PKError(((CommandResult.Err)result).@error);
}
}
public Parameters(string cmd)
public static string GetRelatedCommands(string prefix, string subject)
{
// This is a SUPER dirty hack to avoid having to match both spaces and newlines in the word detection below
// Instead, we just add a space before every newline (which then gets stripped out later).
FullCommand = cmd.Replace("\n", " \n");
_ptr = 0;
return CommandsMethods.GetRelatedCommands(prefix, subject);
}
private void ParseFlags()
public string Callback()
{
_flags = new HashSet<string>();
return _cb;
}
var ptr = 0;
while (NextWordPosition(ptr) is { } wp)
public bool HasFlag(params string[] potentialMatches)
{
return potentialMatches.Any(_flags.ContainsKey);
}
private async Task<Parameter?> ResolveFfiParam(Context ctx, uniffi.commands.Parameter ffi_param)
{
var byId = HasFlag("id", "by-id"); // this is added as a hidden flag to all command definitions
switch (ffi_param)
{
ptr = wp.endPos + wp.advanceAfterWord;
case uniffi.commands.Parameter.MemberRef memberRef:
return new Parameter.MemberRef(
await ctx.ParseMember(memberRef.member, byId)
?? throw new PKError(ctx.CreateNotFoundError("Member", memberRef.member, byId))
);
case uniffi.commands.Parameter.MemberRefs memberRefs:
return new Parameter.MemberRefs(
await memberRefs.members.ToAsyncEnumerable().SelectAwait(async m =>
await ctx.ParseMember(m, byId)
?? throw new PKError(ctx.CreateNotFoundError("Member", m, byId))
).ToListAsync()
);
case uniffi.commands.Parameter.GroupRef groupRef:
return new Parameter.GroupRef(
await ctx.ParseGroup(groupRef.group, byId)
?? throw new PKError(ctx.CreateNotFoundError("Group", groupRef.group))
);
case uniffi.commands.Parameter.GroupRefs groupRefs:
return new Parameter.GroupRefs(
await groupRefs.groups.ToAsyncEnumerable().SelectAwait(async g =>
await ctx.ParseGroup(g, byId)
?? throw new PKError(ctx.CreateNotFoundError("Group", g, byId))
).ToListAsync()
);
case uniffi.commands.Parameter.SystemRef systemRef:
// todo: do we need byId here?
return new Parameter.SystemRef(
await ctx.ParseSystem(systemRef.system)
?? throw new PKError(ctx.CreateNotFoundError("System", systemRef.system))
);
case uniffi.commands.Parameter.UserRef(var userId):
return new Parameter.UserRef(
await ctx.Cache.GetOrFetchUser(ctx.Rest, userId)
?? throw new PKError(ctx.CreateNotFoundError("User", userId.ToString()))
);
// todo(dusk): ideally generate enums for these from rust code in the cs glue
case uniffi.commands.Parameter.MemberPrivacyTarget memberPrivacyTarget:
// this should never really fail...
if (!MemberPrivacyUtils.TryParseMemberPrivacy(memberPrivacyTarget.target, out var memberPrivacy))
throw new PKError($"Invalid member privacy target {memberPrivacyTarget.target}");
return new Parameter.MemberPrivacyTarget(memberPrivacy);
case uniffi.commands.Parameter.GroupPrivacyTarget groupPrivacyTarget:
// this should never really fail...
if (!GroupPrivacyUtils.TryParseGroupPrivacy(groupPrivacyTarget.target, out var groupPrivacy))
throw new PKError($"Invalid group privacy target {groupPrivacyTarget.target}");
return new Parameter.GroupPrivacyTarget(groupPrivacy);
case uniffi.commands.Parameter.SystemPrivacyTarget systemPrivacyTarget:
// this should never really fail...
if (!SystemPrivacyUtils.TryParseSystemPrivacy(systemPrivacyTarget.target, out var systemPrivacy))
throw new PKError($"Invalid system privacy target {systemPrivacyTarget.target}");
return new Parameter.SystemPrivacyTarget(systemPrivacy);
case uniffi.commands.Parameter.PrivacyLevel privacyLevel:
return new Parameter.PrivacyLevel(privacyLevel.level == "public" ? PrivacyLevel.Public : privacyLevel.level == "private" ? PrivacyLevel.Private : throw new PKError($"Invalid privacy level {privacyLevel.level}"));
case uniffi.commands.Parameter.ProxySwitchAction(var action):
SystemConfig.ProxySwitchAction newVal;
// Is this word a *flag* (as in, starts with a - AND is not quoted)
if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) continue; // (if not, carry on w/ next word)
if (action.Equals("off", StringComparison.InvariantCultureIgnoreCase))
newVal = SystemConfig.ProxySwitchAction.Off;
else if (action.Equals("new", StringComparison.InvariantCultureIgnoreCase) || action.Equals("n", StringComparison.InvariantCultureIgnoreCase) || action.Equals("on", StringComparison.InvariantCultureIgnoreCase))
newVal = SystemConfig.ProxySwitchAction.New;
else if (action.Equals("add", StringComparison.InvariantCultureIgnoreCase) || action.Equals("a", StringComparison.InvariantCultureIgnoreCase))
newVal = SystemConfig.ProxySwitchAction.Add;
else
throw new PKError("You must pass either \"new\", \"add\", or \"off\" to this command.");
// Find the *end* of the flag start (technically allowing arbitrary amounts of dashes)
var flagNameStart = wp.startPos;
while (flagNameStart < FullCommand.Length && FullCommand[flagNameStart] == '-')
flagNameStart++;
// Then add the word to the flag set
var word = FullCommand.Substring(flagNameStart, wp.endPos - flagNameStart).Trim();
if (word.Length > 0)
_flags.Add(word.ToLowerInvariant());
return new Parameter.ProxySwitchAction(newVal);
case uniffi.commands.Parameter.Toggle toggle:
return new Parameter.Toggle(toggle.toggle);
case uniffi.commands.Parameter.OpaqueString opaque:
return new Parameter.Opaque(opaque.raw);
case uniffi.commands.Parameter.OpaqueInt number:
return new Parameter.Number(number.raw);
case uniffi.commands.Parameter.Avatar avatar:
return new Parameter.Avatar(await ctx.GetUserPfp(avatar.avatar) ?? ctx.ParseImage(avatar.avatar));
case uniffi.commands.Parameter.MessageRef(var guildId, var channelId, var messageId):
return new Parameter.MessageRef(new Message.Reference(guildId, channelId, messageId));
case uniffi.commands.Parameter.ChannelRef(var channelId):
return new Parameter.ChannelRef(await ctx.Rest.GetChannelOrNull(channelId) ?? throw new PKError($"Channel {channelId} not found"));
case uniffi.commands.Parameter.GuildRef(var guildId):
return new Parameter.GuildRef(await ctx.Rest.GetGuildOrNull(guildId) ?? throw new PKError($"Guild {guildId} not found"));
case uniffi.commands.Parameter.Null:
return null;
}
return null;
}
public string Pop()
// resolves a single flag with value
private async Task<Parameter?> ResolveFlag(Context ctx, string flag_name)
{
// Loop to ignore and skip past flags
while (NextWordPosition(_ptr) is { } pos)
{
_ptr = pos.endPos + pos.advanceAfterWord;
if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue;
return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
if (!HasFlag(flag_name)) return null;
var flag_value = _flags[flag_name];
if (flag_value == null) return null;
var resolved = await ResolveFfiParam(ctx, flag_value);
if (resolved != null) return resolved;
// this should never happen, types are handled rust side
return null;
}
public string Peek()
// resolves a single parameter
private async Task<Parameter?> ResolveParameter(Context ctx, string param_name)
{
// temp ptr so we don't move the real ptr
int ptr = _ptr;
return PeekWithPtr(ref ptr);
if (!_params.ContainsKey(param_name)) return null;
var resolved = await ResolveFfiParam(ctx, _params[param_name]);
if (resolved != null) return resolved;
// this should never happen, types are handled rust side
return null;
}
public string PeekWithPtr(ref int ptr)
public async Task<T?> ResolveFlag<T>(Context ctx, string flag_name, Func<Parameter, T?> extract_func)
{
// Loop to ignore and skip past flags
while (NextWordPosition(ptr) is { } pos)
{
ptr = pos.endPos + pos.advanceAfterWord;
if (FullCommand[pos.startPos] == '-' && !pos.wasQuoted) continue;
return FullCommand.Substring(pos.startPos, pos.endPos - pos.startPos).Trim();
}
return "";
var param = await ResolveFlag(ctx, flag_name);
// todo: i think this should return null for everything...?
if (param == null) return default;
return extract_func(param)
// this should never happen unless codegen somehow uses a wrong name
?? throw new PKError($"Flag {flag_name.AsCode()} was not found or did not have a value defined for command {Callback().AsCode()} -- this is a bug!!");
}
public ISet<string> Flags()
public async Task<T> ResolveParameter<T>(Context ctx, string param_name, Func<Parameter, T?> extract_func)
{
if (_flags == null) ParseFlags();
return _flags;
}
public string Remainder(bool skipFlags = true)
{
if (skipFlags)
// Skip all *leading* flags when taking the remainder
while (NextWordPosition(_ptr) is { } wp)
{
if (FullCommand[wp.startPos] != '-' || wp.wasQuoted) break;
_ptr = wp.endPos + wp.advanceAfterWord;
}
// *Then* get the remainder
return FullCommand.Substring(Math.Min(_ptr, FullCommand.Length)).Trim();
}
private WordPosition? NextWordPosition(int position)
{
// Skip leading spaces before actual content
while (position < FullCommand.Length && FullCommand[position] == ' ') position++;
// Is this the end of the string?
if (FullCommand.Length <= position) return null;
// Is this a quoted word?
if (TryCheckQuote(FullCommand[position], out var endQuotes))
{
// We found a quoted word - find an instance of one of the corresponding end quotes
var endQuotePosition = -1;
for (var i = position + 1; i < FullCommand.Length; i++)
if (endQuotePosition == -1 && endQuotes.Contains(FullCommand[i]))
endQuotePosition = i; // need a break; don't feel like brackets tho lol
// Position after the end quote should be EOL or a space
// Otherwise we fallthrough to the unquoted word handler below
if (FullCommand.Length == endQuotePosition + 1 || FullCommand[endQuotePosition + 1] == ' ')
return new WordPosition(position + 1, endQuotePosition, 2, true);
}
// Not a quoted word, just find the next space and return if it's the end of the command
var wordEnd = FullCommand.IndexOf(' ', position + 1);
return wordEnd == -1
? new WordPosition(position, FullCommand.Length, 0, false)
: new WordPosition(position, wordEnd, 1, false);
}
private bool TryCheckQuote(char potentialLeftQuote, out string correspondingRightQuotes)
{
foreach (var (left, right) in _quotePairs)
if (left.Contains(potentialLeftQuote))
{
correspondingRightQuotes = right;
return true;
}
correspondingRightQuotes = null;
return false;
var param = await ResolveParameter(ctx, param_name);
// todo: i think this should return null for everything...?
if (param == null) return default;
return extract_func(param)
// this should never happen unless codegen somehow uses a wrong name
?? throw new PKError($"Parameter {param_name.AsCode()} was not found for command {Callback().AsCode()} -- this is a bug!!");
}
}

View file

@ -1,5 +1,3 @@
using System.Text.RegularExpressions;
using Humanizer;
using Dapper;
using SqlKata;
@ -113,43 +111,27 @@ public class Admin
return eb.Build();
}
public async Task UpdateSystemId(Context ctx)
public async Task UpdateSystemId(Context ctx, PKSystem target, string newHid, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new system ID `{input}`.");
var existingSystem = await ctx.Repository.GetSystemByHid(newHid);
if (existingSystem != null)
throw new PKError($"Another system already exists with ID `{newHid}`.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change", flagValue: confirmYes))
throw new PKError("ID change cancelled.");
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Hid = newHid });
await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task UpdateMemberId(Context ctx)
public async Task UpdateMemberId(Context ctx, PKMember target, string newHid, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchMember();
if (target == null)
throw new PKError("Unknown member.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new member ID `{input}`.");
var existingMember = await ctx.Repository.GetMemberByHid(newHid);
if (existingMember != null)
throw new PKError($"Another member already exists with ID `{newHid}`.");
@ -159,7 +141,7 @@ public class Admin
if (!await ctx.PromptYesNo(
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
"Change"
"Change", flagValue: confirmYes
))
throw new PKError("ID change cancelled.");
@ -167,18 +149,10 @@ public class Admin
await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task UpdateGroupId(Context ctx)
public async Task UpdateGroupId(Context ctx, PKGroup target, string newHid, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchGroup();
if (target == null)
throw new PKError("Unknown group.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new group ID `{input}`.");
var existingGroup = await ctx.Repository.GetGroupByHid(newHid);
if (existingGroup != null)
throw new PKError($"Another group already exists with ID `{newHid}`.");
@ -187,7 +161,7 @@ public class Admin
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?",
"Change"
"Change", flagValue: confirmYes
))
throw new PKError("ID change cancelled.");
@ -195,17 +169,13 @@ public class Admin
await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollSystemId(Context ctx)
public async Task RerollSystemId(Context ctx, PKSystem target, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll"))
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll", flagValue: confirmYes))
throw new PKError("ID change cancelled.");
var query = new Query("systems").AsUpdate(new
@ -218,20 +188,16 @@ public class Admin
await ctx.Reply($"{Emojis.Success} System ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollMemberId(Context ctx)
public async Task RerollMemberId(Context ctx, PKMember target, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchMember();
if (target == null)
throw new PKError("Unknown member.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo(
$"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?",
"Reroll"
"Reroll", flagValue: confirmYes
))
throw new PKError("ID change cancelled.");
@ -245,19 +211,15 @@ public class Admin
await ctx.Reply($"{Emojis.Success} Member ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task RerollGroupId(Context ctx)
public async Task RerollGroupId(Context ctx, PKGroup target, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchGroup();
if (target == null)
throw new PKError("Unknown group.");
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Reroll group ID for **{target.Name}** (`{target.Hid}`)?",
"Change"
"Change", flagValue: confirmYes
))
throw new PKError("ID change cancelled.");
@ -271,71 +233,52 @@ public class Admin
await ctx.Reply($"{Emojis.Success} Group ID updated (`{target.Hid}` -> `{newHid}`).");
}
public async Task SystemMemberLimit(Context ctx)
public async Task SystemMemberLimit(Context ctx, PKSystem target, int? newLimit, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var config = await ctx.Repository.GetSystemConfig(target.Id);
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (!ctx.HasNext())
if (newLimit == null)
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000");
if (!int.TryParse(newLimitStr, out var newLimit))
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update", flagValue: confirmYes))
throw new PKError("Member limit change cancelled.");
await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { MemberLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Member limit updated.");
}
public async Task SystemGroupLimit(Context ctx)
public async Task SystemGroupLimit(Context ctx, PKSystem target, int? newLimit, bool confirmYes)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
var config = await ctx.Repository.GetSystemConfig(target.Id);
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (!ctx.HasNext())
if (newLimit == null)
{
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
var newLimitStr = ctx.PopArgument().ToLower().Replace(",", null).Replace("k", "000");
if (!int.TryParse(newLimitStr, out var newLimit))
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update", flagValue: confirmYes))
throw new PKError("Group limit change cancelled.");
await ctx.Repository.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Group limit updated.");
}
public async Task SystemRecover(Context ctx)
public async Task SystemRecover(Context ctx, string systemToken, User account, bool rerollToken, bool confirmYes)
{
ctx.AssertBotAdmin();
var rerollToken = ctx.MatchFlag("rt", "reroll-token");
var systemToken = ctx.PopArgument();
var systemId = await ctx.Database.Execute(conn => conn.QuerySingleOrDefaultAsync<SystemId?>(
"select id from systems where token = @token",
new { token = systemToken }
@ -344,10 +287,6 @@ public class Admin
if (systemId == null)
throw new PKError("Could not retrieve a system with that token.");
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the system with (either ID or @mention).");
var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id);
if (existingAccount != null)
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config, ctx.DefaultPrefix);
@ -355,7 +294,7 @@ public class Admin
var system = await ctx.Repository.GetSystem(systemId.Value!);
await ctx.Reply(null, await CreateEmbed(ctx, system));
if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account"))
if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account", flagValue: confirmYes))
throw new PKError("System recovery cancelled.");
await ctx.Repository.AddAccount(system.Id, account.Id);
@ -378,14 +317,10 @@ public class Admin
});
}
public async Task SystemDelete(Context ctx)
public async Task SystemDelete(Context ctx, PKSystem target)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply($"To delete the following system, reply with the system's UUID: `{target.Uuid.ToString()}`",
await CreateEmbed(ctx, target));
if (!await ctx.ConfirmWithReply(target.Uuid.ToString()))
@ -396,18 +331,11 @@ public class Admin
await ctx.Reply($"{Emojis.Success} System deletion succesful.");
}
public async Task AbuseLogCreate(Context ctx)
public async Task AbuseLogCreate(Context ctx, User account, bool denyBotUsage, string? description)
{
var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage");
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
ctx.AssertBotAdmin();
string? desc = null!;
if (ctx.HasNext(false))
desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var abuseLog = await ctx.Repository.CreateAbuseLog(desc, denyBotUsage);
var abuseLog = await ctx.Repository.CreateAbuseLog(description, denyBotUsage);
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
@ -415,14 +343,49 @@ public class Admin
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogShow(Context ctx, AbuseLog abuseLog)
public async Task<AbuseLog?> GetAbuseLog(Context ctx, User? account, string? id)
{
ctx.AssertBotAdmin();
AbuseLog? abuseLog = null!;
if (account != null)
{
abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id);
}
else
{
abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(id));
}
if (abuseLog == null)
{
await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query.");
return null;
}
return abuseLog;
}
public async Task AbuseLogShow(Context ctx, User? account, string? id)
{
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
await ctx.Reply(null, await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogFlagDeny(Context ctx, AbuseLog abuseLog)
public async Task AbuseLogFlagDeny(Context ctx, User? account, string? id, bool? value)
{
if (!ctx.HasNext())
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
if (value == null)
{
await ctx.Reply(
$"Bot usage is currently {(abuseLog.DenyBotUsage ? "denied" : "allowed")} "
@ -430,27 +393,31 @@ public class Admin
}
else
{
var value = ctx.MatchToggle(true);
if (abuseLog.DenyBotUsage != value)
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value });
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value.Value });
await ctx.Reply(
$"Bot usage is now **{(value ? "denied" : "allowed")}** "
$"Bot usage is now **{(value.Value ? "denied" : "allowed")}** "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
}
public async Task AbuseLogDescription(Context ctx, AbuseLog abuseLog)
public async Task AbuseLogDescription(Context ctx, User? account, string? id, string? description, bool clear, bool confirmClear)
{
if (ctx.MatchClear() && await ctx.ConfirmClear("this abuse log description"))
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
if (clear && await ctx.ConfirmClear("this abuse log description", confirmClear))
{
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = null });
await ctx.Reply($"{Emojis.Success} Abuse log description cleared.");
}
else if (ctx.HasNext())
else if (description != null)
{
var desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = desc });
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = description });
await ctx.Reply($"{Emojis.Success} Abuse log description updated.");
}
else
@ -461,11 +428,13 @@ public class Admin
}
}
public async Task AbuseLogAddUser(Context ctx, AbuseLog abuseLog)
public async Task AbuseLogAddUser(Context ctx, User? accountToFind, string? id, User account)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id);
if (abuseLog == null)
return;
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
@ -473,11 +442,13 @@ public class Admin
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogRemoveUser(Context ctx, AbuseLog abuseLog)
public async Task AbuseLogRemoveUser(Context ctx, User? accountToFind, string? id, User account)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to remove from the abuse log (either ID or @mention).");
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, accountToFind, id);
if (abuseLog == null)
return;
await ctx.Repository.UpdateAccount(account.Id, new()
{
@ -489,8 +460,14 @@ public class Admin
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogDelete(Context ctx, AbuseLog abuseLog)
public async Task AbuseLogDelete(Context ctx, User? account, string? id)
{
ctx.AssertBotAdmin();
AbuseLog? abuseLog = await GetAbuseLog(ctx, account, id);
if (abuseLog == null)
return;
if (!await ctx.PromptYesNo($"Really delete abuse log entry `{abuseLog.Uuid}`?", "Delete", matchFlag: false))
{
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
@ -501,17 +478,10 @@ public class Admin
await ctx.Reply($"{Emojis.Success} Successfully deleted abuse log entry.");
}
public async Task SendAdminMessage(Context ctx)
public async Task SendAdminMessage(Context ctx, User account, string content)
{
ctx.AssertBotAdmin();
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to send an admin message to (either ID or @mention).");
if (!ctx.HasNext())
throw new PKError("You must provide a message to send.");
var content = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var messageContent = $"## [Admin Message]\n\n{content}\n\nWe cannot read replies sent to this DM. If you wish to contact the staff team, please join the support server (<https://discord.gg/PczBt78>) or send us an email at <legal@pluralkit.me>.";
try

View file

@ -115,28 +115,32 @@ public class Api
}
}
public async Task SystemWebhook(Context ctx)
public async Task GetSystemWebhook(Context ctx)
{
ctx.CheckSystem().CheckDMContext();
if (!ctx.HasNext(false))
{
if (ctx.System.WebhookUrl == null)
await ctx.Reply($"Your system does not have a webhook URL set. Set one with `{ctx.DefaultPrefix}system webhook <url>`!");
else
await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>.");
if (ctx.System.WebhookUrl == null)
await ctx.Reply($"Your system does not have a webhook URL set. Set one with `{ctx.DefaultPrefix}system webhook <url>`!");
else
await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>.");
}
public async Task ClearSystemWebhook(Context ctx, bool confirmYes)
{
ctx.CheckSystem().CheckDMContext();
if (!await ctx.ConfirmClear("your system's webhook URL", confirmYes))
return;
}
if (ctx.MatchClear() && await ctx.ConfirmClear("your system's webhook URL"))
{
await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null });
await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = null, WebhookToken = null });
await ctx.Reply($"{Emojis.Success} System webhook URL removed.");
return;
}
await ctx.Reply($"{Emojis.Success} System webhook URL removed.");
}
public async Task SetSystemWebhook(Context ctx, string newUrl)
{
ctx.CheckSystem().CheckDMContext();
var newUrl = ctx.RemainderOrNull();
if (!await DispatchExt.ValidateUri(newUrl))
throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?");

View file

@ -11,37 +11,51 @@ public class Autoproxy
{
private readonly IClock _clock;
public abstract record Mode()
{
public record Off(): Mode;
public record Latch(): Mode;
public record Front(): Mode;
public record Member(PKMember member): Mode;
}
public Autoproxy(IClock clock)
{
_clock = clock;
}
public async Task SetAutoproxyMode(Context ctx)
public async Task SetAutoproxyMode(Context ctx, Mode? mode = null)
{
// no need to check account here, it's already done at CommandTree
ctx.CheckGuildContext();
ctx.CheckSystem().CheckGuildContext();
// for now, just for guild
// this also creates settings if there are none present
var settings = await ctx.Repository.GetAutoproxySettings(ctx.System.Id, ctx.Guild.Id, null);
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove"))
await AutoproxyOff(ctx, settings);
else if (ctx.Match("latch", "last", "proxy", "stick", "sticky", "l"))
await AutoproxyLatch(ctx, settings);
else if (ctx.Match("front", "fronter", "switch", "f"))
await AutoproxyFront(ctx, settings);
else if (ctx.Match("member"))
throw new PKSyntaxError($"Member-mode autoproxy must target a specific member. Use the `{ctx.DefaultPrefix}autoproxy <member>` command, where `member` is the name or ID of a member in your system.");
else if (await ctx.MatchMember() is PKMember member)
await AutoproxyMember(ctx, member);
else if (!ctx.HasNext())
await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx, settings));
else
throw new PKSyntaxError($"Invalid autoproxy mode {ctx.PopArgument().AsCode()}.");
if (mode == null)
{
await AutoproxyShow(ctx, settings);
return;
}
switch (mode)
{
case Mode.Off:
await AutoproxyOff(ctx, settings);
break;
case Mode.Latch:
await AutoproxyLatch(ctx, settings);
break;
case Mode.Front:
await AutoproxyFront(ctx, settings);
break;
case Mode.Member(var member):
await AutoproxyMember(ctx, member);
break;
}
}
private async Task AutoproxyOff(Context ctx, AutoproxySettings settings)
public async Task AutoproxyOff(Context ctx, AutoproxySettings settings)
{
if (settings.AutoproxyMode == AutoproxyMode.Off)
{
@ -54,7 +68,7 @@ public class Autoproxy
}
}
private async Task AutoproxyLatch(Context ctx, AutoproxySettings settings)
public async Task AutoproxyLatch(Context ctx, AutoproxySettings settings)
{
if (settings.AutoproxyMode == AutoproxyMode.Latch)
{
@ -67,7 +81,7 @@ public class Autoproxy
}
}
private async Task AutoproxyFront(Context ctx, AutoproxySettings settings)
public async Task AutoproxyFront(Context ctx, AutoproxySettings settings)
{
if (settings.AutoproxyMode == AutoproxyMode.Front)
{
@ -80,7 +94,7 @@ public class Autoproxy
}
}
private async Task AutoproxyMember(Context ctx, PKMember member)
public async Task AutoproxyMember(Context ctx, PKMember member)
{
ctx.CheckOwnMember(member);
@ -90,6 +104,11 @@ public class Autoproxy
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.NameFor(ctx)}** in this server.");
}
public async Task AutoproxyShow(Context ctx, AutoproxySettings settings)
{
await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx, settings));
}
private async Task<Embed> CreateAutoproxyStatusEmbed(Context ctx, AutoproxySettings settings)
{
var commandList = $"**{ctx.DefaultPrefix}autoproxy latch** - Autoproxies as last-proxied member"

View file

@ -36,37 +36,11 @@ public class Checks
_cache = cache;
}
public async Task PermCheckGuild(Context ctx)
public async Task PermCheckGuild(Context ctx, Guild guild)
{
Guild guild;
GuildMemberPartial senderGuildUser = null;
if (ctx.Guild != null && !ctx.HasNext())
{
guild = ctx.Guild;
senderGuildUser = ctx.Member;
}
else
{
var guildIdStr = ctx.RemainderOrNull() ??
throw new PKSyntaxError("You must pass a server ID or run this command in a server.");
if (!ulong.TryParse(guildIdStr, out var guildId))
throw new PKSyntaxError($"Could not parse {guildIdStr.AsCode()} as an ID.");
try
{
guild = await _rest.GetGuild(guildId);
}
catch (ForbiddenException)
{
throw Errors.GuildNotFound(guildId);
}
if (guild != null)
senderGuildUser = await _rest.GetGuildMember(guildId, ctx.Author.Id);
if (guild == null || senderGuildUser == null)
throw Errors.GuildNotFound(guildId);
}
var senderGuildUser = await _rest.GetGuildMember(guild.Id, ctx.Author.Id);
if (senderGuildUser == null)
throw Errors.GuildNotFound(guild.Id);
var guildMember = await _rest.GetGuildMember(guild.Id, _botConfig.ClientId);
@ -135,17 +109,13 @@ public class Checks
await ctx.Reply(embed: eb.Build());
}
public async Task PermCheckChannel(Context ctx)
public async Task PermCheckChannel(Context ctx, Channel channel)
{
if (!ctx.HasNext())
throw new PKSyntaxError("You need to specify a channel.");
var error = "Channel not found or you do not have permissions to access it.";
// todo: this breaks if channel is not in cache and bot does not have View Channel permissions
// with new cache it breaks if channel is not in current guild
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId == null)
if (channel.GuildId == null)
throw new PKError(error);
var guild = await _rest.GetGuildOrNull(channel.GuildId.Value);
@ -189,15 +159,16 @@ public class Checks
await ctx.Reply(embed: eb.Build());
}
public async Task MessageProxyCheck(Context ctx)
public async Task MessageProxyCheck(Context ctx, Message.Reference? messageReference)
{
if (!ctx.HasNext() && ctx.Message.MessageReference == null)
if (messageReference == null && ctx.Message.MessageReference == null)
throw new PKSyntaxError("You need to specify a message.");
var failedToGetMessage =
"Could not find a valid message to check, was not able to fetch the message, or the message was not sent by you.";
var (messageId, channelId) = ctx.MatchMessage(false);
messageReference = ctx.GetRepliedTo();
var (messageId, channelId) = (messageReference?.MessageId, messageReference?.ChannelId);
if (messageId == null || channelId == null)
throw new PKError(failedToGetMessage);

View file

@ -197,17 +197,16 @@ public class Config
}
private string EnabledDisabled(bool value) => value ? "enabled" : "disabled";
public async Task AutoproxyAccount(Context ctx)
public async Task ViewAutoproxyAccount(Context ctx)
{
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
if (!ctx.HasNext())
{
await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>.");
return;
}
await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>.");
}
var allow = ctx.MatchToggle(true);
public async Task EditAutoproxyAccount(Context ctx, bool allow)
{
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
var statusString = EnabledDisabled(allow);
if (allowAutoproxy == allow)
@ -220,80 +219,87 @@ public class Config
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
}
public async Task AutoproxyTimeout(Context ctx)
public async Task ViewAutoproxyTimeout(Context ctx)
{
if (!ctx.HasNext())
{
var timeout = ctx.Config.LatchTimeout.HasValue
? Duration.FromSeconds(ctx.Config.LatchTimeout.Value)
: (Duration?)null;
var timeout = ctx.Config.LatchTimeout.HasValue
? Duration.FromSeconds(ctx.Config.LatchTimeout.Value)
: (Duration?)null;
if (timeout == null)
await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
else if (timeout == Duration.Zero)
await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
return;
}
Duration? newTimeout;
Duration overflow = Duration.Zero;
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero;
else if (ctx.MatchClear()) newTimeout = null;
if (timeout == null)
await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
else if (timeout == Duration.Zero)
await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
}
public async Task DisableAutoproxyTimeout(Context ctx)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int)Duration.Zero.TotalSeconds });
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
}
public async Task ResetAutoproxyTimeout(Context ctx)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = null });
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
}
public async Task EditAutoproxyTimeout(Context ctx, string timeout)
{
Duration newTimeout;
Duration overflow = Duration.Zero;
// todo: we should parse date in the command parser
var timeoutStr = timeout;
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr)
?? throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
if (timeoutPeriod.TotalHours > 100000)
{
var timeoutStr = ctx.RemainderOrNull();
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
if (timeoutPeriod.Value.TotalHours > 100000)
{
// sanity check to prevent seconds overflow if someone types in 999999999
overflow = timeoutPeriod.Value;
newTimeout = Duration.Zero;
}
else newTimeout = timeoutPeriod;
// sanity check to prevent seconds overflow if someone types in 999999999
overflow = timeoutPeriod;
newTimeout = Duration.Zero;
}
else newTimeout = timeoutPeriod;
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout?.TotalSeconds });
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout.TotalSeconds });
if (newTimeout == null)
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
else if (newTimeout == Duration.Zero && overflow != Duration.Zero)
if (newTimeout == Duration.Zero && overflow != Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)");
else if (newTimeout == Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}.");
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.ToTimeSpan().Humanize(4)}.");
}
public async Task SystemTimezone(Context ctx)
public async Task ViewSystemTimezone(Context ctx)
{
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
if (ctx.MatchClear())
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" });
await ctx.Reply(
$"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `{ctx.DefaultPrefix}config tz <zone>`.");
}
await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC).");
return;
}
public async Task ResetSystemTimezone(Context ctx)
{
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
var zoneStr = ctx.RemainderOrNull();
if (zoneStr == null)
{
await ctx.Reply(
$"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `{ctx.DefaultPrefix}config tz <zone>`.");
return;
}
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" });
await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC).");
}
public async Task EditSystemTimezone(Context ctx, string zoneStr, bool confirmYes = false)
{
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
var zone = await FindTimeZone(ctx, zoneStr);
if (zone == null) throw Errors.InvalidTimeZone(zoneStr);
var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone);
var msg = $"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?";
if (!await ctx.PromptYesNo(msg, "Change Timezone")) throw Errors.TimezoneChangeCancelled;
if (!await ctx.PromptYesNo(msg, "Change Timezone", flagValue: confirmYes)) throw Errors.TimezoneChangeCancelled;
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { UiTz = zone.Id });
@ -360,27 +366,24 @@ public class Config
});
}
public async Task SystemPing(Context ctx)
public async Task ViewSystemPing(Context ctx)
{
// note: this is here because this is also used in `pk;system ping`, which does not CheckSystem
ctx.CheckSystem();
// todo: move all the other config settings to this format
await ctx.Reply($"Reaction pings are currently **{EnabledDisabled(ctx.Config.PingsEnabled)}** for your system. " +
$"To {EnabledDisabled(!ctx.Config.PingsEnabled)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!ctx.Config.PingsEnabled)[..^1]}`.");
}
String Response(bool isError, bool val)
=> $"Reaction pings are {(isError ? "already" : "currently")} **{EnabledDisabled(val)}** for your system. "
+ $"To {EnabledDisabled(!val)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!val)[..^1]}`.";
if (!ctx.HasNext())
{
await ctx.Reply(Response(false, ctx.Config.PingsEnabled));
return;
}
var value = ctx.MatchToggle(true);
public async Task EditSystemPing(Context ctx, bool value)
{
ctx.CheckSystem();
if (ctx.Config.PingsEnabled == value)
await ctx.Reply(Response(true, ctx.Config.PingsEnabled));
{
await ctx.Reply($"Reaction pings are already **{EnabledDisabled(ctx.Config.PingsEnabled)}** for your system. " +
$"To {EnabledDisabled(!value)[..^1]} reaction pings, type `{ctx.DefaultPrefix}config ping {EnabledDisabled(!value)[..^1]}`.");
}
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { PingsEnabled = value });
@ -388,230 +391,182 @@ public class Config
}
}
public async Task MemberDefaultPrivacy(Context ctx)
public async Task ViewMemberDefaultPrivacy(Context ctx)
{
if (!ctx.HasNext())
{
if (ctx.Config.MemberDefaultPrivate) { await ctx.Reply($"Newly created members will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private member off`"); }
else { await ctx.Reply($"Newly created members will currently have their privacy settings set to public. To automatically set new members' privacy settings to private, type `{ctx.DefaultPrefix}config private member on`"); }
}
if (ctx.Config.MemberDefaultPrivate)
await ctx.Reply($"Newly created members will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private member off`");
else
{
if (ctx.MatchToggle(false))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = true });
await ctx.Reply("Newly created members will now have their privacy settings set to private.");
}
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = false });
await ctx.Reply("Newly created members will now have their privacy settings set to public.");
}
}
await ctx.Reply($"Newly created members will currently have their privacy settings set to public. To automatically set new members' privacy settings to private, type `{ctx.DefaultPrefix}config private member on`");
}
public async Task GroupDefaultPrivacy(Context ctx)
public async Task EditMemberDefaultPrivacy(Context ctx, bool value)
{
if (!ctx.HasNext())
{
if (ctx.Config.GroupDefaultPrivate) { await ctx.Reply($"Newly created groups will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private group off`"); }
else { await ctx.Reply($"Newly created groups will currently have their privacy settings set to public. To automatically set new groups' privacy settings to private, type `{ctx.DefaultPrefix}config private group on`"); }
}
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { MemberDefaultPrivate = value });
if (value)
await ctx.Reply("Newly created members will now have their privacy settings set to private.");
else
{
if (ctx.MatchToggle(false))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = true });
await ctx.Reply("Newly created groups will now have their privacy settings set to private.");
}
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = false });
await ctx.Reply("Newly created groups will now have their privacy settings set to public.");
}
}
await ctx.Reply("Newly created members will now have their privacy settings set to public.");
}
public async Task ShowPrivateInfo(Context ctx)
public async Task ViewGroupDefaultPrivacy(Context ctx)
{
if (!ctx.HasNext())
{
if (ctx.Config.ShowPrivateInfo) await ctx.Reply("Private information is currently **shown** when looking up your own info. Use the `-public` flag to hide it.");
else await ctx.Reply("Private information is currently **hidden** when looking up your own info. Use the `-private` flag to show it.");
return;
}
if (ctx.Config.GroupDefaultPrivate)
await ctx.Reply($"Newly created groups will currently have their privacy settings set to private. To change this, type `{ctx.DefaultPrefix}config private group off`");
else
await ctx.Reply($"Newly created groups will currently have their privacy settings set to public. To automatically set new groups' privacy settings to private, type `{ctx.DefaultPrefix}config private group on`");
}
if (ctx.MatchToggle(true))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = true });
public async Task EditGroupDefaultPrivacy(Context ctx, bool value)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { GroupDefaultPrivate = value });
if (value)
await ctx.Reply("Newly created groups will now have their privacy settings set to private.");
else
await ctx.Reply("Newly created groups will now have their privacy settings set to public.");
}
public async Task ViewShowPrivateInfo(Context ctx)
{
if (ctx.Config.ShowPrivateInfo)
await ctx.Reply("Private information is currently **shown** when looking up your own info. Use the `-public` flag to hide it.");
else
await ctx.Reply("Private information is currently **hidden** when looking up your own info. Use the `-private` flag to show it.");
}
public async Task EditShowPrivateInfo(Context ctx, bool value)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = value });
if (value)
await ctx.Reply("Private information will now be **shown** when looking up your own info. Use the `-public` flag to hide it.");
}
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ShowPrivateInfo = false });
await ctx.Reply("Private information will now be **hidden** when looking up your own info. Use the `-private` flag to show it.");
}
}
public async Task CaseSensitiveProxyTags(Context ctx)
public async Task ViewCaseSensitiveProxyTags(Context ctx)
{
if (!ctx.HasNext())
{
if (ctx.Config.CaseSensitiveProxyTags) { await ctx.Reply("Proxy tags are currently case **sensitive**."); }
else { await ctx.Reply("Proxy tags are currently case **insensitive**."); }
return;
}
if (ctx.Config.CaseSensitiveProxyTags)
await ctx.Reply("Proxy tags are currently case **sensitive**.");
else
await ctx.Reply("Proxy tags are currently case **insensitive**.");
}
if (ctx.MatchToggle(true))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = true });
public async Task EditCaseSensitiveProxyTags(Context ctx, bool value)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = value });
if (value)
await ctx.Reply("Proxy tags are now case sensitive.");
}
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CaseSensitiveProxyTags = false });
await ctx.Reply("Proxy tags are now case insensitive.");
}
}
public async Task ProxyErrorMessageEnabled(Context ctx)
public async Task ViewProxyErrorMessageEnabled(Context ctx)
{
if (!ctx.HasNext())
{
if (ctx.Config.ProxyErrorMessageEnabled) { await ctx.Reply("Proxy error messages are currently **enabled**."); }
else { await ctx.Reply("Proxy error messages are currently **disabled**. Messages that fail to proxy (due to message or attachment size) will not throw an error message."); }
return;
}
if (ctx.MatchToggle(true))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = true });
await ctx.Reply("Proxy error messages are now enabled.");
}
if (ctx.Config.ProxyErrorMessageEnabled)
await ctx.Reply("Proxy error messages are currently **enabled**.");
else
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = false });
await ctx.Reply("Proxy error messages are currently **disabled**. Messages that fail to proxy (due to message or attachment size) will not throw an error message.");
}
public async Task EditProxyErrorMessageEnabled(Context ctx, bool value)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxyErrorMessageEnabled = value });
if (value)
await ctx.Reply("Proxy error messages are now enabled.");
else
await ctx.Reply("Proxy error messages are now disabled. Messages that fail to proxy (due to message or attachment size) will not throw an error message.");
}
}
public async Task HidDisplaySplit(Context ctx)
public async Task ViewHidDisplaySplit(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Splitting of 6-character IDs with a hyphen is currently **{EnabledDisabled(ctx.Config.HidDisplaySplit)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplaySplit = newVal });
await ctx.Reply($"Splitting of 6-character IDs with a hyphen is now {EnabledDisabled(newVal)}.");
await ctx.Reply($"Splitting of 6-character IDs with a hyphen is currently **{EnabledDisabled(ctx.Config.HidDisplaySplit)}**.");
}
public async Task HidDisplayCaps(Context ctx)
public async Task EditHidDisplaySplit(Context ctx, bool value)
{
if (!ctx.HasNext())
{
var msg = $"Displaying IDs as capital letters is currently **{EnabledDisabled(ctx.Config.HidDisplayCaps)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplayCaps = newVal });
await ctx.Reply($"Displaying IDs as capital letters is now {EnabledDisabled(newVal)}.");
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplaySplit = value });
await ctx.Reply($"Splitting of 6-character IDs with a hyphen is now {EnabledDisabled(value)}.");
}
public async Task HidListPadding(Context ctx)
public async Task ViewHidDisplayCaps(Context ctx)
{
if (!ctx.HasNext())
{
string message;
switch (ctx.Config.HidListPadding)
{
case SystemConfig.HidPadFormat.None: message = "Padding 5-character IDs in lists is currently disabled."; break;
case SystemConfig.HidPadFormat.Left: message = "5-character IDs displayed in lists will have a padding space added to the beginning."; break;
case SystemConfig.HidPadFormat.Right: message = "5-character IDs displayed in lists will have a padding space added to the end."; break;
default: throw new Exception("unreachable");
}
await ctx.Reply(message);
return;
}
await ctx.Reply($"Displaying IDs as capital letters is currently **{EnabledDisabled(ctx.Config.HidDisplayCaps)}**.");
}
public async Task EditHidDisplayCaps(Context ctx, bool value)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplayCaps = value });
await ctx.Reply($"Displaying IDs as capital letters is now {EnabledDisabled(value)}.");
}
public async Task ViewHidListPadding(Context ctx)
{
string message = ctx.Config.HidListPadding switch
{
SystemConfig.HidPadFormat.None => "Padding 5-character IDs in lists is currently disabled.",
SystemConfig.HidPadFormat.Left => "5-character IDs displayed in lists will have a padding space added to the beginning.",
SystemConfig.HidPadFormat.Right => "5-character IDs displayed in lists will have a padding space added to the end.",
_ => throw new Exception("unreachable")
};
await ctx.Reply(message);
}
public async Task EditHidListPadding(Context ctx, string padding)
{
var badInputError = "Valid padding settings are `left`, `right`, or `off`.";
var toggleOff = ctx.MatchToggleOrNull(false);
switch (toggleOff)
if (padding.Equals("off", StringComparison.InvariantCultureIgnoreCase))
{
case true: throw new PKError(badInputError);
case false:
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.None });
await ctx.Reply("Padding 5-character IDs in lists has been disabled.");
return;
}
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.None });
await ctx.Reply("Padding 5-character IDs in lists has been disabled.");
}
if (ctx.Match("left", "l"))
else if (padding.Equals("left", StringComparison.InvariantCultureIgnoreCase) || padding.Equals("l", StringComparison.InvariantCultureIgnoreCase))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Left });
await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the beginning.");
}
else if (ctx.Match("right", "r"))
else if (padding.Equals("right", StringComparison.InvariantCultureIgnoreCase) || padding.Equals("r", StringComparison.InvariantCultureIgnoreCase))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Right });
await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the end.");
}
else throw new PKError(badInputError);
else
{
throw new PKError(badInputError);
}
}
public async Task CardShowColorHex(Context ctx)
public async Task ViewCardShowColorHex(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Showing color codes on system/member/group cards is currently **{EnabledDisabled(ctx.Config.CardShowColorHex)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CardShowColorHex = newVal });
await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(newVal)}.");
await ctx.Reply($"Showing color codes on system/member/group cards is currently **{EnabledDisabled(ctx.Config.CardShowColorHex)}**.");
}
public async Task ProxySwitch(Context ctx)
public async Task EditCardShowColorHex(Context ctx, bool value)
{
if (!ctx.HasNext())
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { CardShowColorHex = value });
await ctx.Reply($"Showing color codes on system/member/group cards is now {EnabledDisabled(value)}.");
}
public async Task ViewProxySwitch(Context ctx)
{
string msg = ctx.Config.ProxySwitch switch
{
string msg = ctx.Config.ProxySwitch switch
{
SystemConfig.ProxySwitchAction.Off => "Currently, when you proxy as a member, no switches are logged or changed.",
SystemConfig.ProxySwitchAction.New => "When you proxy as a member, currently it makes a new switch.",
SystemConfig.ProxySwitchAction.Add => "When you proxy as a member, currently it adds them to the current switch.",
_ => throw new Exception("unreachable"),
};
await ctx.Reply(msg);
return;
}
SystemConfig.ProxySwitchAction.Off => "Currently, when you proxy as a member, no switches are logged or changed.",
SystemConfig.ProxySwitchAction.New => "When you proxy as a member, currently it makes a new switch.",
SystemConfig.ProxySwitchAction.Add => "When you proxy as a member, currently it adds them to the current switch.",
_ => throw new Exception("unreachable"),
};
await ctx.Reply(msg);
}
// toggle = false means off, toggle = true means new, otherwise if they said add that means add or if they said new they mean new. If none of those, error
var toggle = ctx.MatchToggleOrNull(false);
var newVal = toggle == false ? SystemConfig.ProxySwitchAction.Off : toggle == true ? SystemConfig.ProxySwitchAction.New : ctx.Match("add", "a") ? SystemConfig.ProxySwitchAction.Add : ctx.Match("new", "n") ? SystemConfig.ProxySwitchAction.New : throw new PKError("You must pass either \"new\", \"add\", or \"off\" to this command.");
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxySwitch = newVal });
switch (newVal)
public async Task EditProxySwitch(Context ctx, SystemConfig.ProxySwitchAction action)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxySwitch = action });
switch (action)
{
case SystemConfig.ProxySwitchAction.Off: await ctx.Reply("Now when you proxy as a member, no switches are logged or changed."); break;
case SystemConfig.ProxySwitchAction.New: await ctx.Reply("When you proxy as a member, it now makes a new switch."); break;
@ -620,65 +575,61 @@ public class Config
}
}
public async Task NameFormat(Context ctx)
public async Task ViewNameFormat(Context ctx)
{
var clearFlag = ctx.MatchClear();
if (!ctx.HasNext() && !clearFlag)
{
await ctx.Reply($"Member names are currently formatted as `{ctx.Config.NameFormat ?? ProxyMember.DefaultFormat}`");
return;
}
await ctx.Reply($"Member names are currently formatted as `{ctx.Config.NameFormat ?? ProxyMember.DefaultFormat}`");
}
string formatString;
if (clearFlag)
formatString = ProxyMember.DefaultFormat;
else
formatString = ctx.RemainderOrNull();
public async Task ResetNameFormat(Context ctx)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { NameFormat = ProxyMember.DefaultFormat });
await ctx.Reply($"Member names are now formatted as `{ProxyMember.DefaultFormat}`");
}
public async Task EditNameFormat(Context ctx, string formatString)
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { NameFormat = formatString });
await ctx.Reply($"Member names are now formatted as `{formatString}`");
}
public async Task ServerNameFormat(Context ctx)
public async Task ViewServerNameFormat(Context ctx, ReplyFormat format)
{
ctx.CheckGuildContext();
var clearFlag = ctx.MatchClear();
var format = ctx.MatchFormat();
var guildCfg = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, ctx.System.Id);
// if there's nothing next or what's next is raw/plaintext and we're not clearing, it's a query
if ((!ctx.HasNext() || format != ReplyFormat.Standard) && !clearFlag)
{
if (guildCfg.NameFormat == null)
await ctx.Reply("You do not have a specific name format set for this server and member names are formatted with your global name format.");
else
switch (format)
{
case ReplyFormat.Raw:
await ctx.Reply($"`{guildCfg.NameFormat}`");
break;
case ReplyFormat.Plaintext:
var eb = new EmbedBuilder()
.Description($"Showing guild Name Format for system {ctx.System.DisplayHid(ctx.Config)}");
await ctx.Reply(guildCfg.NameFormat, eb.Build());
break;
default:
await ctx.Reply($"Your member names in this server are currently formatted as `{guildCfg.NameFormat}`");
break;
}
return;
}
string? formatString = null;
if (!clearFlag)
{
formatString = ctx.RemainderOrNull();
}
await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = formatString });
if (formatString == null)
await ctx.Reply($"Member names are now formatted with your global name format in this server.");
if (guildCfg.NameFormat == null)
await ctx.Reply("You do not have a specific name format set for this server and member names are formatted with your global name format.");
else
await ctx.Reply($"Member names are now formatted as `{formatString}` in this server.");
switch (format)
{
case ReplyFormat.Raw:
await ctx.Reply($"`{guildCfg.NameFormat}`");
break;
case ReplyFormat.Plaintext:
var eb = new EmbedBuilder()
.Description($"Showing guild Name Format for system {ctx.System.DisplayHid(ctx.Config)}");
await ctx.Reply(guildCfg.NameFormat, eb.Build());
break;
default:
await ctx.Reply($"Your member names in this server are currently formatted as `{guildCfg.NameFormat}`");
break;
}
}
public async Task ResetServerNameFormat(Context ctx)
{
ctx.CheckGuildContext();
await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = null });
await ctx.Reply($"Member names are now formatted with your global name format in this server.");
}
public async Task EditServerNameFormat(Context ctx, string formatString)
{
ctx.CheckGuildContext();
await ctx.Repository.UpdateSystemGuild(ctx.System.Id, ctx.Guild.Id, new() { NameFormat = formatString });
await ctx.Reply($"Member names are now formatted as `{formatString}` in this server.");
}
public Task LimitUpdate(Context ctx)

View file

@ -34,20 +34,19 @@ public class Fun
public Task Sus(Context ctx) =>
ctx.Reply("\U0001F4EE");
public Task Error(Context ctx)
{
if (ctx.Match("message"))
return ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder()
.Color(0xE74C3C)
.Title("Internal error occurred")
.Description(
"For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.")
.Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd"))
.Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O"))
.Build()
);
public Task Meow(Context ctx) =>
ctx.Reply("*mrrp :3*");
return ctx.Reply(
$"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
}
public Task ErrorMessage(Context ctx) => ctx.Reply("> **Error code:** `50f3c7b439d111ecab2023a5431fffbd`", new EmbedBuilder()
.Color(0xE74C3C)
.Title("Internal error occurred")
.Description(
"For support, please send the error code above in **#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)** with a description of what you were doing at the time.")
.Footer(new Embed.EmbedFooter("50f3c7b439d111ecab2023a5431fffbd"))
.Timestamp(SystemClock.Instance.GetCurrentInstant().ToDateTimeOffset().ToString("O"))
.Build()
);
public Task Error(Context ctx) => ctx.Reply(
$"{Emojis.Error} Unknown command {"error".AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
}

View file

@ -10,11 +10,11 @@ namespace PluralKit.Bot;
public class GroupMember
{
public async Task AddRemoveGroups(Context ctx, PKMember target, Groups.AddRemoveOperation op)
public async Task AddRemoveGroups(Context ctx, PKMember target, List<PKGroup> _groups, Groups.AddRemoveOperation op)
{
ctx.CheckSystem().CheckOwnMember(target);
var groups = (await ctx.ParseGroupList(ctx.System.Id))
var groups = _groups.FindAll(g => g.System == ctx.System.Id)
.Select(g => g.Id)
.Distinct()
.ToList();
@ -51,11 +51,12 @@ public class GroupMember
groups.Count - toAction.Count));
}
public async Task ListMemberGroups(Context ctx, PKMember target)
public async Task ListMemberGroups(Context ctx, PKMember target, string? query, IHasListOptions flags, bool all)
{
var targetSystem = await ctx.Repository.GetSystem(target.System);
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System));
var opts = flags.GetListOptions(ctx, target.System);
opts.MemberFilter = target.Id;
opts.Search = query;
var title = new StringBuilder($"Groups containing {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`) in ");
if (ctx.Guild != null)
@ -79,15 +80,15 @@ public class GroupMember
title.Append($" matching **{opts.Search.Truncate(100)}**");
await ctx.RenderGroupList(ctx.LookupContextFor(target.System), target.System, title.ToString(),
target.Color, opts);
target.Color, opts, all);
}
public async Task AddRemoveMembers(Context ctx, PKGroup target, Groups.AddRemoveOperation op)
public async Task AddRemoveMembers(Context ctx, PKGroup target, List<PKMember>? _members, Groups.AddRemoveOperation op, bool all, bool confirmYes = false)
{
ctx.CheckOwnGroup(target);
List<MemberId> members;
if (ctx.MatchFlag("all", "a"))
if (all)
{
members = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
new DatabaseViewsExt.ListQueryOptions { })))
@ -97,10 +98,14 @@ public class GroupMember
}
else
{
members = (await ctx.ParseMemberList(ctx.System.Id))
.Select(m => m.Id)
.Distinct()
.ToList();
if (_members == null)
throw new PKError("Please provide a list of members to add/remove.");
members = _members
.FindAll(m => m.System == ctx.System.Id)
.Select(m => m.Id)
.Distinct()
.ToList();
}
var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
@ -124,7 +129,7 @@ public class GroupMember
.Where(m => existingMembersInGroup.Contains(m.Value))
.ToList();
if (ctx.MatchFlag("all", "a") && !await ctx.PromptYesNo($"Are you sure you want to remove all members from group {target.Reference(ctx)}?", "Empty Group")) throw Errors.GenericCancelled();
if (all && !await ctx.PromptYesNo($"Are you sure you want to remove all members from group {target.Reference(ctx)}?", "Empty Group", flagValue: confirmYes)) throw Errors.GenericCancelled();
await ctx.Repository.RemoveMembersFromGroup(target.Id, toAction);
}
@ -137,15 +142,16 @@ public class GroupMember
members.Count - toAction.Count));
}
public async Task ListGroupMembers(Context ctx, PKGroup target)
public async Task ListGroupMembers(Context ctx, PKGroup target, string? query, IHasListOptions flags)
{
// see global system list for explanation of how privacy settings are used here
var targetSystem = await GetGroupSystem(ctx, target);
ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy);
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System));
var opts = flags.GetListOptions(ctx, target.System);
opts.GroupFilter = target.Id;
opts.Search = query;
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
if (ctx.Guild != null)

View file

@ -32,12 +32,11 @@ public class Groups
_avatarHosting = avatarHosting;
}
public async Task CreateGroup(Context ctx)
public async Task CreateGroup(Context ctx, string groupName, bool confirmYes = false)
{
ctx.CheckSystem();
// Check group name length
var groupName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a group name.");
if (groupName.Length > Limits.MaxGroupNameLength)
throw new PKError($"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters).");
@ -54,7 +53,7 @@ public class Groups
{
var msg =
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to create another group with the same name?";
if (!await ctx.PromptYesNo(msg, "Create"))
if (!await ctx.PromptYesNo(msg, "Create", flagValue: confirmYes))
throw new PKError("Group creation cancelled.");
}
@ -99,12 +98,11 @@ public class Groups
await ctx.Reply(replyStr, eb.Build());
}
public async Task RenameGroup(Context ctx, PKGroup target)
public async Task RenameGroup(Context ctx, PKGroup target, string? newName, bool confirmYes = false)
{
ctx.CheckOwnGroup(target);
// Check group name length
var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new group name.");
if (newName.Length > Limits.MaxGroupNameLength)
throw new PKError(
$"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters).");
@ -115,7 +113,7 @@ public class Groups
{
var msg =
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to rename this group to that name too?";
if (!await ctx.PromptYesNo(msg, "Rename"))
if (!await ctx.PromptYesNo(msg, "Rename", flagValue: confirmYes))
throw new PKError("Group rename cancelled.");
}
@ -124,7 +122,7 @@ public class Groups
await ctx.Reply($"{Emojis.Success} Group name changed from **{target.Name}** to **{newName}** (using {newName.Length}/{Limits.MaxGroupNameLength} characters).");
}
public async Task GroupDisplayName(Context ctx, PKGroup target)
public async Task ShowGroupDisplayName(Context ctx, PKGroup target, ReplyFormat format)
{
var noDisplayNameSetMessage = "This group does not have a display name set" +
(ctx.System?.Id == target.System
@ -134,8 +132,6 @@ public class Groups
// Whether displayname is shown or not should depend on if group name privacy is set.
// If name privacy is on then displayname should look like name.
var format = ctx.MatchFormat();
// if we're doing a raw or plaintext query check for null
if (format != ReplyFormat.Standard)
if (target.DisplayName == null || !target.NamePrivacy.CanAccess(ctx.DirectLookupContextFor(target.System)))
@ -157,69 +153,65 @@ public class Groups
return;
}
if (!ctx.HasNext(false))
{
var showDisplayName = target.NamePrivacy.CanAccess(ctx.LookupContextFor(target.System)) && target.DisplayName != null;
var showDisplayName = target.NamePrivacy.CanAccess(ctx.LookupContextFor(target.System)) && target.DisplayName != null;
var eb = new EmbedBuilder()
.Title("Group names")
.Field(new Embed.Field("Name", target.NameFor(ctx)))
.Field(new Embed.Field("Display Name", showDisplayName ? target.DisplayName : "*(no displayname set or name is private)*"));
var eb2 = new EmbedBuilder()
.Title("Group names")
.Field(new Embed.Field("Name", target.NameFor(ctx)))
.Field(new Embed.Field("Display Name", showDisplayName ? target.DisplayName : "*(no displayname set or name is private)*"));
var reference = target.Reference(ctx);
var reference = target.Reference(ctx);
if (ctx.System?.Id == target.System)
eb.Description(
$"To change display name, type `{ctx.DefaultPrefix}group {reference} displayname <display name>`.\n"
+ $"To clear it, type `{ctx.DefaultPrefix}group {reference} displayname -clear`.\n"
+ $"To print the raw display name, type `{ctx.DefaultPrefix}group {reference} displayname -raw`.");
if (ctx.System?.Id == target.System)
eb2.Description(
$"To change display name, type `{ctx.DefaultPrefix}group {reference} displayname <display name>`.\n"
+ $"To clear it, type `{ctx.DefaultPrefix}group {reference} displayname -clear`.\n"
+ $"To print the raw display name, type `{ctx.DefaultPrefix}group {reference} displayname -raw`.");
if (ctx.System?.Id == target.System && showDisplayName)
eb.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters."));
if (ctx.System?.Id == target.System && showDisplayName)
eb2.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters."));
await ctx.Reply(embed: eb.Build());
return;
}
ctx.CheckOwnGroup(target);
if (ctx.MatchClear() && await ctx.ConfirmClear("this group's display name"))
{
var patch = new GroupPatch { DisplayName = Partial<string>.Null() };
await ctx.Repository.UpdateGroup(target.Id, patch);
var replyStr = $"{Emojis.Success} Group display name cleared.";
if (target.NamePrivacy == PrivacyLevel.Private)
replyStr += $"\n{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**.";
await ctx.Reply(replyStr);
}
else
{
var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
if (newDisplayName.Length > Limits.MaxGroupNameLength)
throw new PKError($"Group name too long ({newDisplayName.Length}/{Limits.MaxGroupNameLength} characters).");
var patch = new GroupPatch { DisplayName = Partial<string>.Present(newDisplayName) };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group display name changed (using {newDisplayName.Length}/{Limits.MaxGroupNameLength} characters).");
}
await ctx.Reply(embed: eb2.Build());
}
public async Task GroupDescription(Context ctx, PKGroup target)
public async Task ClearGroupDisplayName(Context ctx, PKGroup target, bool confirmYes = false)
{
ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy);
ctx.CheckOwnGroup(target);
var noDescriptionSetMessage = "This group does not have a description set.";
if (ctx.System?.Id == target.System)
noDescriptionSetMessage +=
$" To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description <description>`.";
if (!await ctx.ConfirmClear("this group's display name", confirmYes))
return;
var format = ctx.MatchFormat();
var patch = new GroupPatch { DisplayName = Partial<string>.Null() };
await ctx.Repository.UpdateGroup(target.Id, patch);
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
var replyStr = $"{Emojis.Success} Group display name cleared.";
if (target.NamePrivacy == PrivacyLevel.Private)
replyStr += $"\n{Emojis.Warn} Since this group no longer has a display name set, their name privacy **can no longer take effect**.";
await ctx.Reply(replyStr);
}
public async Task ChangeGroupDisplayName(Context ctx, PKGroup target, string newDisplayName)
{
ctx.CheckOwnGroup(target);
if (newDisplayName.Length > Limits.MaxGroupNameLength)
throw new PKError($"Group name too long ({newDisplayName.Length}/{Limits.MaxGroupNameLength} characters).");
var patch = new GroupPatch { DisplayName = Partial<string>.Present(newDisplayName) };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group display name changed (using {newDisplayName.Length}/{Limits.MaxGroupNameLength} characters).");
}
public async Task ShowGroupDescription(Context ctx, PKGroup target, ReplyFormat format)
{
var noDescriptionSetMessage = "This group does not have a description set" +
(ctx.System?.Id == target.System
? $". To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description <description>`."
: ".");
// if we're doing a raw or plaintext query check for null
if (format != ReplyFormat.Standard)
if (target.Description == null)
{
await ctx.Reply(noDescriptionSetMessage);
@ -239,246 +231,291 @@ public class Groups
return;
}
if (!ctx.HasNext(false))
if (target.Description == null)
{
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group description")
.Description(target.Description)
.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description -raw`."
+ (ctx.System?.Id == target.System
? $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} description -clear`."
: "")
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
.Build());
await ctx.Reply(noDescriptionSetMessage);
return;
}
var eb2 = new EmbedBuilder()
.Title("Group description")
.Description(target.Description);
var reference = target.Reference(ctx);
if (ctx.System?.Id == target.System)
eb2.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `{ctx.DefaultPrefix}group {reference} description -raw`."
+ $" To clear it, type `{ctx.DefaultPrefix}group {reference} description -clear`."
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."));
else
eb2.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `{ctx.DefaultPrefix}group {reference} description -raw`."
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."));
await ctx.Reply(embed: eb2.Build());
}
public async Task ClearGroupDescription(Context ctx, PKGroup target, bool confirmYes = false)
{
ctx.CheckOwnGroup(target);
if (ctx.MatchClear() && await ctx.ConfirmClear("this group's description"))
{
var patch = new GroupPatch { Description = Partial<string>.Null() };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group description cleared.");
}
else
{
var description = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
if (description.IsLongerThan(Limits.MaxDescriptionLength))
throw Errors.StringTooLongError("Description", description.Length, Limits.MaxDescriptionLength);
if (!await ctx.ConfirmClear("this group's description", confirmYes))
return;
var patch = new GroupPatch { Description = Partial<string>.Present(description) };
await ctx.Repository.UpdateGroup(target.Id, patch);
var patch = new GroupPatch { Description = Partial<string>.Null() };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group description changed (using {description.Length}/{Limits.MaxDescriptionLength} characters).");
}
await ctx.Reply($"{Emojis.Success} Group description cleared.");
}
public async Task GroupIcon(Context ctx, PKGroup target)
public async Task ChangeGroupDescription(Context ctx, PKGroup target, string newDescription)
{
async Task ClearIcon()
{
await ctx.ConfirmClear("this group's icon");
ctx.CheckOwnGroup(target);
ctx.CheckOwnGroup(target);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null });
await ctx.Reply($"{Emojis.Success} Group icon cleared.");
}
if (newDescription.IsLongerThan(Limits.MaxDescriptionLength))
throw Errors.StringTooLongError("Description", newDescription.Length, Limits.MaxDescriptionLength);
async Task SetIcon(ParsedImage img)
{
ctx.CheckOwnGroup(target);
var patch = new GroupPatch { Description = Partial<string>.Present(newDescription) };
await ctx.Repository.UpdateGroup(target.Id, patch);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Reply($"{Emojis.Success} Group description changed (using {newDescription.Length}/{Limits.MaxDescriptionLength} characters).");
}
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
public async Task ShowGroupIcon(Context ctx, PKGroup target, ReplyFormat format)
{
var noIconSetMessage = "This group does not have an avatar set" +
(ctx.System?.Id == target.System
? ". Set one by attaching an image to this command, or by passing an image URL or @mention."
: ".");
var msg = img.Source switch
ctx.CheckSystemPrivacy(target.System, target.IconPrivacy);
// if we're doing a raw or plaintext query check for null
if (format != ReplyFormat.Standard)
if ((target.Icon?.Trim() ?? "").Length == 0)
{
AvatarSource.User =>
$"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.",
AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group icon changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
await ctx.Reply(noIconSetMessage);
return;
}
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
}
async Task ShowIcon()
if (format == ReplyFormat.Raw)
{
ctx.CheckSystemPrivacy(target.System, target.IconPrivacy);
if ((target.Icon?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat())
{
case ReplyFormat.Raw:
await ctx.Reply($"`{target.Icon.TryGetCleanCdnUrl()}`");
break;
case ReplyFormat.Plaintext:
var ebP = new EmbedBuilder()
.Description($"Showing avatar for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(text: $"<{target.Icon.TryGetCleanCdnUrl()}>", embed: ebP.Build());
break;
default:
var ebS = new EmbedBuilder()
.Title("Group icon")
.Image(new Embed.EmbedImage(target.Icon.TryGetCleanCdnUrl()));
if (target.System == ctx.System?.Id)
ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} icon -clear`.");
await ctx.Reply(embed: ebS.Build());
break;
}
else
throw new PKSyntaxError(
"This group does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
await ctx.Reply($"`{target.Icon.TryGetCleanCdnUrl()}`");
return;
}
if (ctx.MatchClear())
await ClearIcon();
else if (await ctx.MatchImage() is { } img)
await SetIcon(img);
else
await ShowIcon();
}
public async Task GroupBannerImage(Context ctx, PKGroup target)
{
async Task ClearBannerImage()
if (format == ReplyFormat.Plaintext)
{
ctx.CheckOwnGroup(target);
await ctx.ConfirmClear("this group's banner image");
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null });
await ctx.Reply($"{Emojis.Success} Group banner image cleared.");
}
async Task SetBannerImage(ParsedImage img)
{
ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group banner image changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.",
AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."),
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
}
async Task ShowBannerImage()
{
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.BannerImage?.Trim() ?? "").Length > 0)
switch (ctx.MatchFormat())
{
case ReplyFormat.Raw:
await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`");
break;
case ReplyFormat.Plaintext:
var ebP = new EmbedBuilder()
.Description($"Showing banner for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build());
break;
default:
var ebS = new EmbedBuilder()
.Title("Group banner image")
.Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl()));
if (target.System == ctx.System?.Id)
ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} banner clear`.");
await ctx.Reply(embed: ebS.Build());
break;
}
else
throw new PKSyntaxError(
"This group does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
}
if (ctx.MatchClear())
await ClearBannerImage();
else if (await ctx.MatchImage() is { } img)
await SetBannerImage(img);
else
await ShowBannerImage();
}
public async Task GroupColor(Context ctx, PKGroup target)
{
var isOwnSystem = ctx.System?.Id == target.System;
var matchedFormat = ctx.MatchFormat();
var matchedClear = ctx.MatchClear();
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
{
if (target.Color == null)
await ctx.Reply(
"This group does not have a color set." + (isOwnSystem ? $" To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color <color>`." : ""));
else if (matchedFormat == ReplyFormat.Raw)
await ctx.Reply("```\n#" + target.Color + "\n```");
else if (matchedFormat == ReplyFormat.Plaintext)
await ctx.Reply(target.Color);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Description($"This group's color is **#{target.Color}**."
+ (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color -clear`." : ""))
.Build(),
files: [MiscUtils.GenerateColorPreview(target.Color)]);
var ebP = new EmbedBuilder()
.Description($"Showing avatar for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(text: $"<{target.Icon.TryGetCleanCdnUrl()}>", embed: ebP.Build());
return;
}
ctx.CheckSystem().CheckOwnGroup(target);
if (matchedClear)
if ((target.Icon?.Trim() ?? "").Length == 0)
{
await ctx.Repository.UpdateGroup(target.Id, new() { Color = Partial<string>.Null() });
await ctx.Reply($"{Emojis.Success} Group color cleared.");
await ctx.Reply(noIconSetMessage);
return;
}
else
{
var color = ctx.RemainderOrNull();
if (color.StartsWith("#")) color = color.Substring(1);
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
var patch = new GroupPatch { Color = Partial<string>.Present(color.ToLowerInvariant()) };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply(embed: new EmbedBuilder()
.Title($"{Emojis.Success} Group color changed.")
.Color(color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Build(),
files: [MiscUtils.GenerateColorPreview(color)]);
}
var ebS = new EmbedBuilder()
.Title("Group icon")
.Image(new Embed.EmbedImage(target.Icon.TryGetCleanCdnUrl()));
if (target.System == ctx.System?.Id)
ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} icon -clear`.");
await ctx.Reply(embed: ebS.Build());
}
public async Task ListSystemGroups(Context ctx, PKSystem system)
public async Task ClearGroupIcon(Context ctx, PKGroup target, bool confirmYes)
{
ctx.CheckOwnGroup(target);
await ctx.ConfirmClear("this group's icon", confirmYes);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null });
await ctx.Reply($"{Emojis.Success} Group icon cleared.");
}
public async Task ChangeGroupIcon(Context ctx, PKGroup target, ParsedImage img)
{
ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(img.Url);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.User =>
$"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.",
AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group icon changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
}
public async Task ShowGroupBanner(Context ctx, PKGroup target, ReplyFormat format)
{
var noBannerSetMessage = "This group does not have a banner image set" +
(ctx.System?.Id == target.System
? ". Set one by attaching an image to this command, or by passing an image URL or @mention."
: ".");
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
// if we're doing a raw or plaintext query check for null
if (format != ReplyFormat.Standard)
if ((target.BannerImage?.Trim() ?? "").Length == 0)
{
await ctx.Reply(noBannerSetMessage);
return;
}
if (format == ReplyFormat.Raw)
{
await ctx.Reply($"`{target.BannerImage.TryGetCleanCdnUrl()}`");
return;
}
if (format == ReplyFormat.Plaintext)
{
var ebP = new EmbedBuilder()
.Description($"Showing banner for group {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(text: $"<{target.BannerImage.TryGetCleanCdnUrl()}>", embed: ebP.Build());
return;
}
if ((target.BannerImage?.Trim() ?? "").Length == 0)
{
await ctx.Reply(noBannerSetMessage);
return;
}
var ebS = new EmbedBuilder()
.Title("Group banner image")
.Image(new Embed.EmbedImage(target.BannerImage.TryGetCleanCdnUrl()));
if (target.System == ctx.System?.Id)
ebS.Description($"To clear, use `{ctx.DefaultPrefix}group {target.Reference(ctx)} banner clear`.");
await ctx.Reply(embed: ebS.Build());
}
public async Task ClearGroupBanner(Context ctx, PKGroup target, bool confirmYes)
{
ctx.CheckOwnGroup(target);
await ctx.ConfirmClear("this group's banner image", confirmYes);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null });
await ctx.Reply($"{Emojis.Success} Group banner image cleared.");
}
public async Task ChangeGroupBanner(Context ctx, PKGroup target, ParsedImage img)
{
ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(img.Url, true);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group banner image changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.",
AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."),
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
}
public async Task ShowGroupColor(Context ctx, PKGroup target, ReplyFormat format)
{
var noColorSetMessage = "This group does not have a color set" +
(ctx.System?.Id == target.System
? $". To set one, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color <color>`."
: ".");
// if we're doing a raw or plaintext query check for null
if (format != ReplyFormat.Standard)
if (target.Color == null)
{
await ctx.Reply(noColorSetMessage);
return;
}
if (format == ReplyFormat.Raw)
{
await ctx.Reply("```\n#" + target.Color + "\n```");
return;
}
if (format == ReplyFormat.Plaintext)
{
await ctx.Reply(target.Color);
return;
}
if (target.Color == null)
{
await ctx.Reply(noColorSetMessage);
return;
}
var eb = new EmbedBuilder()
.Title("Group color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Description($"This group's color is **#{target.Color}**.");
if (ctx.System?.Id == target.System)
eb.Description(eb.Build().Description + $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color -clear`.");
await ctx.Reply(embed: eb.Build(), files: [MiscUtils.GenerateColorPreview(target.Color)]);
}
public async Task ClearGroupColor(Context ctx, PKGroup target, bool confirmYes = false)
{
ctx.CheckOwnGroup(target);
if (!await ctx.ConfirmClear("this group's color", confirmYes))
return;
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Color = Partial<string>.Null() });
await ctx.Reply($"{Emojis.Success} Group color cleared.");
}
public async Task ChangeGroupColor(Context ctx, PKGroup target, string color)
{
ctx.CheckOwnGroup(target);
if (color.StartsWith("#")) color = color.Substring(1);
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
var patch = new GroupPatch { Color = Partial<string>.Present(color.ToLowerInvariant()) };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply(embed: new EmbedBuilder()
.Title($"{Emojis.Success} Group color changed.")
.Color(color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Build(),
files: [MiscUtils.GenerateColorPreview(color)]);
}
public async Task ListSystemGroups(Context ctx, PKSystem system, string? query, IHasListOptions flags, bool all)
{
if (system == null)
{
@ -492,13 +529,16 @@ public class Groups
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
// - RenderGroupList checks the indivual privacy for each member (NameFor, etc)
// the own system is always allowed to look up their list
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id), ctx.LookupContextFor(system.Id));
var opts = flags.GetListOptions(ctx, system.Id);
opts.Search = query;
await ctx.RenderGroupList(
ctx.LookupContextFor(system.Id),
system.Id,
GetEmbedTitle(ctx, system, opts),
system.Color,
opts
opts,
all
);
}
@ -517,114 +557,109 @@ public class Groups
return title.ToString();
}
public async Task ShowGroupCard(Context ctx, PKGroup target)
public async Task ShowGroupCard(Context ctx, PKGroup target, bool showEmbed, bool all)
{
var system = await GetGroupSystem(ctx, target);
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateGroupEmbed(ctx, system, target));
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateGroupEmbed(ctx, system, target, all));
return;
}
await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target));
await ctx.Reply(components: await _embeds.CreateGroupMessageComponents(ctx, system, target, all));
}
public async Task GroupPrivacy(Context ctx, PKGroup target, PrivacyLevel? newValueFromCommand)
public async Task ShowGroupPrivacy(Context ctx, PKGroup target)
{
ctx.CheckSystem().CheckOwnGroup(target);
// Display privacy settings
if (!ctx.HasNext() && newValueFromCommand == null)
{
await ctx.Reply(embed: new EmbedBuilder()
.Title($"Current privacy settings for {target.Name}")
.Field(new Embed.Field("Name", target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation()))
.Field(new Embed.Field("Icon", target.IconPrivacy.Explanation()))
.Field(new Embed.Field("Member list", target.ListPrivacy.Explanation()))
.Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation()))
.Field(new Embed.Field("Visibility", target.Visibility.Explanation()))
.Description(
$"To edit privacy settings, use the command:\n> {ctx.DefaultPrefix}group **{target.Reference(ctx)}** privacy **<subject>** **<level>**\n\n- `subject` is one of `name`, `description`, `banner`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
.Build());
return;
}
async Task SetAll(PrivacyLevel level)
{
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level));
await ctx.Reply(embed: new EmbedBuilder()
.Title($"Current privacy settings for {target.Name}")
.Field(new Embed.Field("Name", target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation()))
.Field(new Embed.Field("Icon", target.IconPrivacy.Explanation()))
.Field(new Embed.Field("Member list", target.ListPrivacy.Explanation()))
.Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation()))
.Field(new Embed.Field("Visibility", target.Visibility.Explanation()))
.Description(
$"To edit privacy settings, use the command:\n> {ctx.DefaultPrefix}group **{target.Reference(ctx)}** privacy **<subject>** **<level>**\n\n- `subject` is one of `name`, `description`, `banner`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
.Build());
}
if (level == PrivacyLevel.Private)
await ctx.Reply(
$"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card.");
else
await ctx.Reply(
$"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card.");
}
public async Task SetAllGroupPrivacy(Context ctx, PKGroup target, PrivacyLevel level)
{
ctx.CheckOwnGroup(target);
async Task SetLevel(GroupPrivacySubject subject, PrivacyLevel level)
{
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level));
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithAllPrivacy(level));
var subjectName = subject switch
{
GroupPrivacySubject.Name => "name privacy",
GroupPrivacySubject.Description => "description privacy",
GroupPrivacySubject.Banner => "banner privacy",
GroupPrivacySubject.Icon => "icon privacy",
GroupPrivacySubject.List => "member list",
GroupPrivacySubject.Metadata => "metadata",
GroupPrivacySubject.Visibility => "visibility",
_ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
};
var explanation = (subject, level) switch
{
(GroupPrivacySubject.Name, PrivacyLevel.Private) =>
"This group's name is now hidden from other systems, and will be replaced by the group's display name.",
(GroupPrivacySubject.Description, PrivacyLevel.Private) =>
"This group's description is now hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Private) =>
"This group's banner is now hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Private) =>
"This group's icon is now hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Private) =>
"This group is now hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Private) =>
"This group's metadata (eg. creation date) is now hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Private) =>
"This group's member list is now hidden from other systems.",
(GroupPrivacySubject.Name, PrivacyLevel.Public) =>
"This group's name is no longer hidden from other systems.",
(GroupPrivacySubject.Description, PrivacyLevel.Public) =>
"This group's description is no longer hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Public) =>
"This group's banner is no longer hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Public) =>
"This group's icon is no longer hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Public) =>
"This group is no longer hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Public) =>
"This group's metadata (eg. creation date) is no longer hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Public) =>
"This group's member list is no longer hidden from other systems.",
_ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})")
};
var replyStr = $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}";
if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null)
replyStr += $"\n{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**.";
await ctx.Reply(replyStr);
}
if (ctx.Match("all") || newValueFromCommand != null)
await SetAll(newValueFromCommand ?? ctx.PopPrivacyLevel());
if (level == PrivacyLevel.Private)
await ctx.Reply(
$"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card.");
else
await SetLevel(ctx.PopGroupPrivacySubject(), ctx.PopPrivacyLevel());
await ctx.Reply(
$"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card.");
}
public async Task SetGroupPrivacy(Context ctx, PKGroup target, GroupPrivacySubject subject, PrivacyLevel level)
{
ctx.CheckOwnGroup(target);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch().WithPrivacy(subject, level));
var subjectName = subject switch
{
GroupPrivacySubject.Name => "name privacy",
GroupPrivacySubject.Description => "description privacy",
GroupPrivacySubject.Banner => "banner privacy",
GroupPrivacySubject.Icon => "icon privacy",
GroupPrivacySubject.List => "member list",
GroupPrivacySubject.Metadata => "metadata",
GroupPrivacySubject.Visibility => "visibility",
_ => throw new ArgumentOutOfRangeException($"Unknown privacy subject {subject}")
};
var explanation = (subject, level) switch
{
(GroupPrivacySubject.Name, PrivacyLevel.Private) =>
"This group's name is now hidden from other systems, and will be replaced by the group's display name.",
(GroupPrivacySubject.Description, PrivacyLevel.Private) =>
"This group's description is now hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Private) =>
"This group's banner is now hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Private) =>
"This group's icon is now hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Private) =>
"This group is now hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Private) =>
"This group's metadata (eg. creation date) is now hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Private) =>
"This group's member list is now hidden from other systems.",
(GroupPrivacySubject.Name, PrivacyLevel.Public) =>
"This group's name is no longer hidden from other systems.",
(GroupPrivacySubject.Description, PrivacyLevel.Public) =>
"This group's description is no longer hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Public) =>
"This group's banner is no longer hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Public) =>
"This group's icon is no longer hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Public) =>
"This group is no longer hidden from group lists and member cards.",
(GroupPrivacySubject.Metadata, PrivacyLevel.Public) =>
"This group's metadata (eg. creation date) is no longer hidden from other systems.",
(GroupPrivacySubject.List, PrivacyLevel.Public) =>
"This group's member list is no longer hidden from other systems.",
_ => throw new InvalidOperationException($"Invalid subject/level tuple ({subject}, {level})")
};
var replyStr = $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}";
if (subject == GroupPrivacySubject.Name && level == PrivacyLevel.Private && target.DisplayName == null)
replyStr += $"\n{Emojis.Warn} This group does not have a display name set, and name privacy **will not take effect**.";
await ctx.Reply(replyStr);
}
public async Task DeleteGroup(Context ctx, PKGroup target)

View file

@ -7,9 +7,9 @@ namespace PluralKit.Bot;
public class Help
{
public Task HelpRoot(Context ctx)
public Task HelpRoot(Context ctx, bool showEmbed = false)
{
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
return HelpRootOld(ctx);
return ctx.Reply(BuildComponents(ctx.Author.Id, Help.Description.Replace("{prefix}", ctx.DefaultPrefix), -1));

View file

@ -31,9 +31,9 @@ public class ImportExport
_dmCache = dmCache;
}
public async Task Import(Context ctx)
public async Task Import(Context ctx, string? inputUrl, bool confirmYes)
{
var inputUrl = ctx.RemainderOrNull() ?? ctx.Message.Attachments.FirstOrDefault()?.Url;
inputUrl = inputUrl ?? ctx.Message.Attachments.FirstOrDefault()?.Url;
if (inputUrl == null) throw Errors.NoImportFilePassed;
if (!Core.MiscUtils.TryMatchUri(inputUrl, out var url))
@ -77,7 +77,7 @@ public class ImportExport
async Task ConfirmImport(string message)
{
var msg = $"{message}\n\nDo you want to proceed with the import?";
if (!await ctx.PromptYesNo(msg, "Proceed"))
if (!await ctx.PromptYesNo(msg, "Proceed", flagValue: confirmYes))
throw Errors.ImportCancelled;
}
@ -86,7 +86,7 @@ public class ImportExport
&& data.Value<JArray>("accounts").Contains(ctx.Author.Id.ToString()))
{
var msg = $"{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, "Import")) throw Errors.ImportCancelled;
if (!await ctx.PromptYesNo(msg, "Import", flagValue: confirmYes)) throw Errors.ImportCancelled;
}
var result = await _dataFiles.ImportSystem(ctx.Author.Id, ctx.System, data, ConfirmImport);

View file

@ -9,95 +9,13 @@ using PluralKit.Core;
namespace PluralKit.Bot;
public interface IHasListOptions
{
ListOptions GetListOptions(Context ctx, SystemId system);
}
public static class ContextListExt
{
public static ListOptions ParseListOptions(this Context ctx, LookupContext directLookupCtx, LookupContext lookupContext)
{
var p = new ListOptions();
// Short or long list? (parse this first, as it can potentially take a positional argument)
var isFull = ctx.Match("f", "full", "big", "details", "long") || ctx.MatchFlag("f", "full");
p.Type = isFull ? ListType.Long : ListType.Short;
// Search query
if (ctx.HasNext())
p.Search = ctx.RemainderOrNull();
// Include description in search?
if (ctx.MatchFlag(
"search-description",
"filter-description",
"in-description",
"sd",
"description",
"desc"
))
p.SearchDescription = true;
// Sort property (default is by name, but adding a flag anyway, 'cause why not)
if (ctx.MatchFlag("by-name", "bn")) p.SortProperty = SortProperty.Name;
if (ctx.MatchFlag("by-display-name", "bdn")) p.SortProperty = SortProperty.DisplayName;
if (ctx.MatchFlag("by-id", "bid")) p.SortProperty = SortProperty.Hid;
if (ctx.MatchFlag("by-message-count", "bmc")) p.SortProperty = SortProperty.MessageCount;
if (ctx.MatchFlag("by-created", "bc", "bcd")) p.SortProperty = SortProperty.CreationDate;
if (ctx.MatchFlag("by-last-fronted", "by-last-front", "by-last-switch", "blf", "bls"))
p.SortProperty = SortProperty.LastSwitch;
if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage;
if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate;
if (ctx.MatchFlag("random", "rand")) p.SortProperty = SortProperty.Random;
// Sort reverse?
if (ctx.MatchFlag("r", "rev", "reverse"))
p.Reverse = true;
// Privacy filter (default is public only)
if (ctx.MatchFlag("a", "all")) p.PrivacyFilter = null;
if (ctx.MatchFlag("private-only", "po")) p.PrivacyFilter = PrivacyLevel.Private;
// PERM CHECK: If we're trying to access non-public members of another system, error
if (p.PrivacyFilter != PrivacyLevel.Public && directLookupCtx != LookupContext.ByOwner)
// TODO: should this just return null instead of throwing or something? >.>
throw Errors.NotOwnInfo;
//this is for searching
p.Context = lookupContext;
// Additional fields to include in the search results
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
p.IncludeLastSwitch = true;
if (ctx.MatchFlag("with-last-message", "with-last-proxy", "wlm", "wlp"))
p.IncludeLastMessage = true;
if (ctx.MatchFlag("with-message-count", "wmc"))
p.IncludeMessageCount = true;
if (ctx.MatchFlag("with-created", "wc"))
p.IncludeCreated = true;
if (ctx.MatchFlag("with-avatar", "with-image", "with-icon", "wa", "wi", "ia", "ii", "img"))
p.IncludeAvatar = true;
if (ctx.MatchFlag("with-pronouns", "wp", "wprns"))
p.IncludePronouns = true;
if (ctx.MatchFlag("with-displayname", "wdn"))
p.IncludeDisplayName = true;
if (ctx.MatchFlag("with-birthday", "wbd", "wb"))
p.IncludeBirthday = true;
// Always show the sort property, too (unless this is the short list and we are already showing something else)
if (p.Type != ListType.Short || p.includedCount == 0)
{
if (p.SortProperty == SortProperty.DisplayName) p.IncludeDisplayName = true;
if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true;
if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true;
if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true;
if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true;
if (p.SortProperty == SortProperty.Birthdate) p.IncludeBirthday = true;
}
// Make sure the options are valid
p.AssertIsValid();
// Done!
return p;
}
public static async Task RenderMemberList(this Context ctx, LookupContext lookupCtx,
SystemId system, string embedTitle, string color, ListOptions opts)
{
@ -212,7 +130,7 @@ public static class ContextListExt
}
public static async Task RenderGroupList(this Context ctx, LookupContext lookupCtx,
SystemId system, string embedTitle, string color, ListOptions opts)
SystemId system, string embedTitle, string color, ListOptions opts, bool all)
{
// We take an IDatabase instead of a IPKConnection so we don't keep the handle open for the entire runtime
// We wanna release it as soon as the member list is actually *fetched*, instead of potentially minutes later (paginate timeout)
@ -286,7 +204,7 @@ public static class ContextListExt
{
if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner)
{
if (ctx.MatchFlag("all", "a"))
if (all)
{
ret += $"({"member".ToQuantity(g.TotalMemberCount)})";
}
@ -324,7 +242,7 @@ public static class ContextListExt
if (g.ListPrivacy == PrivacyLevel.Public || lookupCtx == LookupContext.ByOwner)
{
if (ctx.MatchFlag("all", "a") && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner)
if (all && ctx.DirectLookupContextFor(system) == LookupContext.ByOwner)
profile.Append($"\n**Member Count:** {g.TotalMemberCount}");
else
profile.Append($"\n**Member Count:** {g.PublicMemberCount}");

View file

@ -184,6 +184,7 @@ public static class ListOptionsExt
// the check for multiple *sorting* property flags is done in SortProperty setter
}
}
public enum SortProperty

View file

@ -1,4 +1,5 @@
using System.Net;
using System.Reflection.Metadata;
using System.Web;
using Dapper;
@ -27,10 +28,10 @@ public class Member
_avatarHosting = avatarHosting;
}
public async Task NewMember(Context ctx)
public async Task NewMember(Context ctx, string? memberName, bool confirmYes = false)
{
if (ctx.System == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
var memberName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a member name.");
memberName = memberName ?? throw new PKSyntaxError("You must pass a member name.");
// Hard name length cap
if (memberName.Length > Limits.MaxMemberNameLength)
@ -41,7 +42,7 @@ public class Member
if (existingMember != null)
{
var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.DisplayHid(ctx.Config)}`). Do you want to create another member with the same name?";
if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled.");
if (!await ctx.PromptYesNo(msg, "Create", flagValue: confirmYes)) throw new PKError("Member creation cancelled.");
}
await using var conn = await ctx.Database.Obtain();
@ -119,10 +120,10 @@ public class Member
await ctx.Reply(replyStr);
}
public async Task ViewMember(Context ctx, PKMember target)
public async Task ViewMember(Context ctx, PKMember target, bool showEmbed = false)
{
var system = await ctx.Repository.GetSystem(target.System);
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,

View file

@ -17,8 +17,11 @@ public class MemberAvatar
_avatarHosting = avatarHosting;
}
private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs)
private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs, bool confirmYes)
{
ctx.CheckSystem().CheckOwnMember(target);
await ctx.ConfirmClear("this member's " + location.Name(), confirmYes);
await UpdateAvatar(location, ctx, target, null);
if (location == MemberAvatarLocation.Server)
{
@ -47,7 +50,7 @@ public class MemberAvatar
}
private async Task AvatarShow(MemberAvatarLocation location, Context ctx, PKMember target,
MemberGuildSettings? guildData)
MemberGuildSettings? guildData, ReplyFormat format)
{
// todo: this privacy code is really confusing
// for now, we skip privacy flag/config parsing for this, but it would be good to fix that at some point
@ -86,7 +89,6 @@ public class MemberAvatar
if (location == MemberAvatarLocation.Server)
field += $" (for {ctx.Guild.Name})";
var format = ctx.MatchFormat();
if (format == ReplyFormat.Raw)
{
await ctx.Reply($"`{currentValue?.TryGetCleanCdnUrl()}`");
@ -110,58 +112,89 @@ public class MemberAvatar
else throw new PKError("Format Not Recognized");
}
public async Task ServerAvatar(Context ctx, PKMember target)
private async Task AvatarChange(MemberAvatarLocation location, Context ctx, PKMember target,
MemberGuildSettings? guildData, ParsedImage avatar)
{
ctx.CheckGuildContext();
var guildData = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id);
await AvatarCommandTree(MemberAvatarLocation.Server, ctx, target, guildData);
}
public async Task Avatar(Context ctx, PKMember target)
{
var guildData = ctx.Guild != null
? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id)
: null;
await AvatarCommandTree(MemberAvatarLocation.Member, ctx, target, guildData);
}
public async Task WebhookAvatar(Context ctx, PKMember target)
{
var guildData = ctx.Guild != null
? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id)
: null;
await AvatarCommandTree(MemberAvatarLocation.MemberWebhook, ctx, target, guildData);
}
private async Task AvatarCommandTree(MemberAvatarLocation location, Context ctx, PKMember target,
MemberGuildSettings? guildData)
{
// First, see if we need to *clear*
if (ctx.MatchClear())
{
ctx.CheckSystem().CheckOwnMember(target);
await ctx.ConfirmClear("this member's " + location.Name());
await AvatarClear(location, ctx, target, guildData);
return;
}
// Then, parse an image from the command (from various sources...)
var avatarArg = await ctx.MatchImage();
if (avatarArg == null)
{
// If we didn't get any, just show the current avatar
await AvatarShow(location, ctx, target, guildData);
return;
}
ctx.CheckSystem().CheckOwnMember(target);
avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(avatarArg.Value.Url);
await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url);
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
avatar = await _avatarHosting.TryRehostImage(avatar, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(avatar.Url);
await UpdateAvatar(location, ctx, target, avatar.CleanUrl ?? avatar.Url);
await PrintResponse(location, ctx, target, avatar, guildData);
}
private Task<MemberGuildSettings> GetServerAvatarGuildData(Context ctx, PKMember target)
{
ctx.CheckGuildContext();
return ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id);
}
private async Task<MemberGuildSettings?> GetAvatarGuildData(Context ctx, PKMember target)
{
return ctx.Guild != null
? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id)
: null;
}
private async Task<MemberGuildSettings?> GetWebhookAvatarGuildData(Context ctx, PKMember target)
{
return ctx.Guild != null
? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id)
: null;
}
public async Task ShowServerAvatar(Context ctx, PKMember target, ReplyFormat format)
{
var guildData = await GetServerAvatarGuildData(ctx, target);
await AvatarShow(MemberAvatarLocation.Server, ctx, target, guildData, format);
}
public async Task ClearServerAvatar(Context ctx, PKMember target, bool confirmYes)
{
var guildData = await GetServerAvatarGuildData(ctx, target);
await AvatarClear(MemberAvatarLocation.Server, ctx, target, guildData, confirmYes);
}
public async Task ChangeServerAvatar(Context ctx, PKMember target, ParsedImage avatar)
{
var guildData = await GetServerAvatarGuildData(ctx, target);
await AvatarChange(MemberAvatarLocation.Server, ctx, target, guildData, avatar);
}
public async Task ShowAvatar(Context ctx, PKMember target, ReplyFormat format)
{
var guildData = await GetAvatarGuildData(ctx, target);
await AvatarShow(MemberAvatarLocation.Member, ctx, target, guildData, format);
}
public async Task ClearAvatar(Context ctx, PKMember target, bool confirmYes)
{
var guildData = await GetAvatarGuildData(ctx, target);
await AvatarClear(MemberAvatarLocation.Member, ctx, target, guildData, confirmYes);
}
public async Task ChangeAvatar(Context ctx, PKMember target, ParsedImage avatar)
{
var guildData = await GetAvatarGuildData(ctx, target);
await AvatarChange(MemberAvatarLocation.Member, ctx, target, guildData, avatar);
}
public async Task ShowWebhookAvatar(Context ctx, PKMember target, ReplyFormat format)
{
var guildData = await GetWebhookAvatarGuildData(ctx, target);
await AvatarShow(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, format);
}
public async Task ClearWebhookAvatar(Context ctx, PKMember target, bool confirmYes)
{
var guildData = await GetWebhookAvatarGuildData(ctx, target);
await AvatarClear(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, confirmYes);
}
public async Task ChangeWebhookAvatar(Context ctx, PKMember target, ParsedImage avatar)
{
var guildData = await GetWebhookAvatarGuildData(ctx, target);
await AvatarChange(MemberAvatarLocation.MemberWebhook, ctx, target, guildData, avatar);
}
private Task PrintResponse(MemberAvatarLocation location, Context ctx, PKMember target, ParsedImage avatar,

File diff suppressed because it is too large Load diff

View file

@ -6,133 +6,120 @@ namespace PluralKit.Bot;
public class MemberProxy
{
public async Task Proxy(Context ctx, PKMember target)
public async Task ShowProxy(Context ctx, PKMember target)
{
if (target.ProxyTags.Count == 0)
await ctx.Reply("This member does not have any proxy tags.");
else
await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}");
}
public async Task ClearProxy(Context ctx, PKMember target, bool confirmYes = false)
{
ctx.CheckSystem().CheckOwnMember(target);
ProxyTag ParseProxyTags(string exampleProxy)
// If we already have multiple tags, this would clear everything, so prompt that
if (target.ProxyTags.Count > 1)
{
// // Make sure there's one and only one instance of "text" in the example proxy given
var prefixAndSuffix = exampleProxy.Split("text");
if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT");
if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText;
if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText;
return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]);
}
async Task<bool> WarnOnConflict(ProxyTag newTag)
{
var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing";
var conflicts = (await ctx.Database.Execute(conn => conn.QueryAsync<PKMember>(query,
new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList();
if (conflicts.Count <= 0) return true;
var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**");
var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?";
return await ctx.PromptYesNo(msg, "Proceed");
}
// "Sub"command: clear flag
if (ctx.MatchClear())
{
// If we already have multiple tags, this would clear everything, so prompt that
if (target.ProxyTags.Count > 1)
{
var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?";
if (!await ctx.PromptYesNo(msg, "Clear"))
throw Errors.GenericCancelled();
}
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(new ProxyTag[0]) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Proxy tags cleared.");
}
// "Sub"command: no arguments; will print proxy tags
else if (!ctx.HasNext(false))
{
if (target.ProxyTags.Count == 0)
await ctx.Reply("This member does not have any proxy tags.");
else
await ctx.Reply($"This member's proxy tags are:\n{target.ProxyTagsString("\n")}");
}
// Subcommand: "add"
else if (ctx.Match("add", "append"))
{
if (!ctx.HasNext(false))
throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`).");
var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing());
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
if (target.ProxyTags.Contains(tagToAdd))
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength)
throw new PKError(
$"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
if (!await WarnOnConflict(tagToAdd))
var msg = $"{Emojis.Warn} You already have multiple proxy tags set: {target.ProxyTagsString()}\nDo you want to clear them all?";
if (!await ctx.PromptYesNo(msg, "Clear", flagValue: confirmYes))
throw Errors.GenericCancelled();
var newTags = target.ProxyTags.ToList();
newTags.Add(tagToAdd);
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()} (using {tagToAdd.ProxyString.Length}/{Limits.MaxProxyTagLength} characters).");
}
// Subcommand: "remove"
else if (ctx.Match("remove", "delete"))
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(new ProxyTag[0]) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Proxy tags cleared.");
}
public async Task AddProxy(Context ctx, PKMember target, string proxyString, bool confirmYes = false)
{
ctx.CheckSystem().CheckOwnMember(target);
var tagToAdd = ParseProxyTag(proxyString);
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
if (target.ProxyTags.Contains(tagToAdd))
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
if (tagToAdd.ProxyString.Length > Limits.MaxProxyTagLength)
throw new PKError(
$"Proxy tag too long ({tagToAdd.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
if (!await WarnOnConflict(ctx, target, tagToAdd, confirmYes))
throw Errors.GenericCancelled();
var newTags = target.ProxyTags.ToList();
newTags.Add(tagToAdd);
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Added proxy tags {tagToAdd.ProxyString.AsCode()} (using {tagToAdd.ProxyString.Length}/{Limits.MaxProxyTagLength} characters).");
}
public async Task RemoveProxy(Context ctx, PKMember target, string proxyString)
{
ctx.CheckSystem().CheckOwnMember(target);
var tagToRemove = ParseProxyTag(proxyString);
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
if (!target.ProxyTags.Contains(tagToRemove))
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
var newTags = target.ProxyTags.ToList();
newTags.Remove(tagToRemove);
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}.");
}
public async Task SetProxy(Context ctx, PKMember target, string proxyString, bool confirmYes = false)
{
ctx.CheckSystem().CheckOwnMember(target);
var requestedTag = ParseProxyTag(proxyString);
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
if (target.ProxyTags.Count > 1)
{
if (!ctx.HasNext(false))
throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`).");
var remainder = ctx.RemainderOrNull(false);
var tagToRemove = ParseProxyTags(remainder.NormalizeLineEndSpacing());
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
if (!target.ProxyTags.Contains(tagToRemove))
{
// Legacy support for when line endings weren't normalized
tagToRemove = ParseProxyTags(remainder);
if (!target.ProxyTags.Contains(tagToRemove))
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
}
var newTags = target.ProxyTags.ToList();
newTags.Remove(tagToRemove);
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags.ToArray()) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Removed proxy tags {tagToRemove.ProxyString.AsCode()}.");
}
// Subcommand: bare proxy tag given
else
{
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing());
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
// This is mostly a legacy command, so it's gonna warn if there's
// already more than one proxy tag.
if (target.ProxyTags.Count > 1)
{
var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?";
if (!await ctx.PromptYesNo(msg, "Replace"))
throw Errors.GenericCancelled();
}
if (requestedTag.ProxyString.Length > Limits.MaxProxyTagLength)
throw new PKError(
$"Proxy tag too long ({requestedTag.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
if (!await WarnOnConflict(requestedTag))
var msg = $"This member already has more than one proxy tag set: {target.ProxyTagsString()}\nDo you want to replace them?";
if (!await ctx.PromptYesNo(msg, "Replace", flagValue: confirmYes))
throw Errors.GenericCancelled();
var newTags = new[] { requestedTag };
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()} (using {requestedTag.ProxyString.Length}/{Limits.MaxProxyTagLength} characters).");
}
if (requestedTag.ProxyString.Length > Limits.MaxProxyTagLength)
throw new PKError(
$"Proxy tag too long ({requestedTag.ProxyString.Length} > {Limits.MaxProxyTagLength} characters).");
if (!await WarnOnConflict(ctx, target, requestedTag, confirmYes))
throw Errors.GenericCancelled();
var newTags = new[] { requestedTag };
var patch = new MemberPatch { ProxyTags = Partial<ProxyTag[]>.Present(newTags) };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Member proxy tags set to {requestedTag.ProxyString.AsCode()} (using {requestedTag.ProxyString.Length}/{Limits.MaxProxyTagLength} characters).");
}
private ProxyTag ParseProxyTag(string proxyString)
{
// Make sure there's one and only one instance of "text" in the example proxy given
var prefixAndSuffix = proxyString.Split("text");
if (prefixAndSuffix.Length == 1) prefixAndSuffix = prefixAndSuffix[0].Split("TEXT");
if (prefixAndSuffix.Length < 2) throw Errors.ProxyMustHaveText;
if (prefixAndSuffix.Length > 2) throw Errors.ProxyMultipleText;
return new ProxyTag(prefixAndSuffix[0], prefixAndSuffix[1]);
}
private async Task<bool> WarnOnConflict(Context ctx, PKMember target, ProxyTag newTag, bool confirmYes = false)
{
var query = "select * from (select *, (unnest(proxy_tags)).prefix as prefix, (unnest(proxy_tags)).suffix as suffix from members where system = @System) as _ where prefix is not distinct from @Prefix and suffix is not distinct from @Suffix and id != @Existing";
var conflicts = (await ctx.Database.Execute(conn => conn.QueryAsync<PKMember>(query,
new { newTag.Prefix, newTag.Suffix, Existing = target.Id, system = target.System }))).ToList();
if (conflicts.Count <= 0) return true;
var conflictList = conflicts.Select(m => $"- **{m.NameFor(ctx)}**");
var msg = $"{Emojis.Warn} The following members have conflicting proxy tags:\n{string.Join('\n', conflictList)}\nDo you want to proceed anyway?";
return await ctx.PromptYesNo(msg, "Proceed", flagValue: confirmYes);
}
}

View file

@ -58,16 +58,14 @@ public class ProxiedMessage
_redisService = redisService;
}
public async Task ReproxyMessage(Context ctx)
public async Task ReproxyMessage(Context ctx, Message.Reference? messageRef, PKMember target)
{
var (msg, systemId) = await GetMessageToEdit(ctx, ReproxyTimeout, true);
var (msg, systemId) = await GetMessageToEdit(ctx, messageRef?.MessageId ?? ctx.GetRepliedTo()?.MessageId, ReproxyTimeout, true);
if (ctx.System.Id != systemId)
throw new PKError("Can't reproxy a message sent by a different system.");
// Get target member ID
var target = await ctx.MatchMember(restrictToSystem: ctx.System.Id);
if (target == null)
if (target == null || target.System != ctx.System.Id)
throw new PKError("Could not find a member to reproxy the message with.");
// Fetch members and get the ProxyMember for `target`
@ -93,9 +91,9 @@ public class ProxiedMessage
}
}
public async Task EditMessage(Context ctx, bool useRegex)
public async Task EditMessage(Context ctx, Message.Reference? messageRef, string? newContent, bool useRegex, bool noSpace, bool append, bool prepend, bool clearEmbeds, bool clearAttachments)
{
var (msg, systemId) = await GetMessageToEdit(ctx, EditTimeout, false);
var (msg, systemId) = await GetMessageToEdit(ctx, messageRef?.MessageId ?? ctx.GetRepliedTo()?.MessageId, EditTimeout, false);
if (ctx.System.Id != systemId)
throw new PKError("Can't edit a message sent by a different system.");
@ -104,21 +102,12 @@ public class ProxiedMessage
if (originalMsg == null)
throw new PKError("Could not edit message.");
// Regex flag
useRegex = useRegex || ctx.MatchFlag("regex", "x");
// Check if we should append or prepend
var mutateSpace = ctx.MatchFlag("nospace", "ns") ? "" : " ";
var append = ctx.MatchFlag("append", "a");
var prepend = ctx.MatchFlag("prepend", "p");
var mutateSpace = noSpace ? "" : " ";
// Grab the original message content and new message content
var originalContent = originalMsg.Content;
var newContent = ctx.RemainderOrNull()?.NormalizeLineEndSpacing();
// Should we clear embeds?
var clearEmbeds = ctx.MatchFlag("clear-embed", "ce");
var clearAttachments = ctx.MatchFlag("clear-attachments", "ca");
if ((clearEmbeds || clearAttachments) && newContent == null)
newContent = originalMsg.Content!;
@ -249,14 +238,13 @@ public class ProxiedMessage
}
}
private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, Duration timeout, bool isReproxy)
private async Task<(PKMessage, SystemId)> GetMessageToEdit(Context ctx, ulong? referencedMessage, Duration timeout, bool isReproxy)
{
var editType = isReproxy ? "reproxy" : "edit";
var editTypeAction = isReproxy ? "reproxied" : "edited";
PKMessage? msg = null;
var (referencedMessage, _) = ctx.MatchMessage(false);
if (referencedMessage != null)
{
await using var conn = await ctx.Database.Obtain();
@ -332,22 +320,20 @@ public class ProxiedMessage
return lastMessage;
}
public async Task GetMessage(Context ctx)
public async Task GetMessage(Context ctx, Message.Reference? messageRef, ReplyFormat format, bool isDelete, bool author, bool showEmbed)
{
var (messageId, _) = ctx.MatchMessage(true);
if (messageId == null)
if (ctx.Message.Type == Message.MessageType.Reply && ctx.Message.MessageReference?.MessageId != null)
messageRef = ctx.Message.MessageReference;
if (messageRef == null || messageRef.MessageId == null)
{
if (!ctx.HasNext())
throw new PKSyntaxError("You must pass a message ID or link.");
throw new PKSyntaxError($"Could not parse {ctx.PeekArgument().AsCode()} as a message ID or link.");
throw new PKSyntaxError("You must pass a message ID or link.");
}
var isDelete = ctx.Match("delete") || ctx.MatchFlag("delete");
var message = await ctx.Repository.GetFullMessage(messageId.Value);
var message = await ctx.Repository.GetFullMessage(messageRef.MessageId.Value);
if (message == null)
{
await GetCommandMessage(ctx, messageId.Value, isDelete);
await GetCommandMessage(ctx, messageRef.MessageId.Value, isDelete, showEmbed);
return;
}
@ -360,8 +346,6 @@ public class ProxiedMessage
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false;
var format = ctx.MatchFormat();
if (format != ReplyFormat.Standard)
{
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
@ -423,10 +407,10 @@ public class ProxiedMessage
return;
}
if (ctx.Match("author") || ctx.MatchFlag("author"))
if (author)
{
var user = await _rest.GetUser(message.Message.Sender);
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(
@ -446,7 +430,7 @@ public class ProxiedMessage
return;
}
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
return;
@ -455,7 +439,7 @@ public class ProxiedMessage
await ctx.Reply(components: await _embeds.CreateMessageInfoMessageComponents(message, showContent, ctx.Config));
}
private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete)
private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete, bool showEmbed)
{
var msg = await _repo.GetCommandMessage(messageId);
if (msg == null)
@ -484,7 +468,7 @@ public class ProxiedMessage
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false;
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent));
return;

View file

@ -15,7 +15,7 @@ public class Random
// todo: get postgresql to return one random member/group instead of querying all members/groups
public async Task Member(Context ctx, PKSystem target)
public async Task Member(Context ctx, PKSystem target, bool all, bool showEmbed = false)
{
if (target == null)
throw Errors.NoSystemError(ctx.DefaultPrefix);
@ -24,7 +24,7 @@ public class Random
var members = await ctx.Repository.GetSystemMembers(target.Id).ToListAsync();
if (!ctx.MatchFlag("all", "a"))
if (!all)
members = members.Where(m => m.MemberVisibility == PrivacyLevel.Public).ToList();
else
ctx.CheckOwnSystem(target);
@ -37,7 +37,7 @@ public class Random
var randInt = randGen.Next(members.Count);
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
@ -49,7 +49,7 @@ public class Random
components: await _embeds.CreateMemberMessageComponents(target, members[randInt], ctx.Guild, ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone));
}
public async Task Group(Context ctx, PKSystem target)
public async Task Group(Context ctx, PKSystem target, bool all, bool showEmbed = false)
{
if (target == null)
throw Errors.NoSystemError(ctx.DefaultPrefix);
@ -57,7 +57,7 @@ public class Random
ctx.CheckSystemPrivacy(target.Id, target.GroupListPrivacy);
var groups = await ctx.Repository.GetSystemGroups(target.Id).ToListAsync();
if (!ctx.MatchFlag("all", "a"))
if (!all)
groups = groups.Where(g => g.Visibility == PrivacyLevel.Public).ToList();
else
ctx.CheckOwnSystem(target);
@ -70,23 +70,23 @@ public class Random
var randInt = randGen.Next(groups.Count());
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,
embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt]));
embed: await _embeds.CreateGroupEmbed(ctx, target, groups.ToArray()[randInt], all));
return;
}
await ctx.Reply(
components: await _embeds.CreateGroupMessageComponents(ctx, target, groups.ToArray()[randInt]));
components: await _embeds.CreateGroupMessageComponents(ctx, target, groups.ToArray()[randInt], all));
}
public async Task GroupMember(Context ctx, PKGroup group)
public async Task GroupMember(Context ctx, PKGroup group, bool all, bool show_embed, IHasListOptions flags)
{
ctx.CheckSystemPrivacy(group.System, group.ListPrivacy);
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System), ctx.LookupContextFor(group.System));
var opts = flags.GetListOptions(ctx, group.System);
opts.GroupFilter = group.Id;
var members = await ctx.Database.Execute(conn => conn.QueryMemberList(group.System, opts.ToQueryOptions()));
@ -96,7 +96,7 @@ public class Random
"This group has no members!"
+ (ctx.System?.Id == group.System ? " Please add at least one member to this group before using this command." : ""));
if (!ctx.MatchFlag("all", "a"))
if (!all)
members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public);
else
ctx.CheckOwnGroup(group);
@ -112,7 +112,7 @@ public class Random
var randInt = randGen.Next(ms.Count);
if (ctx.MatchFlag("show-embed", "se"))
if (show_embed)
{
await ctx.Reply(
text: EmbedService.LEGACY_EMBED_WARNING,

View file

@ -110,34 +110,27 @@ public class ServerConfig
);
}
public async Task SetLogChannel(Context ctx)
public async Task ShowLogChannel(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var settings = await ctx.Repository.GetGuild(ctx.Guild.Id);
if (ctx.MatchClear() && await ctx.ConfirmClear("the server log channel"))
if (settings.LogChannel == null)
{
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null });
await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared.");
await ctx.Reply("This server does not have a log channel set.");
return;
}
if (!ctx.HasNext())
{
if (settings.LogChannel == null)
{
await ctx.Reply("This server does not have a log channel set.");
return;
}
await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>.");
}
await ctx.Reply($"This server's log channel is currently set to <#{settings.LogChannel}>.");
return;
}
public async Task SetLogChannel(Context ctx, Channel channel)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (channel.GuildId != ctx.Guild.Id)
throw Errors.ChannelNotFound(channel.Id.ToString());
Channel channel = null;
var channelString = ctx.PeekArgument();
channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
if (channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildPublicThread && channel.Type != Channel.ChannelType.GuildPrivateThread)
throw new PKError("PluralKit cannot log messages to this type of channel.");
@ -151,46 +144,18 @@ public class ServerConfig
await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>.");
}
// legacy behaviour: enable/disable logging for commands
// new behaviour is add/remove from log blacklist (see #LogBlacklistNew)
public async Task SetLogEnabled(Context ctx, bool enable)
public async Task ClearLogChannel(Context ctx, bool confirmYes)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (ctx.Match("all"))
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
.Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
else
while (ctx.HasNext())
{
var channelString = ctx.PeekArgument();
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
affectedChannels.Add(channel);
}
if (!await ctx.ConfirmClear("the server log channel", confirmYes))
return;
ulong? logChannel = null;
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
logChannel = config.LogChannel;
var blacklist = config.LogBlacklist.ToHashSet();
if (enable)
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
else
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() });
await ctx.Reply(
$"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." +
(logChannel == null
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`."
: ""));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogChannel = null });
await ctx.Reply($"{Emojis.Success} Proxy logging channel cleared.");
}
public async Task ShowProxyBlacklisted(Context ctx)
public async Task ShowProxyBlacklist(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
@ -240,14 +205,73 @@ public class ServerConfig
});
}
public async Task ShowLogDisabledChannels(Context ctx)
public async Task AddProxyBlacklist(Context ctx, Channel? channel, bool all)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (all)
{
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
}
else if (channel != null)
{
if (channel.GuildId != ctx.Guild.Id)
throw Errors.ChannelNotFound(channel.Id.ToString());
affectedChannels.Add(channel);
}
else
{
throw new PKSyntaxError("You must specify a channel or use the --all flag.");
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.Blacklist.ToHashSet();
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() });
await ctx.Reply($"{Emojis.Success} {(all ? "All channels" : "Channel")} added to the proxy blacklist.");
}
public async Task RemoveProxyBlacklist(Context ctx, Channel? channel, bool all)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (all)
{
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
}
else if (channel != null)
{
if (channel.GuildId != ctx.Guild.Id)
throw Errors.ChannelNotFound(channel.Id.ToString());
affectedChannels.Add(channel);
}
else
{
throw new PKSyntaxError("You must specify a channel or use the --all flag.");
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.Blacklist.ToHashSet();
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() });
await ctx.Reply($"{Emojis.Success} {(all ? "All channels" : "Channel")} removed from the proxy blacklist.");
}
public async Task ShowLogBlacklist(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
// Resolve all channels from the cache and order by position
// todo: GetAllChannels?
var channels = (await Task.WhenAll(config.LogBlacklist
.Select(id => _cache.TryGetChannel(ctx.Guild.Id, id))))
.Where(c => c != null)
@ -291,78 +315,75 @@ public class ServerConfig
});
}
public async Task SetProxyBlacklisted(Context ctx, bool shouldAdd)
public async Task AddLogBlacklist(Context ctx, Channel? channel, bool all)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (ctx.Match("all"))
if (all)
{
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
// All the channel types you can proxy in
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
}
else if (channel != null)
{
if (channel.GuildId != ctx.Guild.Id)
throw Errors.ChannelNotFound(channel.Id.ToString());
affectedChannels.Add(channel);
}
else
while (ctx.HasNext())
{
var channelString = ctx.PeekArgument();
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
affectedChannels.Add(channel);
}
{
throw new PKSyntaxError("You must specify a channel or use the --all flag.");
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.Blacklist.ToHashSet();
if (shouldAdd)
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
else
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { Blacklist = blacklist.ToArray() });
await ctx.Reply(
$"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist.");
}
public async Task SetLogBlacklisted(Context ctx, bool shouldAdd)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (ctx.Match("all"))
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
// All the channel types you can proxy in
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
else
while (ctx.HasNext())
{
var channelString = ctx.PeekArgument();
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
affectedChannels.Add(channel);
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.LogBlacklist.ToHashSet();
if (shouldAdd)
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
else
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() });
await ctx.Reply(
$"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the logging blacklist." +
$"{Emojis.Success} {(all ? "All channels" : "Channel")} added to the logging blacklist." +
(guild.LogChannel == null
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`."
: ""));
}
public async Task SetLogCleanup(Context ctx)
public async Task RemoveLogBlacklist(Context ctx, Channel? channel, bool all)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (all)
{
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
}
else if (channel != null)
{
if (channel.GuildId != ctx.Guild.Id)
throw Errors.ChannelNotFound(channel.Id.ToString());
affectedChannels.Add(channel);
}
else
{
throw new PKSyntaxError("You must specify a channel or use the --all flag.");
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.LogBlacklist.ToHashSet();
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() });
await ctx.Reply(
$"{Emojis.Success} {(all ? "All channels" : "Channel")} removed from the logging blacklist." +
(guild.LogChannel == null
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `{ctx.DefaultPrefix}serverconfig log channel #your-log-channel`."
: ""));
}
public async Task ShowLogCleanup(Context ctx)
{
var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
var eb = new EmbedBuilder()
@ -377,74 +398,77 @@ public class ServerConfig
}
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
bool? newValue = ctx.MatchToggleOrNull();
if (newValue == null)
{
if (ctx.GuildConfig!.LogCleanupEnabled)
eb.Description(
$"Log cleanup is currently **on** for this server. To disable it, type `{ctx.DefaultPrefix}serverconfig logclean off`.");
else
eb.Description(
$"Log cleanup is currently **off** for this server. To enable it, type `{ctx.DefaultPrefix}serverconfig logclean on`.");
await ctx.Reply(embed: eb.Build());
return;
}
if (ctx.GuildConfig!.LogCleanupEnabled)
eb.Description(
$"Log cleanup is currently **on** for this server. To disable it, type `{ctx.DefaultPrefix}serverconfig logclean off`.");
else
eb.Description(
$"Log cleanup is currently **off** for this server. To enable it, type `{ctx.DefaultPrefix}serverconfig logclean on`.");
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue.Value });
await ctx.Reply(embed: eb.Build());
}
if (newValue.Value)
public async Task SetLogCleanup(Context ctx, bool value)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = value });
if (value)
await ctx.Reply(
$"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts.");
else
await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server.");
}
public async Task InvalidCommandResponse(Context ctx)
public async Task ShowInvalidCommandResponse(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (!ctx.HasNext())
{
var msg = $"Error responses for unknown/invalid commands are currently **{EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { InvalidCommandResponseEnabled = newVal });
await ctx.Reply($"Error responses for unknown/invalid commands are now {EnabledDisabled(newVal)}.");
var msg = $"Error responses for unknown/invalid commands are currently **{EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled)}**.";
await ctx.Reply(msg);
}
public async Task RequireSystemTag(Context ctx)
public async Task SetInvalidCommandResponse(Context ctx, bool value)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (!ctx.HasNext())
{
var msg = $"System tags are currently **{(ctx.GuildConfig!.RequireSystemTag ? "required" : "not required")}** for PluralKit users in this server.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { RequireSystemTag = newVal });
await ctx.Reply($"System tags are now **{(newVal ? "required" : "not required")}** for PluralKit users in this server.");
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { InvalidCommandResponseEnabled = value });
await ctx.Reply($"Error responses for unknown/invalid commands are now {EnabledDisabled(value)}.");
}
public async Task SuppressNotifications(Context ctx)
public async Task ShowRequireSystemTag(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (!ctx.HasNext())
{
var msg = $"Suppressing notifications for proxied messages is currently **{EnabledDisabled(ctx.GuildConfig!.SuppressNotifications)}**.";
await ctx.Reply(msg);
return;
}
var msg = $"System tags are currently **{(ctx.GuildConfig!.RequireSystemTag ? "required" : "not required")}** for PluralKit users in this server.";
await ctx.Reply(msg);
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { SuppressNotifications = newVal });
await ctx.Reply($"Suppressing notifications for proxied messages is now {EnabledDisabled(newVal)}.");
public async Task SetRequireSystemTag(Context ctx, bool value)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { RequireSystemTag = value });
await ctx.Reply($"System tags are now **{(value ? "required" : "not required")}** for PluralKit users in this server.");
}
public async Task ShowSuppressNotifications(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var msg = $"Suppressing notifications for proxied messages is currently **{EnabledDisabled(ctx.GuildConfig!.SuppressNotifications)}**.";
await ctx.Reply(msg);
}
public async Task SetSuppressNotifications(Context ctx, bool value)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { SuppressNotifications = value });
await ctx.Reply($"Suppressing notifications for proxied messages is now {EnabledDisabled(value)}.");
}
}

View file

@ -8,11 +8,10 @@ namespace PluralKit.Bot;
public class Switch
{
public async Task SwitchDo(Context ctx)
public async Task SwitchDo(Context ctx, ICollection<PKMember> members)
{
ctx.CheckSystem();
var members = await ctx.ParseMemberList(ctx.System.Id);
await DoSwitchCommand(ctx, members);
}
@ -21,11 +20,12 @@ public class Switch
ctx.CheckSystem();
// Switch with no members = switch-out
await DoSwitchCommand(ctx, new PKMember[] { });
await DoSwitchCommand(ctx, []);
}
private async Task DoSwitchCommand(Context ctx, ICollection<PKMember> members)
private async Task DoSwitchCommand(Context ctx, ICollection<PKMember>? members)
{
if (members == null) members = new List<PKMember>();
// 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;
@ -57,16 +57,14 @@ public class Switch
$"{Emojis.Success} Switch registered. Current fronters are now {string.Join(", ", members.Select(m => m.NameFor(ctx)))}.");
}
public async Task SwitchMove(Context ctx)
public async Task SwitchMove(Context ctx, string str, bool confirmYes = false)
{
ctx.CheckSystem();
var timeToMove = ctx.RemainderOrNull() ??
throw new PKSyntaxError("Must pass a date or time to move the switch to.");
var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.Config?.UiTz ?? "UTC");
var result = DateUtils.ParseDateTime(timeToMove, true, tz);
if (result == null) throw Errors.InvalidDateTime(timeToMove);
var result = DateUtils.ParseDateTime(str, true, tz);
if (result == null) throw Errors.InvalidDateTime(str);
var time = result.Value;
@ -97,18 +95,18 @@ public class Switch
// yeet
var msg =
$"{Emojis.Warn} This will move the latest switch ({lastSwitchMemberStr}) from <t:{lastSwitchTime}> ({lastSwitchDeltaStr} ago) to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago). Is this OK?";
if (!await ctx.PromptYesNo(msg, "Move Switch")) throw Errors.SwitchMoveCancelled;
if (!await ctx.PromptYesNo(msg, "Move Switch", flagValue: confirmYes)) throw Errors.SwitchMoveCancelled;
// aaaand *now* we do the move
await ctx.Repository.MoveSwitch(lastTwoSwitches[0].Id, time.ToInstant());
await ctx.Reply($"{Emojis.Success} Switch moved to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago).");
}
public async Task SwitchEdit(Context ctx, bool newSwitch = false)
public async Task SwitchEdit(Context ctx, List<PKMember>? newMembers, bool newSwitch = false, bool first = false, bool remove = false, bool append = false, bool prepend = false, bool confirmYes = false)
{
ctx.CheckSystem();
var newMembers = await ctx.ParseMemberList(ctx.System.Id);
if (newMembers == null) newMembers = new List<PKMember>();
await using var conn = await ctx.Database.Obtain();
var currentSwitch = await ctx.Repository.GetLatestSwitch(ctx.System.Id);
@ -116,24 +114,24 @@ public class Switch
throw Errors.NoRegisteredSwitches;
var currentSwitchMembers = await ctx.Repository.GetSwitchMembers(conn, currentSwitch.Id).ToListAsync().AsTask();
if (ctx.MatchFlag("first", "f"))
if (first)
newMembers = FirstInSwitch(newMembers[0], currentSwitchMembers);
else if (ctx.MatchFlag("remove", "r"))
else if (remove)
newMembers = RemoveFromSwitch(newMembers, currentSwitchMembers);
else if (ctx.MatchFlag("append", "a"))
else if (append)
newMembers = AppendToSwitch(newMembers, currentSwitchMembers);
else if (ctx.MatchFlag("prepend", "p"))
else if (prepend)
newMembers = PrependToSwitch(newMembers, currentSwitchMembers);
if (newSwitch)
{
// if there's no edit flag, assume we're appending
if (!ctx.MatchFlag("first", "f", "remove", "r", "append", "a", "prepend", "p"))
if (!prepend && !append && !remove && !first)
newMembers = AppendToSwitch(newMembers, currentSwitchMembers);
await DoSwitchCommand(ctx, newMembers);
}
else
await DoEditCommand(ctx, newMembers);
await DoEditCommand(ctx, newMembers, confirmYes);
}
public List<PKMember> PrependToSwitch(List<PKMember> members, List<PKMember> currentSwitchMembers)
@ -169,14 +167,16 @@ public class Switch
return members;
}
public async Task SwitchEditOut(Context ctx)
public async Task SwitchEditOut(Context ctx, bool confirmYes)
{
ctx.CheckSystem();
await DoEditCommand(ctx, new PKMember[] { });
await DoEditCommand(ctx, [], confirmYes);
}
public async Task DoEditCommand(Context ctx, ICollection<PKMember> members)
public async Task DoEditCommand(Context ctx, ICollection<PKMember>? members, bool confirmYes)
{
if (members == null) members = new List<PKMember>();
// 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;
@ -203,7 +203,7 @@ public class Switch
msg = $"{Emojis.Warn} This will turn the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) into a switch-out. Is this okay?";
else
msg = $"{Emojis.Warn} This will change the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago) to {newSwitchMemberStr}. Is this okay?";
if (!await ctx.PromptYesNo(msg, "Edit")) throw Errors.SwitchEditCancelled;
if (!await ctx.PromptYesNo(msg, "Edit", flagValue: confirmYes)) throw Errors.SwitchEditCancelled;
// Actually edit the switch
await ctx.Repository.EditSwitch(conn, lastSwitch.Id, members.Select(m => m.Id).ToList());
@ -217,16 +217,16 @@ public class Switch
await ctx.Reply($"{Emojis.Success} Switch edited. Current fronters are now {newSwitchMemberStr}.");
}
public async Task SwitchDelete(Context ctx)
public async Task SwitchDelete(Context ctx, bool all = false, bool confirmYes = false)
{
ctx.CheckSystem();
if (ctx.Match("all", "clear") || ctx.MatchFlag("all", "clear", "c"))
if (all)
{
// Subcommand: "delete all"
var purgeMsg =
$"{Emojis.Warn} This will delete *all registered switches* in your system. Are you sure you want to proceed?";
if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches"))
if (!await ctx.PromptYesNo(purgeMsg, "Clear Switches", flagValue: confirmYes))
throw Errors.GenericCancelled();
await ctx.Repository.DeleteAllSwitches(ctx.System.Id);
await ctx.Reply($"{Emojis.Success} Cleared system switches!");
@ -258,7 +258,7 @@ public class Switch
msg = $"{Emojis.Warn} This will delete the latest switch ({lastSwitchMemberStr}, {lastSwitchDeltaStr} ago). The next latest switch is {secondSwitchMemberStr} ({secondSwitchDeltaStr} ago). Is this okay?";
}
if (!await ctx.PromptYesNo(msg, "Delete Switch")) throw Errors.SwitchDeleteCancelled;
if (!await ctx.PromptYesNo(msg, "Delete Switch", flagValue: confirmYes)) throw Errors.SwitchDeleteCancelled;
await ctx.Repository.DeleteSwitch(lastTwoSwitches[0].Id);
await ctx.Reply($"{Emojis.Success} Switch deleted.");

View file

@ -14,23 +14,22 @@ public class System
_embeds = embeds;
}
public async Task Query(Context ctx, PKSystem system)
public async Task Query(Context ctx, PKSystem system, bool all, bool @public, bool @private, bool showEmbed = false)
{
if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
if (ctx.MatchFlag("show-embed", "se"))
if (showEmbed)
{
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id)));
await ctx.Reply(text: EmbedService.LEGACY_EMBED_WARNING, embed: await _embeds.CreateSystemEmbed(ctx, system, ctx.LookupContextFor(system.Id), all));
return;
}
await ctx.Reply(components: await _embeds.CreateSystemMessageComponents(ctx, system, ctx.LookupContextFor(system.Id)));
await ctx.Reply(components: await _embeds.CreateSystemMessageComponents(ctx, system, ctx.LookupContextFor(system.Id), all));
}
public async Task New(Context ctx)
public async Task New(Context ctx, string? systemName)
{
ctx.CheckNoSystem();
var systemName = ctx.RemainderOrNull();
if (systemName != null && systemName.Length > Limits.MaxSystemNameLength)
throw Errors.StringTooLongError("System name", systemName.Length, Limits.MaxSystemNameLength);

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,7 @@ public class SystemFront
_embeds = embeds;
}
public async Task SystemFronter(Context ctx, PKSystem system)
public async Task Fronter(Context ctx, PKSystem system)
{
if (system == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
ctx.CheckSystemPrivacy(system.Id, system.FrontPrivacy);
@ -26,11 +26,11 @@ public class SystemFront
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system.Id)));
}
public async Task SystemFrontHistory(Context ctx, PKSystem system)
public async Task FrontHistory(Context ctx, PKSystem system, bool showMemberId, bool clear = false)
{
if (ctx.MatchFlag("clear", "c") || ctx.PeekArgument() == "clear")
if (clear)
{
await new Switch().SwitchDelete(ctx);
await new Switch().SwitchDelete(ctx, true);
return;
}
@ -55,8 +55,6 @@ public class SystemFront
embedTitle = $"Front history of {guildSettings.DisplayName} (`{system.Hid}`)";
}
var showMemberId = ctx.MatchFlag("with-id", "wid");
await ctx.Paginate(
sws,
totalSwitches,
@ -106,7 +104,7 @@ public class SystemFront
);
}
public async Task FrontPercent(Context ctx, PKSystem? system = null, PKGroup? group = null)
public async Task FrontPercent(Context ctx, PKSystem? system, string? durationStr, bool ignoreNoFronters = false, bool showFlat = false, PKGroup? group = null)
{
if (system == null && group == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
if (system == null) system = await GetGroupSystem(ctx, group);
@ -116,10 +114,8 @@ public class SystemFront
var totalSwitches = await ctx.Repository.GetSwitchCount(system.Id);
if (totalSwitches == 0) throw Errors.NoRegisteredSwitches;
var ignoreNoFronters = ctx.MatchFlag("fo", "fronters-only");
var showFlat = ctx.MatchFlag("flat");
var durationStr = ctx.RemainderOrNull() ?? "30d";
if (durationStr == null)
durationStr = "30d";
// Picked the UNIX epoch as a random date
// even though we don't store switch timestamps in UNIX time

View file

@ -1,4 +1,5 @@
using Myriad.Extensions;
using Myriad.Types;
using PluralKit.Core;
@ -6,12 +7,10 @@ namespace PluralKit.Bot;
public class SystemLink
{
public async Task LinkSystem(Context ctx)
public async Task LinkSystem(Context ctx, User account, bool confirmYes = false)
{
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 ctx.Repository.GetSystemAccounts(ctx.System.Id);
if (accountIds.Contains(account.Id))
throw Errors.AccountAlreadyLinked;
@ -21,17 +20,17 @@ public class SystemLink
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config, ctx.DefaultPrefix);
var msg = $"{account.Mention()}, please confirm the link.";
if (!await ctx.PromptYesNo(msg, "Confirm", account, false)) throw Errors.MemberLinkCancelled;
if (!await ctx.PromptYesNo(msg, "Confirm", account, true, confirmYes)) throw Errors.MemberLinkCancelled;
await ctx.Repository.AddAccount(ctx.System.Id, account.Id);
await ctx.Reply($"{Emojis.Success} Account linked to system.");
}
public async Task UnlinkAccount(Context ctx)
public async Task UnlinkAccount(Context ctx, string idRaw, bool confirmYes)
{
ctx.CheckSystem();
ulong id;
if (!ctx.MatchUserRaw(out id))
if (!idRaw.TryParseMention(out id))
throw new PKSyntaxError("You must pass an account to unlink from (either ID or @mention).");
var accountIds = (await ctx.Repository.GetSystemAccounts(ctx.System.Id)).ToList();
@ -39,7 +38,7 @@ public class SystemLink
if (accountIds.Count == 1) throw Errors.UnlinkingLastAccount(ctx.DefaultPrefix);
var msg = $"Are you sure you want to unlink <@{id}> from your system?";
if (!await ctx.PromptYesNo(msg, "Unlink")) throw Errors.MemberUnlinkCancelled;
if (!await ctx.PromptYesNo(msg, "Unlink", flagValue: confirmYes)) throw Errors.MemberUnlinkCancelled;
await ctx.Repository.RemoveAccount(ctx.System.Id, id);
await ctx.Reply($"{Emojis.Success} Account unlinked.");

View file

@ -8,16 +8,20 @@ namespace PluralKit.Bot;
public class SystemList
{
public async Task MemberList(Context ctx, PKSystem target)
public async Task MemberList(Context ctx, PKSystem target, string? query, IHasListOptions flags)
{
ctx.CheckSystem(target);
if (target == null) throw Errors.NoSystemError(ctx.DefaultPrefix);
ctx.CheckSystemPrivacy(target.Id, target.MemberListPrivacy);
var opts = flags.GetListOptions(ctx, target.Id);
opts.Search = query;
// explanation of privacy lookup here:
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
// - RenderMemberList checks the indivual privacy for each member (NameFor, etc)
// the own system is always allowed to look up their list
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id), ctx.LookupContextFor(target.Id));
await ctx.RenderMemberList(
ctx.LookupContextFor(target.Id),
target.Id,

View file

@ -140,7 +140,40 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes));
// parse parameters
Parameters parameters;
try
{
parameters = new Parameters(evt.Content?.Substring(0, cmdStart), evt.Content?.Substring(cmdStart));
}
catch (PKError e)
{
// don't send an "invalid command" response if the guild has those turned off
// TODO: only dont send command not found, not every parse error (eg. missing params, syntax error...)
if (!(guildConfig != null && guildConfig!.InvalidCommandResponseEnabled != true))
{
await _rest.CreateMessage(channel.Id, new MessageRequest
{
Content = $"{Emojis.Error} {e.Message}",
});
}
throw;
}
var ctx = new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig, _config.Prefixes ?? BotConfig.DefaultPrefixes, parameters);
Commands command;
try
{
command = await Commands.FromContext(ctx);
}
catch (PKError e)
{
await ctx.Reply($"{Emojis.Error} {e.Message}");
throw;
}
await _tree.ExecuteCommand(ctx, command);
}
catch (PKError)
{

View file

@ -4,6 +4,7 @@
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>annotations</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View file

@ -43,7 +43,7 @@ public class EmbedService
return Task.WhenAll(ids.Select(Inner));
}
public async Task<MessageComponent[]> CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx)
public async Task<MessageComponent[]> CreateSystemMessageComponents(Context cctx, PKSystem system, LookupContext ctx, bool countctxByOwner)
{
// Fetch/render info for all accounts simultaneously
var accounts = await _repo.GetSystemAccounts(system.Id);
@ -55,7 +55,7 @@ public class EmbedService
};
var countctx = LookupContext.ByNonOwner;
if (cctx.MatchFlag("a", "all"))
if (countctxByOwner)
{
if (system.Id == cctx.System?.Id)
countctx = LookupContext.ByOwner;
@ -206,14 +206,14 @@ public class EmbedService
];
}
public async Task<Embed> CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx)
public async Task<Embed> CreateSystemEmbed(Context cctx, PKSystem system, LookupContext ctx, bool countctxByOwner)
{
// Fetch/render info for all accounts simultaneously
var accounts = await _repo.GetSystemAccounts(system.Id);
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted account {x.Id})");
var countctx = LookupContext.ByNonOwner;
if (cctx.MatchFlag("a", "all"))
if (countctxByOwner)
{
if (system.Id == cctx.System?.Id)
countctx = LookupContext.ByOwner;
@ -560,7 +560,7 @@ public class EmbedService
return eb.Build();
}
public async Task<MessageComponent[]> CreateGroupMessageComponents(Context ctx, PKSystem system, PKGroup target)
public async Task<MessageComponent[]> CreateGroupMessageComponents(Context ctx, PKSystem system, PKGroup target, bool all)
{
var pctx = ctx.LookupContextFor(system.Id);
var name = target.NameFor(ctx);
@ -568,7 +568,7 @@ public class EmbedService
var systemName = (ctx.Guild != null && systemGuildSettings?.DisplayName != null) ? systemGuildSettings?.DisplayName! : system.NameFor(ctx);
var countctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
if (all)
{
if (system.Id == ctx.System?.Id)
countctx = LookupContext.ByOwner;
@ -673,12 +673,12 @@ public class EmbedService
];
}
public async Task<Embed> CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target)
public async Task<Embed> CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target, bool all)
{
var pctx = ctx.LookupContextFor(system.Id);
var countctx = LookupContext.ByNonOwner;
if (ctx.MatchFlag("a", "all"))
if (all)
{
if (system.Id == ctx.System?.Id)
countctx = LookupContext.ByOwner;

View file

@ -16,17 +16,17 @@ namespace PluralKit.Bot;
public static class ContextUtils
{
public static async Task<bool> ConfirmClear(this Context ctx, string toClear)
public static async Task<bool> ConfirmClear(this Context ctx, string toClear, bool confirmYes)
{
if (!await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear"))
if (!await ctx.PromptYesNo($"{Emojis.Warn} Are you sure you want to clear {toClear}?", "Clear", flagValue: confirmYes))
throw Errors.GenericCancelled();
return true;
}
public static async Task<bool> PromptYesNo(this Context ctx, string msgString, string acceptButton,
User user = null, bool matchFlag = true)
User user = null, bool matchFlag = true, bool flagValue = false)
{
if (matchFlag && ctx.MatchFlag("y", "yes")) return true;
if (matchFlag && flagValue) return true;
var prompt = new YesNoPrompt(ctx)
{

View file

@ -2,6 +2,15 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /app
RUN apt-get update && apt-get install -y curl build-essential && \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
ENV RUSTFLAGS='-C link-arg=-s'
# Install uniffi-bindgen-cs
RUN cargo install uniffi-bindgen-cs --git https://github.com/90-008/uniffi-bindgen-cs
# Restore/fetch dependencies excluding app code to make use of caching
COPY PluralKit.sln /app/
COPY Myriad/Myriad.csproj /app/Myriad/
@ -13,8 +22,21 @@ COPY .git/ /app/.git
COPY Serilog/ /app/Serilog/
RUN dotnet restore PluralKit.sln
# Copy the rest of the code and build
# Copy the rest of the code
COPY . /app
# copy parser code
COPY Cargo.toml /app/
COPY Cargo.lock /app/
COPY crates/ /app/crates
# Generate command parser bindings
RUN mkdir -p /app/bin && cargo -Z unstable-options build --package commands --lib --release --artifact-dir /app/bin/
RUN uniffi-bindgen-cs "/app/bin/libcommands.so" --library --out-dir="/app/PluralKit.Bot"
RUN cargo run --package commands --bin write_cs_glue -- "/app/PluralKit.Bot/commandtypes.cs"
# build bot
RUN dotnet build -c Release -o bin
# Build runtime stage (doesn't include SDK)

View file

@ -153,7 +153,7 @@ async fn verify(
)
.await?;
process::process_async(result.data, req.kind).await?;
let _ = process::process_async(result.data, req.kind).await?;
Ok(())
}

View file

@ -0,0 +1,7 @@
[package]
name = "command_definitions"
version = "0.1.0"
edition = "2021"
[dependencies]
command_parser = { path = "../command_parser"}

View file

@ -0,0 +1,75 @@
use super::*;
pub fn admin() -> &'static str {
"admin"
}
pub fn cmds() -> impl IntoIterator<Item = Command> {
let admin = admin();
let abuselog = tokens!(admin, ("abuselog", ["al"]));
let make_abuselog_cmds = |log_param: Parameter| {
[
command!(abuselog, ("show", ["s"]), log_param => format!("admin_abuselog_show_{}", log_param.name()))
.help("Shows an abuse log entry"),
command!(abuselog, ("flagdeny", ["fd"]), log_param, Optional(("value", Toggle)) => format!("admin_abuselog_flag_deny_{}", log_param.name()))
.help("Sets the deny flag on an abuse log entry"),
command!(abuselog, ("description", ["desc"]), log_param, Optional(Remainder(("desc", OpaqueString))) => format!("admin_abuselog_description_{}", log_param.name()))
.flag(CLEAR)
.flag(YES)
.help("Sets the description of an abuse log entry"),
command!(abuselog, ("adduser", ["au"]), log_param => format!("admin_abuselog_add_user_{}", log_param.name()))
.help("Adds a user to an abuse log entry"),
command!(abuselog, ("removeuser", ["ru"]), log_param => format!("admin_abuselog_remove_user_{}", log_param.name()))
.help("Removes a user from an abuse log entry"),
command!(abuselog, ("delete", ["d"]), log_param => format!("admin_abuselog_delete_{}", log_param.name()))
.help("Deletes an abuse log entry"),
]
};
let abuselog_cmds = [
command!(abuselog, ("create", ["c", "new"]), ("account", UserRef), Optional(Remainder(("description", OpaqueString))) => "admin_abuselog_create")
.flag(("deny-boy-usage", ["deny"]))
.help("Creates an abuse log entry")
]
.into_iter()
.chain(make_abuselog_cmds(Skip(("account", UserRef)).into())) // falls through to log_id
.chain(make_abuselog_cmds(("log_id", OpaqueString).into()));
[
command!(admin, ("updatesystemid", ["usid"]), SystemRef, ("new_hid", OpaqueString) => "admin_update_system_id")
.flag(YES)
.help("Updates a system's ID"),
command!(admin, ("updatememberid", ["umid"]), MemberRef, ("new_hid", OpaqueString) => "admin_update_member_id")
.flag(YES)
.help("Updates a member's ID"),
command!(admin, ("updategroupid", ["ugid"]), GroupRef, ("new_hid", OpaqueString) => "admin_update_group_id")
.flag(YES)
.help("Updates a group's ID"),
command!(admin, ("rerollsystemid", ["rsid"]), SystemRef => "admin_reroll_system_id")
.flag(YES)
.help("Rerolls a system's ID"),
command!(admin, ("rerollmemberid", ["rmid"]), MemberRef => "admin_reroll_member_id")
.flag(YES)
.help("Rerolls a member's ID"),
command!(admin, ("rerollgroupid", ["rgid"]), GroupRef => "admin_reroll_group_id")
.flag(YES)
.help("Rerolls a group's ID"),
command!(admin, ("updatememberlimit", ["uml"]), SystemRef, Optional(("limit", OpaqueInt)) => "admin_system_member_limit")
.flag(YES)
.help("Updates a system's member limit"),
command!(admin, ("updategrouplimit", ["ugl"]), SystemRef, Optional(("limit", OpaqueInt)) => "admin_system_group_limit")
.flag(YES)
.help("Updates a system's group limit"),
command!(admin, ("systemrecover", ["sr"]), ("token", OpaqueString), ("account", UserRef) => "admin_system_recover")
.flag(YES)
.flag(("reroll-token", ["rt"]))
.help("Recovers a system"),
command!(admin, ("systemdelete", ["sd"]), SystemRef => "admin_system_delete")
.help("Deletes a system"),
command!(admin, ("sendmessage", ["sendmsg"]), ("account", UserRef), Remainder(("content", OpaqueString)) => "admin_send_message")
.help("Sends a message to a user"),
]
.into_iter()
.chain(abuselog_cmds)
.map(|cmd| cmd.show_in_suggestions(false))
}

View file

@ -0,0 +1,9 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
[
command!("token" => "token_display").help("Gets your system's API token"),
command!("token", ("refresh", ["renew", "regen", "reroll"]) => "token_refresh")
.help("Generates a new API token and invalidates the old one"),
]
}

View file

@ -0,0 +1,20 @@
use super::*;
pub fn autoproxy() -> (&'static str, [&'static str; 2]) {
("autoproxy", ["ap", "auto"])
}
pub fn cmds() -> impl IntoIterator<Item = Command> {
let ap = autoproxy();
[
command!(ap => "autoproxy_show").help("Shows your current autoproxy settings"),
command!(ap, ("off", ["stop", "cancel", "no", "disable", "remove"]) => "autoproxy_off")
.help("Disables autoproxying for your system in the current server"),
command!(ap, ("latch", ["last", "proxy", "stick", "sticky", "l"]) => "autoproxy_latch")
.help("Sets your system's autoproxy in this server to proxy the last manually proxied member"),
command!(ap, ("front", ["fronter", "switch", "f"]) => "autoproxy_front")
.help("Sets your system's autoproxy in this server to proxy the first member currently registered as front"),
command!(ap, MemberRef => "autoproxy_member").help("Sets your system's autoproxy in this server to proxy a specific member"),
]
}

View file

@ -0,0 +1,239 @@
use command_parser::parameter;
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
let cfg = ("config", ["cfg", "configure"]);
let base = [command!(cfg => "cfg_show").help("Shows the current configuration")];
let ap = tokens!(cfg, ("autoproxy", ["ap"]));
let ap_account = tokens!(ap, ("account", ["ac"]));
let ap_timeout = tokens!(ap, ("timeout", ["tm"]));
let autoproxy = [
command!(ap_account => "cfg_ap_account_show")
.help("Shows autoproxy status for the account"),
command!(ap_account, Toggle => "cfg_ap_account_update")
.help("Toggles autoproxy globally for the current account"),
command!(ap_timeout => "cfg_ap_timeout_show").help("Shows the autoproxy timeout"),
command!(ap_timeout, RESET => "cfg_ap_timeout_reset")
.help("Resets the autoproxy timeout"),
command!(ap_timeout, parameter::Toggle::Off => "cfg_ap_timeout_off")
.help("Disables the autoproxy timeout"),
command!(ap_timeout, ("timeout", OpaqueString) => "cfg_ap_timeout_update")
.help("Sets the latch timeout duration for your system"),
];
let timezone_tokens = tokens!(cfg, ("timezone", ["zone", "tz"]));
let timezone = [
command!(timezone_tokens => "cfg_timezone_show").help("Shows the system timezone"),
command!(timezone_tokens, RESET => "cfg_timezone_reset")
.help("Resets the system timezone"),
command!(timezone_tokens, ("timezone", OpaqueString) => "cfg_timezone_update")
.flag(YES)
.help("Changes your system's time zone"),
];
let ping_tokens = tokens!(cfg, "ping");
let ping = [
command!(ping_tokens => "cfg_ping_show").help("Shows ping preferences"),
command!(ping_tokens, Toggle => "cfg_ping_update")
.help("Changes your system's ping preferences"),
];
let priv_ = ("private", ["priv"]);
let member_privacy = tokens!(cfg, priv_, ("member", ["mem"]));
let member_privacy_short = tokens!(cfg, "mp");
let group_privacy = tokens!(cfg, priv_, ("group", ["grp"]));
let group_privacy_short = tokens!(cfg, "gp");
let privacy = [
command!(member_privacy => "cfg_member_privacy_show")
.help("Shows the default privacy for new members"),
command!(member_privacy, Toggle => "cfg_member_privacy_update")
.help("Sets whether member privacy is automatically set to private when creating a new member"),
command!(member_privacy_short => "cfg_member_privacy_show")
.help("Shows the default privacy for new members"),
command!(member_privacy_short, Toggle => "cfg_member_privacy_update")
.help("Sets whether member privacy is automatically set to private when creating a new member"),
command!(group_privacy => "cfg_group_privacy_show")
.help("Shows the default privacy for new groups"),
command!(group_privacy, Toggle => "cfg_group_privacy_update")
.help("Sets whether group privacy is automatically set to private when creating a new group"),
command!(group_privacy_short => "cfg_group_privacy_show")
.help("Shows the default privacy for new groups"),
command!(group_privacy_short, Toggle => "cfg_group_privacy_update")
.help("Sets whether group privacy is automatically set to private when creating a new group"),
];
let show = "show";
let show_private = tokens!(cfg, show, priv_);
let show_private_short = tokens!(cfg, "sp");
let private_info = [
command!(show_private => "cfg_show_private_info_show")
.help("Shows whether private info is shown"),
command!(show_private, Toggle => "cfg_show_private_info_update")
.help("Sets whether private information is shown to linked accounts by default"),
command!(show_private_short => "cfg_show_private_info_show")
.help("Shows whether private info is shown"),
command!(show_private_short, Toggle => "cfg_show_private_info_update")
.help("Sets whether private information is shown to linked accounts by default"),
];
let proxy = ("proxy", ["px"]);
let proxy_case = tokens!(cfg, proxy, ("case", ["caps", "capitalize", "capitalise"]));
let proxy_error = tokens!(cfg, proxy, ("error", ["errors"]));
let proxy_error_short = tokens!(cfg, "pe");
let proxy_switch = tokens!(cfg, proxy, "switch");
let proxy_switch_short = tokens!(cfg, ("proxyswitch", ["ps"]));
let proxy_settings = [
command!(proxy_case => "cfg_case_sensitive_proxy_tags_show")
.help("Shows whether proxy tags are case-sensitive"),
command!(proxy_case, Toggle => "cfg_case_sensitive_proxy_tags_update")
.help("Toggles case sensitivity for proxy tags"),
command!(proxy_error => "cfg_proxy_error_message_show")
.help("Shows whether proxy error messages are enabled"),
command!(proxy_error, Toggle => "cfg_proxy_error_message_update")
.help("Toggles proxy error messages"),
command!(proxy_error_short => "cfg_proxy_error_message_show")
.help("Shows whether proxy error messages are enabled"),
command!(proxy_error_short, Toggle => "cfg_proxy_error_message_update")
.help("Toggles proxy error messages"),
command!(proxy_switch => "cfg_proxy_switch_show").help("Shows the proxy switch behavior"),
command!(proxy_switch, ProxySwitchAction => "cfg_proxy_switch_update")
.help("Sets the switching behavior when proxy tags are used"),
command!(proxy_switch_short => "cfg_proxy_switch_show")
.help("Shows the proxy switch behavior"),
command!(proxy_switch_short, ProxySwitchAction => "cfg_proxy_switch_update")
.help("Sets the switching behavior when proxy tags are used"),
];
let id = ("id", ["ids"]);
let split_id = tokens!(cfg, "split", id);
let split_id_short = tokens!(cfg, ("sid", ["sids"]));
let cap_id = tokens!(cfg, ("cap", ["caps", "capitalize", "capitalise"]), id);
let cap_id_short = tokens!(cfg, ("capid", ["capids"]));
let pad = ("pad", ["padding"]);
let pad_id = tokens!(cfg, pad, id);
let id_pad = tokens!(cfg, id, pad);
let id_pad_short = tokens!(cfg, ("idpad", ["padid", "padids"]));
let id_settings = [
command!(split_id => "cfg_hid_split_show").help("Shows whether IDs are split in lists"),
command!(split_id, Toggle => "cfg_hid_split_update").help("Toggles splitting IDs in lists"),
command!(split_id_short => "cfg_hid_split_show")
.help("Shows whether IDs are split in lists"),
command!(split_id_short, Toggle => "cfg_hid_split_update")
.help("Toggles splitting IDs in lists"),
command!(cap_id => "cfg_hid_caps_show").help("Shows whether IDs are capitalized in lists"),
command!(cap_id, Toggle => "cfg_hid_caps_update")
.help("Toggles capitalization of IDs in lists"),
command!(cap_id_short => "cfg_hid_caps_show")
.help("Shows whether IDs are capitalized in lists"),
command!(cap_id_short, Toggle => "cfg_hid_caps_update")
.help("Toggles capitalization of IDs in lists"),
command!(pad_id => "cfg_hid_padding_show").help("Shows the ID padding for lists"),
command!(pad_id, ("padding", OpaqueString) => "cfg_hid_padding_update")
.help("Sets the ID padding for lists"),
command!(id_pad => "cfg_hid_padding_show").help("Shows the ID padding for lists"),
command!(id_pad, ("padding", OpaqueString) => "cfg_hid_padding_update")
.help("Sets the ID padding for lists"),
command!(id_pad_short => "cfg_hid_padding_show").help("Shows the ID padding for lists"),
command!(id_pad_short, ("padding", OpaqueString) => "cfg_hid_padding_update")
.help("Sets the ID padding for lists"),
];
let show_color = tokens!(cfg, show, ("color", ["colour", "colors", "colours"]));
let show_color_short = tokens!(
cfg,
(
"showcolor",
[
"showcolour",
"showcolors",
"showcolours",
"colorcode",
"colorhex"
]
)
);
let color_settings = [
command!(show_color => "cfg_card_show_color_hex_show")
.help("Shows whether color hex codes are shown on cards"),
command!(show_color, Toggle => "cfg_card_show_color_hex_update")
.help("Toggles showing color hex codes on cards"),
command!(show_color_short => "cfg_card_show_color_hex_show")
.help("Shows whether color hex codes are shown on cards"),
command!(show_color_short, Toggle => "cfg_card_show_color_hex_update")
.help("Toggles showing color hex codes on cards"),
];
let format = "format";
let name_format = tokens!(cfg, "name", format);
let name_format_short = tokens!(cfg, ("nameformat", ["nf"]));
let name_formatting = [
command!(name_format => "cfg_name_format_show").help("Shows the name format"),
command!(name_format, RESET => "cfg_name_format_reset")
.help("Resets the name format"),
command!(name_format, ("format", OpaqueString) => "cfg_name_format_update")
.help("Changes your system's username formatting"),
command!(name_format_short => "cfg_name_format_show").help("Shows the name format"),
command!(name_format_short, RESET => "cfg_name_format_reset")
.help("Resets the name format"),
command!(name_format_short, ("format", OpaqueString) => "cfg_name_format_update")
.help("Changes your system's username formatting"),
];
let server = "server";
let server_name_format = tokens!(cfg, server, "name", format);
let server_format = tokens!(
cfg,
("server", ["servername"]),
("format", ["nameformat", "nf"])
);
let server_format_short = tokens!(
cfg,
("snf", ["servernf", "servernameformat", "snameformat"])
);
let server_name_formatting = [
command!(server_name_format => "cfg_server_name_format_show")
.help("Shows the server name format"),
command!(server_name_format, RESET => "cfg_server_name_format_reset")
.help("Resets the server name format"),
command!(server_name_format, ("format", OpaqueString) => "cfg_server_name_format_update")
.help("Changes your system's username formatting in the current server"),
command!(server_format => "cfg_server_name_format_show")
.help("Shows the server name format"),
command!(server_format, RESET => "cfg_server_name_format_reset")
.help("Resets the server name format"),
command!(server_format, ("format", OpaqueString) => "cfg_server_name_format_update")
.help("Changes your system's username formatting in the current server"),
command!(server_format_short => "cfg_server_name_format_show")
.help("Shows the server name format"),
command!(server_format_short, RESET => "cfg_server_name_format_reset")
.help("Resets the server name format"),
command!(server_format_short, ("format", OpaqueString) => "cfg_server_name_format_update")
.help("Changes your system's username formatting in the current server"),
];
let limit_ = ("limit", ["lim"]);
let member_limit = tokens!(cfg, ("member", ["mem"]), limit_);
let group_limit = tokens!(cfg, ("group", ["grp"]), limit_);
let limit = tokens!(cfg, limit_);
let limits = [
command!(member_limit => "cfg_limits_update").help("Refreshes member/group limits"),
command!(group_limit => "cfg_limits_update").help("Refreshes member/group limits"),
command!(limit => "cfg_limits_update").help("Refreshes member/group limits"),
];
base.into_iter()
.chain(autoproxy)
.chain(timezone)
.chain(ping)
.chain(privacy)
.chain(private_info)
.chain(proxy_settings)
.chain(id_settings)
.chain(color_settings)
.chain(name_formatting)
.chain(server_name_formatting)
.chain(limits)
}

View file

@ -0,0 +1,20 @@
use command_parser::parameter::MESSAGE_REF;
use super::*;
pub fn debug() -> (&'static str, [&'static str; 1]) {
("debug", ["dbg"])
}
pub fn cmds() -> impl IntoIterator<Item = Command> {
let debug = debug();
let perms = ("permissions", ["perms", "permcheck"]);
[
command!(debug, perms, ("channel", ["ch"]), ChannelRef => "permcheck_channel")
.help("Checks if PluralKit has the required permissions in a channel"),
command!(debug, perms, ("guild", ["g"]), GuildRef => "permcheck_guild")
.help("Checks whether a server's permission setup is correct"),
command!(debug, ("proxy", ["proxying", "proxycheck"]), MESSAGE_REF => "message_proxy_check")
.help("Checks why a message has not been proxied"),
]
}

View file

@ -0,0 +1,17 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
[
command!("thunder" => "fun_thunder").help("Vanquishes your opponent with a lightning bolt"),
command!("meow" => "fun_meow").help("mrrp :3"),
command!("mn" => "fun_pokemon").help("Gotta catch 'em all!"),
command!("fire" => "fun_fire").help("Engulfs your opponent in a pillar of fire"),
command!("freeze" => "fun_freeze").help("Freezes your opponent solid"),
command!("starstorm" => "fun_starstorm")
.help("Summons a storm of meteors to strike your opponent"),
command!("flash" => "fun_flash").help("Explodes your opponent with a ball of green light"),
command!("rool" => "fun_rool").help("\"What the fuck is a Pokémon?\""),
command!("sus" => "amogus").help(""),
command!("error" => "fun_error").help("Shows a fake error message"),
]
}

View file

@ -0,0 +1,182 @@
use std::iter::once;
use command_parser::token::TokensIterator;
use crate::utils::get_list_flags;
use super::*;
pub fn group() -> (&'static str, [&'static str; 1]) {
("group", ["g"])
}
pub fn targeted() -> TokensIterator {
tokens!(group(), GroupRef)
}
pub fn cmds() -> impl Iterator<Item = Command> {
let group = group();
let group_target = targeted();
let group_new = tokens!(group, ("new", ["n"]));
let group_new_cmd = once(
command!(group_new, Remainder(("name", OpaqueString)) => "group_new")
.flag(YES)
.help("Creates a new group"),
);
let group_info_cmd = once(
command!(group_target => "group_info")
.flag(ALL)
.help("Looks up information about a group"),
);
let group_name = tokens!(
group_target,
("name", ["rename", "changename", "setname", "rn"])
);
let group_name_cmd = [
command!(group_name => "group_show_name").help("Shows the group's name"),
command!(group_name, CLEAR => "group_clear_name")
.flag(YES)
.help("Clears the group's name"),
command!(group_name, Remainder(("name", OpaqueString)) => "group_rename")
.flag(YES)
.help("Renames a group"),
];
let group_display_name = tokens!(group_target, ("displayname", ["dn", "nick", "nickname"]));
let group_display_name_cmd = [
command!(group_display_name => "group_show_display_name")
.help("Shows the group's display name"),
command!(group_display_name, CLEAR => "group_clear_display_name")
.flag(YES)
.help("Clears the group's display name"),
command!(group_display_name, Remainder(("name", OpaqueString)) => "group_change_display_name")
.help("Changes the group's display name"),
];
let group_description = tokens!(
group_target,
(
"description",
["desc", "describe", "d", "bio", "info", "text", "intro"]
)
);
let group_description_cmd = [
command!(group_description => "group_show_description")
.help("Shows the group's description"),
command!(group_description, CLEAR => "group_clear_description")
.flag(YES)
.help("Clears the group's description"),
command!(group_description, Remainder(("description", OpaqueString)) => "group_change_description")
.help("Changes the group's description"),
];
let group_icon = tokens!(
group_target,
("icon", ["avatar", "picture", "image", "pic", "pfp"])
);
let group_icon_cmd = [
command!(group_icon => "group_show_icon").help("Shows the group's icon"),
command!(group_icon, CLEAR => "group_clear_icon")
.flag(YES)
.help("Clears the group's icon"),
command!(group_icon, ("icon", Avatar) => "group_change_icon")
.help("Changes a group's icon"),
];
let group_banner = tokens!(group_target, ("banner", ["splash", "cover"]));
let group_banner_cmd = [
command!(group_banner => "group_show_banner").help("Sets the group's banner image"),
command!(group_banner, CLEAR => "group_clear_banner")
.flag(YES)
.help("Clears the group's banner"),
command!(group_banner, ("banner", Avatar) => "group_change_banner")
.help("Sets the group's banner image"),
];
let group_color = tokens!(group_target, ("color", ["colour"]));
let group_color_cmd = [
command!(group_color => "group_show_color").help("Shows the group's color"),
command!(group_color, CLEAR => "group_clear_color")
.flag(YES)
.help("Clears the group's color"),
command!(group_color, ("color", OpaqueString) => "group_change_color")
.help("Changes a group's color"),
];
let group_privacy = tokens!(group_target, ("privacy", ["priv"]));
let group_privacy_cmd = [
command!(group_privacy => "group_show_privacy")
.help("Shows the group's privacy settings"),
command!(group_privacy, ALL, ("level", PrivacyLevel) => "group_change_privacy_all")
.help("Changes all privacy settings for the group"),
command!(group_privacy, ("privacy", GroupPrivacyTarget), ("level", PrivacyLevel) => "group_change_privacy")
.help("Changes a specific privacy setting for the group"),
];
let group_public_cmd = [
command!(group_target, ("public", ["pub"]) => "group_set_public")
.help("Sets the group to public"),
];
let group_private_cmd = [
command!(group_target, ("private", ["priv"]) => "group_set_private")
.help("Sets the group to private"),
];
let group_delete_cmd = [
command!(group_target, ("delete", ["destroy", "erase", "yeet"]) => "group_delete")
.help("Deletes a group"),
];
let group_id_cmd = [command!(group_target, "id" => "group_id").help("Prints a group's ID")];
let group_front = tokens!(group_target, ("front", ["fronter", "fronters", "f"]));
let group_front_cmd = [
command!(group_front, ("percent", ["p", "%"]) => "group_fronter_percent")
.help("Shows a group's front breakdown")
.flag(("duration", OpaqueString))
.flag(("fronters-only", ["fo"]))
.flag("flat"),
];
let apply_list_opts = |cmd: Command| cmd.flags(get_list_flags());
let search_param = Optional(Remainder(("query", OpaqueString)));
let group_list_members_cmd =
once(command!(group_target, ("members", ["list", "ls"]), search_param => "group_members"))
.map(apply_list_opts);
let group_modify_members_cmd = [
command!(group_target, "add", Optional(MemberRefs) => "group_add_member")
.help("Adds one or more members to a group")
.flag(ALL),
command!(group_target, ("remove", ["rem", "rm"]), Optional(MemberRefs) => "group_remove_member")
.help("Removes one or more members from a group")
.flag(ALL).flag(YES),
];
let system_groups_cmd =
once(command!(group, ("list", ["ls", "l"]), search_param => "groups_self"))
.map(apply_list_opts);
system_groups_cmd
.chain(group_new_cmd)
.chain(group_info_cmd)
.chain(group_name_cmd)
.chain(group_display_name_cmd)
.chain(group_description_cmd)
.chain(group_icon_cmd)
.chain(group_banner_cmd)
.chain(group_color_cmd)
.chain(group_privacy_cmd)
.chain(group_public_cmd)
.chain(group_private_cmd)
.chain(group_front_cmd)
.chain(group_modify_members_cmd)
.chain(group_delete_cmd)
.chain(group_id_cmd)
.chain(group_list_members_cmd)
}

View file

@ -0,0 +1,15 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
let help = ("help", ["h"]);
[
command!(("commands", ["cmd", "c"]), ("subject", OpaqueString) => "commands_list")
.help("Lists all commands or commands in a specific category"),
command!(("dashboard", ["dash"]) => "dashboard")
.help("Gets a link to the PluralKit web dashboard"),
command!("explain" => "explain").help("Explains the basics of systems and proxying"),
command!(help => "help").help("Shows the help command"),
command!(help, "commands" => "help_commands").help("help commands"),
command!(help, "proxy" => "help_proxy").help("help proxy"),
]
}

View file

@ -0,0 +1,10 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
[
command!("import", Optional(Remainder(("url", OpaqueString))) => "import")
.help("Imports system information from a data file")
.flag(YES),
command!("export" => "export").help("Exports system information to a file"),
]
}

View file

@ -0,0 +1,59 @@
pub mod admin;
pub mod api;
pub mod autoproxy;
pub mod config;
pub mod debug;
pub mod fun;
pub mod group;
pub mod help;
pub mod import_export;
pub mod member;
pub mod message;
pub mod misc;
pub mod random;
pub mod server_config;
pub mod switch;
pub mod system;
pub mod utils;
use command_parser::{
command,
command::Command,
parameter::{Optional, Parameter, ParameterKind::*, Remainder, Skip},
tokens,
};
pub fn all() -> impl Iterator<Item = Command> {
std::iter::empty()
.chain(help::cmds())
.chain(system::cmds())
.chain(group::cmds())
.chain(member::cmds())
.chain(config::cmds())
.chain(server_config::cmds())
.chain(fun::cmds())
.chain(switch::cmds())
.chain(random::cmds())
.chain(api::cmds())
.chain(autoproxy::cmds())
.chain(debug::cmds())
.chain(message::cmds())
.chain(import_export::cmds())
.chain(admin::cmds())
.chain(misc::cmds())
.map(|cmd| {
cmd.hidden_flag(("plaintext", ["pt"]))
.hidden_flag(("raw", ["r"]))
.hidden_flag(("show-embed", ["se"]))
.hidden_flag(("by-id", ["id"]))
.hidden_flag(("private", ["priv"]))
.hidden_flag(("public", ["pub"]))
})
}
pub const RESET: (&str, [&str; 2]) = ("reset", ["clear", "default"]);
pub const CLEAR: (&str, [&str; 1]) = ("clear", ["c"]);
pub const YES: (&str, [&str; 1]) = ("yes", ["y"]);
pub const ALL: (&str, [&str; 1]) = ("all", ["a"]);

View file

@ -0,0 +1,331 @@
use std::iter::once;
use command_parser::token::TokensIterator;
use crate::utils::get_list_flags;
use super::*;
pub fn member() -> (&'static str, [&'static str; 1]) {
("member", ["m"])
}
pub fn targetted() -> TokensIterator {
tokens!(member(), MemberRef)
}
pub fn cmds() -> impl Iterator<Item = Command> {
let member = member();
let member_target = targetted();
let name = ("name", ["n"]);
let description = ("description", ["desc"]);
let pronouns = ("pronouns", ["pronoun", "prns", "pn"]);
let privacy = ("privacy", ["priv"]);
let new = ("new", ["n"]);
let banner = ("banner", ["bn"]);
let color = ("color", ["colour"]);
let birthday = ("birthday", ["bday", "bd"]);
let display_name = ("displayname", ["dname", "dn"]);
let server_name = ("servername", ["sname", "sn"]);
let keep_proxy = ("keepproxy", ["kp"]);
let server_keep_proxy = ("serverkeepproxy", ["skp"]);
let autoproxy = ("autoproxy", ["ap"]);
let proxy = ("proxy", ["tags", "proxytags", "brackets"]);
let tts = ("tts", ["texttospeech"]);
let delete = ("delete", ["del", "remove"]);
let member_new_cmd = once(
command!(member, new, ("name", OpaqueString) => "member_new")
.flag(YES)
.help("Creates a new member"),
);
let member_info_cmd = once(
command!(member_target => "member_show")
.flag("pt")
.help("Looks up information about a member"),
);
let member_name_cmd = {
let member_name = tokens!(member_target, name);
[
command!(member_name => "member_name_show").help("Shows a member's name"),
command!(member_name, Remainder(("name", OpaqueString)) => "member_name_update")
.flag(YES)
.help("Renames a member"),
]
};
let member_description_cmd = {
let member_desc = tokens!(member_target, description);
[
command!(member_desc => "member_desc_show").help("Shows a member's description"),
command!(member_desc, CLEAR => "member_desc_clear")
.flag(YES)
.help("Clears a member's description"),
command!(member_desc, Remainder(("description", OpaqueString)) => "member_desc_update")
.help("Changes a member's description"),
]
};
let member_privacy_cmd = {
let member_privacy = tokens!(member_target, privacy);
[
command!(member_privacy => "member_privacy_show")
.help("Displays a member's current privacy settings"),
command!(
member_privacy, MemberPrivacyTarget, ("new_privacy_level", PrivacyLevel)
=> "member_privacy_update"
)
.help("Changes a member's privacy settings"),
]
};
let member_pronouns_cmd = {
let member_pronouns = tokens!(member_target, pronouns);
[
command!(member_pronouns => "member_pronouns_show")
.help("Shows a member's pronouns"),
command!(member_pronouns, Remainder(("pronouns", OpaqueString)) => "member_pronouns_update")
.help("Changes a member's pronouns"),
command!(member_pronouns, CLEAR => "member_pronouns_clear")
.flag(YES)
.help("Clears a member's pronouns"),
]
};
let member_banner_cmd = {
let member_banner = tokens!(member_target, banner);
[
command!(member_banner => "member_banner_show").help("Shows a member's banner image"),
command!(member_banner, ("banner", Avatar) => "member_banner_update")
.help("Sets the member's banner image"),
command!(member_banner, CLEAR => "member_banner_clear")
.flag(YES)
.help("Clears a member's banner image"),
]
};
let member_color_cmd = {
let member_color = tokens!(member_target, color);
[
command!(member_color => "member_color_show").help("Shows a member's color"),
command!(member_color, ("color", OpaqueString) => "member_color_update")
.help("Changes a member's color"),
command!(member_color, CLEAR => "member_color_clear")
.flag(YES)
.help("Clears a member's color"),
]
};
let member_birthday_cmd = {
let member_birthday = tokens!(member_target, birthday);
[
command!(member_birthday => "member_birthday_show").help("Shows a member's birthday"),
command!(member_birthday, ("birthday", OpaqueString) => "member_birthday_update")
.help("Changes a member's birthday"),
command!(member_birthday, CLEAR => "member_birthday_clear")
.flag(YES)
.help("Clears a member's birthday"),
]
};
let member_display_name_cmd = {
let member_display_name = tokens!(member_target, display_name);
[
command!(member_display_name => "member_displayname_show")
.help("Shows a member's display name"),
command!(member_display_name, Remainder(("name", OpaqueString)) => "member_displayname_update")
.help("Changes a member's display name"),
command!(member_display_name, CLEAR => "member_displayname_clear")
.flag(YES)
.help("Clears a member's display name"),
]
};
let member_server_name_cmd = {
let member_server_name = tokens!(member_target, server_name);
[
command!(member_server_name => "member_servername_show")
.help("Shows a member's server name"),
command!(member_server_name, Remainder(("name", OpaqueString)) => "member_servername_update")
.help("Changes a member's display name in the current server"),
command!(member_server_name, CLEAR => "member_servername_clear")
.flag(YES)
.help("Clears a member's server name"),
]
};
let member_proxy_cmd = {
let member_proxy = tokens!(member_target, proxy);
[
command!(member_proxy => "member_proxy_show")
.help("Shows a member's proxy tags"),
command!(member_proxy, ("add", ["a"]), ("tag", OpaqueString) => "member_proxy_add")
.flag(YES)
.help("Adds proxy tag to a member"),
command!(member_proxy, ("remove", ["r", "rm"]), ("tag", OpaqueString) => "member_proxy_remove")
.help("Removes proxy tag from a member"),
command!(member_proxy, CLEAR => "member_proxy_clear")
.flag(YES)
.help("Clears all proxy tags from a member"),
command!(member_proxy, Remainder(("tags", OpaqueString)) => "member_proxy_set")
.flag(YES)
.help("Sets a member's proxy tags"),
]
};
let member_proxy_settings_cmd = {
let member_keep_proxy = tokens!(member_target, keep_proxy);
let member_server_keep_proxy = tokens!(member_target, server_keep_proxy);
[
command!(member_keep_proxy => "member_keepproxy_show")
.help("Shows a member's keep-proxy setting"),
command!(member_keep_proxy, ("value", Toggle) => "member_keepproxy_update")
.help("Sets whether to include a member's proxy tags when proxying"),
command!(member_server_keep_proxy => "member_server_keepproxy_show")
.help("Shows a member's server-specific keep-proxy setting"),
command!(member_server_keep_proxy, CLEAR => "member_server_keepproxy_clear")
.flag(YES)
.help("Clears a member's server-specific keep-proxy setting"),
command!(member_server_keep_proxy, ("value", Toggle) => "member_server_keepproxy_update")
.help("Sets whether to include a member's proxy tags when proxying in the current server"),
]
};
let member_message_settings_cmd = {
let member_tts = tokens!(member_target, tts);
let member_autoproxy = tokens!(member_target, autoproxy);
[
command!(member_tts => "member_tts_show")
.help("Shows whether a member's messages are sent as TTS"),
command!(member_tts, ("value", Toggle) => "member_tts_update")
.help("Sets whether to send a member's messages as text-to-speech messages"),
command!(member_autoproxy => "member_autoproxy_show")
.help("Shows whether a member can be autoproxied"),
command!(member_autoproxy, ("value", Toggle) => "member_autoproxy_update")
.help("Sets whether a member will be autoproxied when autoproxy is set to latch or front mode"),
]
};
let member_avatar_cmd = {
let member_avatar = tokens!(
member_target,
(
"avatar",
["profile", "picture", "icon", "image", "pfp", "pic"]
)
);
[
command!(member_avatar => "member_avatar_show").help("Shows a member's avatar"),
command!(member_avatar, ("avatar", Avatar) => "member_avatar_update")
.help("Changes a member's avatar"),
command!(member_avatar, CLEAR => "member_avatar_clear")
.flag(YES)
.help("Clears a member's avatar"),
]
};
let member_webhook_avatar_cmd = {
let member_webhook_avatar = tokens!(
member_target,
(
"proxyavatar",
[
"proxypfp",
"webhookavatar",
"webhookpfp",
"pa",
"pavatar",
"ppfp"
]
)
);
[
command!(member_webhook_avatar => "member_webhook_avatar_show")
.help("Shows a member's proxy avatar"),
command!(member_webhook_avatar, ("avatar", Avatar) => "member_webhook_avatar_update")
.help("Changes a member's proxy avatar"),
command!(member_webhook_avatar, CLEAR => "member_webhook_avatar_clear")
.flag(YES)
.help("Clears a member's proxy avatar"),
]
};
let member_server_avatar_cmd = {
let member_server_avatar = tokens!(
member_target,
(
"serveravatar",
[
"sa",
"servericon",
"serverimage",
"serverpfp",
"serverpic",
"savatar",
"spic",
"guildavatar",
"guildpic",
"guildicon",
"sicon",
"spfp"
]
)
);
[
command!(member_server_avatar => "member_server_avatar_show")
.help("Shows a member's server-specific avatar"),
command!(member_server_avatar, ("avatar", Avatar) => "member_server_avatar_update")
.help("Changes a member's avatar in the current server"),
command!(member_server_avatar, CLEAR => "member_server_avatar_clear")
.flag(YES)
.help("Clears a member's server-specific avatar"),
]
};
let member_group = tokens!(member_target, ("groups", ["group", "g"]));
let member_list_group_cmds = once(
command!(member_group, Optional(Remainder(("query", OpaqueString))) => "member_groups"),
)
.map(|cmd| cmd.flags(get_list_flags()));
let member_add_remove_group_cmds = [
command!(member_group, "add", Optional(("groups", GroupRefs)) => "member_group_add")
.help("Adds a member to one or more groups"),
command!(member_group, ("remove", ["rem"]), Optional(("groups", GroupRefs)) => "member_group_remove")
.help("Removes a member from one or more groups"),
];
let member_display_id_cmd =
[command!(member_target, "id" => "member_id").help("Prints a member's ID")];
let member_delete_cmd =
[command!(member_target, delete => "member_delete").help("Deletes a member")];
let member_easter_eggs =
[command!(member_target, "soulscream" => "member_soulscream").show_in_suggestions(false)];
member_new_cmd
.chain(member_info_cmd)
.chain(member_name_cmd)
.chain(member_description_cmd)
.chain(member_privacy_cmd)
.chain(member_pronouns_cmd)
.chain(member_banner_cmd)
.chain(member_color_cmd)
.chain(member_birthday_cmd)
.chain(member_display_name_cmd)
.chain(member_server_name_cmd)
.chain(member_proxy_cmd)
.chain(member_avatar_cmd)
.chain(member_webhook_avatar_cmd)
.chain(member_server_avatar_cmd)
.chain(member_proxy_settings_cmd)
.chain(member_message_settings_cmd)
.chain(member_display_id_cmd)
.chain(member_delete_cmd)
.chain(member_easter_eggs)
.chain(member_add_remove_group_cmds)
.chain(member_list_group_cmds)
}

View file

@ -0,0 +1,44 @@
use command_parser::{
parameter::{MESSAGE_LINK, MESSAGE_REF},
token::TokensIterator,
};
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
let message = tokens!(("message", ["msg", "messageinfo"]), Optional(MESSAGE_REF));
let author = ("author", ["sender", "a"]);
let delete = ("delete", ["del", "d"]);
let reproxy = ("reproxy", ["rp", "crimes", "crime"]);
let edit = ("edit", ["e"]);
let new_content_param = Optional(Remainder(("new_content", OpaqueString)));
let edit_short_subcmd = tokens!(Optional(MESSAGE_LINK), new_content_param);
let apply_edit = |cmd: Command| {
cmd.flag(("append", ["a"]))
.flag(("prepend", ["p"]))
.flag(("regex", ["r"]))
.flag(("no-space", ["nospace", "ns"]))
.flag(("clear-embeds", ["clear-embed", "ce"]))
.flag(("clear-attachments", ["clear-attachment", "ca"]))
.help("Edits a previously proxied message")
};
let make_edit_cmd = |tokens: TokensIterator| apply_edit(command!(tokens => "message_edit"));
[
make_edit_cmd(tokens!(edit, edit_short_subcmd)),
// this one always does regex
make_edit_cmd(tokens!("x", edit_short_subcmd)).flag_value("regex", None),
command!(reproxy, Optional(("msg", MESSAGE_REF)), ("member", MemberRef) => "message_reproxy")
.help("Reproxies a previously proxied message with a different member"),
command!(message, author => "message_author").help("Shows the author of a proxied message"),
command!(message, delete => "message_delete").help("Deletes a proxied message"),
make_edit_cmd(tokens!(message, edit, new_content_param)),
command!(message => "message_info")
.flag(delete)
.flag(author)
.help("Shows information about a proxied message"),
]
}

View file

@ -0,0 +1,9 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
[
command!("invite" => "invite").help("Gets a link to invite PluralKit to other servers"),
command!(("stats", ["status"]) => "stats")
.help("Shows statistics and information about PluralKit"),
]
}

View file

@ -0,0 +1,31 @@
use crate::utils::get_list_flags;
use super::*;
pub fn cmds() -> impl Iterator<Item = Command> {
let random = ("random", ["rand", "r"]);
let group = group::group();
let member = member::member();
[
command!(random => "random_self")
.help("Shows the info card of a randomly selected member in your system")
.flag(group),
command!(random, member => "random_self"),
command!(random, group => "random_group_self")
.help("Shows the info card of a randomly selected group in your system"),
command!(random, group::targeted() => "random_group_member_self")
.help("Shows the info card of a randomly selected member in a group in your system")
.flags(get_list_flags()),
command!(system::targeted(), random => "system_random")
.help("Shows the info card of a randomly selected member in a system")
.flag(group),
command!(system::targeted(), random, group => "system_random_group")
.help("Shows the info card of a randomly selected group in a system"),
command!(group::targeted(), random => "group_random_member")
.help("Shows the info card of a randomly selected member in a group")
.flags(get_list_flags()),
]
.into_iter()
.map(|cmd| cmd.flag(ALL))
}

View file

@ -0,0 +1,140 @@
use std::iter::once;
use super::*;
pub fn cmds() -> impl Iterator<Item = Command> {
let server_config = ("serverconfig", ["guildconfig", "scfg", "gcfg"]);
let log = tokens!(server_config, ("log", ["log", "logging"]));
let log_channel = tokens!(log, ("channel", ["ch", "chan"]));
let log_cleanup = tokens!(log, ("cleanup", ["clean"]));
let log_cleanup_short = tokens!(server_config, ("logclean", ["logclean", "logcleanup"]));
let log_blacklist = tokens!(log, ("blacklist", ["bl", "ignore"]));
let proxy = tokens!(server_config, ("proxy", ["proxy", "proxying"]));
let proxy_blacklist = tokens!(proxy, ("blacklist", ["bl", "ignore", "disable"]));
let invalid = tokens!(
server_config,
("invalid", ["invalid", "unknown"]),
("command", ["command", "cmd"]),
("error", ["error", "response"])
);
let invalid_short = tokens!(
server_config,
(
"invalidcommanderror",
["invalidcommanderror", "unknowncommanderror", "ice"]
)
);
let require_tag = tokens!(
server_config,
("require", ["require", "enforce"]),
("tag", ["tag", "systemtag"])
);
let require_tag_short = tokens!(server_config, ("requiretag", ["requiretag", "enforcetag"]));
let suppress = tokens!(
server_config,
("suppress", ["suppress"]),
("notifications", ["notifications", "notifs"])
);
let suppress_short = tokens!(server_config, ("proxysilent", ["proxysilent", "silent"]));
// Common tokens for add/remove operations
let add = ("add", ["enable", "on", "deny"]);
let remove = ("remove", ["disable", "off", "allow"]);
let log_channel_cmds = [
command!(log_channel => "server_config_log_channel_show")
.help("Shows the current log channel"),
command!(log_channel, ("channel", ChannelRef) => "server_config_log_channel_set")
.help("Designates a channel to post proxied messages to"),
command!(log_channel, CLEAR => "server_config_log_channel_clear")
.flag(YES)
.help("Clears the log channel"),
];
let log_cleanup_cmds = [
command!(log_cleanup => "server_config_log_cleanup_show")
.help("Shows whether log cleanup is enabled"),
command!(log_cleanup, Toggle => "server_config_log_cleanup_set")
.help("Toggles whether to clean up other bots' log channels"),
command!(log_cleanup_short => "server_config_log_cleanup_show")
.help("Shows whether log cleanup is enabled"),
command!(log_cleanup_short, Toggle => "server_config_log_cleanup_set")
.help("Toggles whether to clean up other bots' log channels"),
];
let log_blacklist_cmds = [
command!(log_blacklist => "server_config_log_blacklist_show")
.help("Shows channels where logging is disabled"),
command!(log_blacklist, add, Optional(("channel", ChannelRef)) => "server_config_log_blacklist_add")
.flag(ALL)
.help("Disables message logging in certain channels"),
command!(log_blacklist, remove, Optional(("channel", ChannelRef)) => "server_config_log_blacklist_remove")
.flag(ALL)
.help("Enables message logging in certain channels"),
];
let proxy_blacklist_cmds = [
command!(proxy_blacklist => "server_config_proxy_blacklist_show")
.help("Shows channels where proxying is disabled"),
command!(proxy_blacklist, add, Optional(("channel", ChannelRef)) => "server_config_proxy_blacklist_add")
.flag(ALL)
.help("Disables message proxying in certain channels"),
command!(proxy_blacklist, remove, Optional(("channel", ChannelRef)) => "server_config_proxy_blacklist_remove")
.flag(ALL)
.help("Enables message proxying in certain channels"),
];
let invalid_cmds = [
command!(invalid => "server_config_invalid_command_response_show")
.help("Shows whether error responses for invalid commands are enabled"),
command!(invalid, Toggle => "server_config_invalid_command_response_set")
.help("Sets whether to show an error message when an unknown command is sent"),
command!(invalid_short => "server_config_invalid_command_response_show")
.help("Shows whether error responses for invalid commands are enabled"),
command!(invalid_short, Toggle => "server_config_invalid_command_response_set")
.help("Sets whether to show an error message when an unknown command is sent"),
];
let require_tag_cmds = [
command!(require_tag => "server_config_require_system_tag_show")
.help("Shows whether system tags are required"),
command!(require_tag, Toggle => "server_config_require_system_tag_set").help(
"Sets whether server users are required to have a system tag on proxied messages",
),
command!(require_tag_short => "server_config_require_system_tag_show")
.help("Shows whether system tags are required"),
command!(require_tag_short, Toggle => "server_config_require_system_tag_set").help(
"Sets whether server users are required to have a system tag on proxied messages",
),
];
let suppress_cmds = [
command!(suppress => "server_config_suppress_notifications_show")
.help("Shows whether notifications are suppressed for proxied messages"),
command!(suppress, Toggle => "server_config_suppress_notifications_set")
.help("Sets whether all proxied messages will have notifications suppressed (sent as `@silent` messages)"),
command!(suppress_short => "server_config_suppress_notifications_show")
.help("Shows whether notifications are suppressed for proxied messages"),
command!(suppress_short, Toggle => "server_config_suppress_notifications_set")
.help("Sets whether all proxied messages will have notifications suppressed (sent as `@silent` messages)"),
];
let main_cmd = once(
command!(server_config => "server_config_show")
.help("Shows the current server configuration"),
);
main_cmd
.chain(log_channel_cmds)
.chain(log_cleanup_cmds)
.chain(log_blacklist_cmds)
.chain(proxy_blacklist_cmds)
.chain(invalid_cmds)
.chain(require_tag_cmds)
.chain(suppress_cmds)
}

View file

@ -0,0 +1,42 @@
use super::*;
pub fn cmds() -> impl IntoIterator<Item = Command> {
let switch = ("switch", ["sw"]);
let edit = ("edit", ["e", "replace"]);
let r#move = ("move", ["m", "shift", "offset"]);
let delete = ("delete", ["remove", "erase", "cancel", "yeet"]);
let copy = ("copy", ["add", "duplicate", "dupe"]);
let out = "out";
let edit_flags = [
("first", ["f"]),
("remove", ["r"]),
("append", ["a"]),
("prepend", ["p"]),
];
[
command!(switch, ("commands", ["help"]) => "switch_commands")
.help("Shows help for switch commands"),
command!(switch, out => "switch_out").help("Registers a switch with no members"),
command!(switch, delete => "switch_delete")
.flag(YES)
.help("Deletes the latest switch")
.flag(("all", ["clear", "c"])),
command!(switch, r#move, Remainder(OpaqueString) => "switch_move")
.flag(YES)
.help("Moves the latest switch in time"), // TODO: datetime parsing
command!(switch, edit, out => "switch_edit_out")
.help("Turns the latest switch into a switch-out")
.flag(YES),
command!(switch, edit, Optional(MemberRefs) => "switch_edit")
.flag(YES)
.help("Edits the members in the latest switch")
.flags(edit_flags),
command!(switch, copy, Optional(MemberRefs) => "switch_copy")
.help("Makes a new switch with the listed members added")
.flags(edit_flags),
command!(switch, MemberRefs => "switch_do").help("Registers a switch"),
]
}

View file

@ -0,0 +1,315 @@
use std::iter::once;
use command_parser::token::TokensIterator;
use crate::utils::get_list_flags;
use super::*;
pub fn cmds() -> impl Iterator<Item = Command> {
edit()
}
pub fn system() -> (&'static str, [&'static str; 1]) {
("system", ["s"])
}
pub fn targeted() -> TokensIterator {
tokens!(system(), SystemRef)
}
pub fn edit() -> impl Iterator<Item = Command> {
let system = system();
let system_new_cmd =
once(
command!(system, ("new", ["n"]), Optional(Remainder(("name", OpaqueString))) => "system_new")
.help("Creates a new system")
);
let system_webhook = tokens!(system, ("webhook", ["hook"]));
let system_webhook_cmd = [
command!(system_webhook => "system_webhook_show").help("Shows your system's webhook URL"),
command!(system_webhook, CLEAR => "system_webhook_clear")
.flag(YES)
.help("Clears your system's webhook URL"),
command!(system_webhook, ("url", OpaqueString) => "system_webhook_set")
.help("Sets your system's webhook URL"),
];
let add_info_flags = |cmd: Command| {
cmd.flag(("public", ["pub"]))
.flag(("private", ["priv"]))
.flag(ALL)
};
let system_info_cmd = [
command!(system, Optional(SystemRef) => "system_info")
.help("Shows information about your system"),
command!(system, Optional(SystemRef), ("info", ["show", "view"]) => "system_info")
.help("Shows information about your system"),
]
.into_iter()
.map(add_info_flags);
let name = "name";
let system_name_cmd = once(
command!(system, Optional(SystemRef), name => "system_show_name")
.help("Shows the systems name"),
);
let system_name_self = tokens!(system, ("rename", [name]));
let system_name_self_cmd = [
command!(system_name_self, CLEAR => "system_clear_name")
.flag(YES)
.help("Clears your system's name"),
command!(system_name_self, Remainder(("name", OpaqueString)) => "system_rename")
.help("Renames your system"),
];
let server_name = ("servername", ["sn", "guildname"]);
let system_server_name_cmd = once(
command!(system, Optional(SystemRef), server_name => "system_show_server_name")
.help("Shows the system's server name"),
);
let system_server_name_self = tokens!(system, server_name);
let system_server_name_self_cmd = [
command!(system_server_name_self, CLEAR => "system_clear_server_name")
.flag(YES)
.help("Clears your system's server name"),
command!(system_server_name_self, Remainder(("name", OpaqueString)) => "system_rename_server_name")
.help("Renames your system's server name"),
];
let description = ("description", ["desc", "d"]);
let system_description_cmd = once(
command!(system, Optional(SystemRef), description => "system_show_description")
.help("Shows the system's description"),
);
let system_description_self = tokens!(system, description);
let system_description_self_cmd = [
command!(system_description_self, CLEAR => "system_clear_description")
.flag(YES)
.help("Clears your system's description"),
command!(system_description_self, Remainder(("description", OpaqueString)) => "system_change_description")
.help("Changes your system's description"),
];
let color = ("color", ["colour"]);
let system_color_cmd = once(
command!(system, Optional(SystemRef), color => "system_show_color")
.help("Shows the system's color"),
);
let system_color_self = tokens!(system, color);
let system_color_self_cmd = [
command!(system_color_self, CLEAR => "system_clear_color")
.flag(YES)
.help("Clears your system's color"),
command!(system_color_self, ("color", OpaqueString) => "system_change_color")
.help("Changes your system's color"),
];
let tag = ("tag", ["suffix"]);
let system_tag_cmd = once(
command!(system, Optional(SystemRef), tag => "system_show_tag")
.help("Shows the system's tag"),
);
let system_tag_self = tokens!(system, tag);
let system_tag_self_cmd = [
command!(system_tag_self, CLEAR => "system_clear_tag")
.flag(YES)
.help("Clears your system's tag"),
command!(system_tag_self, Remainder(("tag", OpaqueString)) => "system_change_tag")
.help("Changes your system's tag"),
];
let servertag = ("servertag", ["st", "guildtag", "stag", "deer"]);
let system_server_tag_cmd = once(
command!(system, Optional(SystemRef), servertag => "system_show_server_tag")
.help("Shows the system's server tag"),
);
let system_server_tag_self = tokens!(system, servertag);
let system_server_tag_self_cmd = [
command!(system_server_tag_self, CLEAR => "system_clear_server_tag")
.flag(YES)
.help("Clears your system's server tag"),
command!(system_server_tag_self, Remainder(("tag", OpaqueString)) => "system_change_server_tag")
.help("Changes your system's server tag"),
];
let pronouns = ("pronouns", ["prns"]);
let system_pronouns_cmd = once(
command!(system, Optional(SystemRef), pronouns => "system_show_pronouns")
.help("Shows the system's pronouns"),
);
let system_pronouns_self = tokens!(system, pronouns);
let system_pronouns_self_cmd = [
command!(system_pronouns_self, CLEAR => "system_clear_pronouns")
.flag(YES)
.help("Clears your system's pronouns"),
command!(system_pronouns_self, Remainder(("pronouns", OpaqueString)) => "system_change_pronouns")
.help("Changes your system's pronouns"),
];
let avatar = ("avatar", ["pfp"]);
let system_avatar_cmd = once(
command!(system, Optional(SystemRef), avatar => "system_show_avatar")
.help("Shows the system's avatar"),
);
let system_avatar_self = tokens!(system, avatar);
let system_avatar_self_cmd = [
command!(system_avatar_self, CLEAR => "system_clear_avatar")
.flag(YES)
.help("Clears your system's avatar"),
command!(system_avatar_self, ("avatar", Avatar) => "system_change_avatar")
.help("Changes your system's avatar"),
];
let serveravatar = ("serveravatar", ["spfp"]);
let system_server_avatar_cmd = once(
command!(system, Optional(SystemRef), serveravatar => "system_show_server_avatar")
.help("Shows the system's server avatar"),
);
let system_server_avatar_self = tokens!(system, serveravatar);
let system_server_avatar_self_cmd = [
command!(system_server_avatar_self, CLEAR => "system_clear_server_avatar")
.flag(YES)
.help("Clears your system's server avatar"),
command!(system_server_avatar_self, ("avatar", Avatar) => "system_change_server_avatar")
.help("Changes your system's server avatar"),
];
let banner = ("banner", ["cover"]);
let system_banner_cmd = once(
command!(system, Optional(SystemRef), banner => "system_show_banner")
.help("Shows the system's banner"),
);
let system_banner_self = tokens!(system, banner);
let system_banner_self_cmd = [
command!(system_banner_self, CLEAR => "system_clear_banner")
.flag(YES)
.help("Clears your system's banner"),
command!(system_banner_self, ("banner", Avatar) => "system_change_banner")
.help("Changes your system's banner"),
];
let system_proxy = tokens!(system, "proxy");
let system_proxy_cmd = [
command!(system_proxy => "system_show_proxy_current")
.help("Shows your system's proxy setting for the guild you are in"),
command!(system_proxy, Skip(Toggle) => "system_toggle_proxy_current")
.help("Toggle your system's proxy for the guild you are in"),
command!(system_proxy, GuildRef => "system_show_proxy")
.help("Shows your system's proxy setting for a guild"),
command!(system_proxy, GuildRef, Toggle => "system_toggle_proxy")
.help("Toggle your system's proxy for a guild"),
];
let system_privacy = tokens!(system, ("privacy", ["priv"]));
let system_privacy_cmd = [
command!(system_privacy => "system_show_privacy")
.help("Shows your system's privacy settings"),
command!(system_privacy, ALL, ("level", PrivacyLevel) => "system_change_privacy_all")
.help("Changes all privacy settings for your system"),
command!(system_privacy, ("privacy", SystemPrivacyTarget), ("level", PrivacyLevel) => "system_change_privacy")
.help("Changes a specific privacy setting for your system"),
];
let front = ("front", ["fronter", "fronters", "f"]);
let make_front_history = |subcmd: TokensIterator| {
command!(system, Optional(SystemRef), subcmd => "system_fronter_history")
.help("Shows a system's front history")
.flag(CLEAR)
};
let make_front_percent = |subcmd: TokensIterator| {
command!(system, Optional(SystemRef), subcmd => "system_fronter_percent")
.help("Shows a system's front breakdown")
.flag(("duration", OpaqueString))
.flag(("fronters-only", ["fo"]))
.flag("flat")
};
let system_front_cmd = [
command!(system, Optional(SystemRef), front => "system_fronter")
.help("Shows a system's fronter(s)"),
make_front_history(tokens!(front, ("history", ["h"]))),
make_front_history(tokens!(("fronthistory", ["fh", "history", "switches"]))),
make_front_percent(tokens!(front, ("percent", ["p", "%"]))),
make_front_percent(tokens!((
"frontpercent",
["fp", "front%", "frontbreakdown"]
))),
];
let search_param = Optional(Remainder(("query", OpaqueString)));
let apply_list_opts = |cmd: Command| cmd.flags(get_list_flags());
let members_subcmd = tokens!(
(
"members",
["l", "ls", "list", "find", "search", "query", "fd"]
),
search_param
);
let system_members_cmd = [
command!(system, Optional(SystemRef), members_subcmd => "system_members")
.help("Lists a system's members"),
command!(members_subcmd => "system_members").help("Lists your system's members"),
]
.map(apply_list_opts);
let system_groups_cmd = once(
command!(system, Optional(SystemRef), ("groups", ["g"]), search_param => "system_groups")
.help("Lists groups in a system"),
)
.map(apply_list_opts);
let system_display_id_cmd = once(
command!(system, Optional(SystemRef), "id" => "system_display_id")
.help("Prints a system's ID"),
);
let system_delete = once(
command!(system, ("delete", ["erase", "remove", "yeet"]) => "system_delete")
.flag(("no-export", ["ne"]))
.help("Deletes the system"),
);
let system_link = [
command!("link", ("account", UserRef) => "system_link")
.flag(YES)
.help("Links another Discord account to your system"),
command!("unlink", ("account", OpaqueString) => "system_unlink")
.help("Unlinks a Discord account from your system")
.flag(YES),
];
system_new_cmd
.chain(system_webhook_cmd)
.chain(system_name_self_cmd)
.chain(system_server_name_self_cmd)
.chain(system_description_self_cmd)
.chain(system_color_self_cmd)
.chain(system_tag_self_cmd)
.chain(system_server_tag_self_cmd)
.chain(system_pronouns_self_cmd)
.chain(system_avatar_self_cmd)
.chain(system_server_avatar_self_cmd)
.chain(system_banner_self_cmd)
.chain(system_delete)
.chain(system_privacy_cmd)
.chain(system_proxy_cmd)
.chain(system_name_cmd)
.chain(system_server_name_cmd)
.chain(system_description_cmd)
.chain(system_color_cmd)
.chain(system_tag_cmd)
.chain(system_server_tag_cmd)
.chain(system_pronouns_cmd)
.chain(system_avatar_cmd)
.chain(system_server_avatar_cmd)
.chain(system_banner_cmd)
.chain(system_info_cmd)
.chain(system_front_cmd)
.chain(system_link)
.chain(system_members_cmd)
.chain(system_groups_cmd)
.chain(system_display_id_cmd)
}

View file

@ -0,0 +1,54 @@
use command_parser::flag::Flag;
use crate::ALL;
pub fn get_list_flags() -> [Flag; 22] {
[
// Short or long list
Flag::from(("full", ["f", "big", "details", "long"])),
// Search description
Flag::from((
"search-description",
[
"filter-description",
"in-description",
"sd",
"description",
"desc",
],
)),
// Sort properties
Flag::from(("by-name", ["bn"])),
Flag::from(("by-display-name", ["by-displayname", "bdn"])),
Flag::from(("by-id", ["bid"])),
Flag::from(("by-message-count", ["bmc"])),
Flag::from(("by-created", ["bc", "bcd"])),
Flag::from((
"by-last-fronted",
["by-last-front", "by-last-switch", "blf", "bls"],
)),
Flag::from(("by-last-message", ["blm", "blp"])),
Flag::from(("by-birthday", ["by-birthdate", "bbd"])),
Flag::from(("random", ["rand"])),
// Sort reverse
Flag::from(("reverse", ["r", "rev"])),
// Privacy filter
Flag::from(ALL),
Flag::from(("private-only", ["po"])),
// Additional fields to include
Flag::from((
"with-last-switch",
["with-last-fronted", "with-last-front", "wls", "wlf"],
)),
Flag::from(("with-last-message", ["with-last-proxy", "wlm", "wlp"])),
Flag::from(("with-message-count", ["wmc"])),
Flag::from(("with-created", ["wc"])),
Flag::from((
"with-avatar",
["with-image", "with-icon", "wa", "wi", "ia", "ii", "img"],
)),
Flag::from(("with-pronouns", ["wp", "wprns"])),
Flag::from(("with-display-name", ["with-displayname", "wdn"])),
Flag::from(("with-birthday", ["wbd", "wb"])),
]
}

View file

@ -0,0 +1,12 @@
[package]
name = "command_parser"
version = "0.1.0"
edition = "2024"
[dependencies]
lazy_static = { workspace = true }
smol_str = "0.3.2"
ordermap = "0.5"
regex = "1"
strsim = "0.11"
log = "0.4"

View file

@ -0,0 +1,162 @@
use std::{
collections::{HashMap, HashSet},
fmt::{Debug, Display},
sync::Arc,
};
use smol_str::SmolStr;
use crate::{flag::Flag, parameter::ParameterValue, token::Token};
#[derive(Debug, Clone)]
pub struct Command {
// TODO: fix hygiene
pub tokens: Vec<Token>,
pub flags: HashSet<Flag>,
pub help: SmolStr,
pub cb: SmolStr,
pub show_in_suggestions: bool,
pub parse_flags_before: usize,
pub hidden_flags: HashSet<SmolStr>,
pub flag_values: HashMap<SmolStr, Option<ParameterValue>>,
pub original: Option<Arc<Command>>,
}
impl Command {
pub fn new(tokens: impl IntoIterator<Item = Token>, cb: impl Into<SmolStr>) -> Self {
let tokens = tokens.into_iter().collect::<Vec<_>>();
assert!(tokens.len() > 0);
// figure out which token to parse / put flags after
// (by default, put flags after the last token)
let mut parse_flags_before = tokens.len();
for (idx, token) in tokens.iter().enumerate().rev() {
match token {
// we want flags to go before any parameters
Token::Parameter(_) => parse_flags_before = idx,
Token::Value { .. } => break,
}
}
Self {
flags: HashSet::new(),
help: SmolStr::new_static("<no help text>"),
cb: cb.into(),
show_in_suggestions: true,
parse_flags_before,
tokens,
hidden_flags: HashSet::new(),
flag_values: HashMap::new(),
original: None,
}
}
pub fn help(mut self, v: impl Into<SmolStr>) -> Self {
self.help = v.into();
self
}
pub fn show_in_suggestions(mut self, v: bool) -> Self {
self.show_in_suggestions = v;
self
}
pub fn flags(mut self, flags: impl IntoIterator<Item = impl Into<Flag>>) -> Self {
self.flags.extend(flags.into_iter().map(Into::into));
self
}
pub fn flag(mut self, flag: impl Into<Flag>) -> Self {
self.flags.insert(flag.into());
self
}
pub fn hidden_flag(mut self, flag: impl Into<Flag>) -> Self {
let flag = flag.into();
self.hidden_flags.insert(flag.get_name().into());
self.flags.insert(flag);
self
}
pub fn flag_value(
mut self,
flag_name: impl Into<SmolStr>,
value: impl Into<Option<ParameterValue>>,
) -> Self {
self.flag_values.insert(flag_name.into(), value.into());
self
}
}
impl PartialEq for Command {
fn eq(&self, other: &Self) -> bool {
self.cb == other.cb
}
}
impl Eq for Command {}
impl std::hash::Hash for Command {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.cb.hash(state);
}
}
impl Display for Command {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let visible_flags = self
.flags
.iter()
.filter(|flag| !self.hidden_flags.contains(flag.get_name()))
.collect::<Vec<_>>();
let write_flags = |f: &mut std::fmt::Formatter<'_>, space: bool| {
if visible_flags.is_empty() {
return Ok(());
}
write!(f, "{}(", space.then_some(" ").unwrap_or(""))?;
let mut written = 0;
let max_flags = visible_flags.len().min(5);
for flag in &visible_flags {
if written > max_flags {
break;
}
write!(f, "{flag}")?;
if max_flags > written {
write!(f, " ")?;
}
written += 1;
}
if visible_flags.len() > written {
let rest_count = visible_flags.len() - written;
write!(
f,
" ...and {rest_count} flag{}...",
(rest_count > 1).then_some("s").unwrap_or(""),
)?;
}
write!(f, "){}", space.then_some("").unwrap_or(" "))
};
for (idx, token) in self.tokens.iter().enumerate() {
if idx == self.parse_flags_before {
write_flags(f, false)?;
}
write!(
f,
"{token}{}",
(idx < self.tokens.len() - 1).then_some(" ").unwrap_or("")
)?;
}
if self.tokens.len() == self.parse_flags_before {
write_flags(f, true)?;
}
Ok(())
}
}
// a macro is required because generic cant be different types at the same time (which means you couldnt have ["member", MemberRef, "subcmd"] etc)
// (and something like &dyn Trait would require everything to be referenced which doesnt look nice anyway)
#[macro_export]
macro_rules! command {
($($v:expr),+ => $cb:expr$(,)*) => {
$crate::command::Command::new($crate::tokens!($($v),+), $cb)
};
}

View file

@ -0,0 +1,137 @@
use std::{fmt::Display, hash::Hash};
use smol_str::SmolStr;
use crate::parameter::{Parameter, ParameterKind, ParameterValue};
#[derive(Debug)]
pub enum FlagValueMatchError {
ValueMissing,
InvalidValue { raw: SmolStr, msg: SmolStr },
}
#[derive(Debug, Clone)]
pub struct Flag {
name: SmolStr,
aliases: Vec<SmolStr>,
value: Option<Parameter>,
}
impl Display for Flag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "-{}", self.name)?;
if let Some(value) = self.value.as_ref() {
write!(f, "=")?;
value.fmt(f)?;
}
Ok(())
}
}
impl PartialEq for Flag {
fn eq(&self, other: &Self) -> bool {
self.name.eq(&other.name)
}
}
impl Eq for Flag {}
impl Hash for Flag {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.name.hash(state);
}
}
#[derive(Debug)]
pub enum FlagMatchError {
ValueMatchFailed(FlagValueMatchError),
}
type TryMatchFlagResult = Option<Result<Option<ParameterValue>, FlagMatchError>>;
impl Flag {
pub fn new(name: impl Into<SmolStr>) -> Self {
Self {
name: name.into(),
aliases: Vec::new(),
value: None,
}
}
pub fn value(mut self, param: impl Into<Parameter>) -> Self {
self.value = Some(param.into());
self
}
pub fn alias(mut self, alias: impl Into<SmolStr>) -> Self {
self.aliases.push(alias.into());
self
}
pub fn get_name(&self) -> &str {
&self.name
}
pub fn get_value(&self) -> Option<&Parameter> {
self.value.as_ref()
}
pub fn get_aliases(&self) -> impl Iterator<Item = &str> {
self.aliases.iter().map(|s| s.as_str())
}
pub fn try_match(&self, input_name: &str, input_value: Option<&str>) -> TryMatchFlagResult {
// if not matching the name or any aliases then skip anymore matching
if self.name != input_name && self.get_aliases().all(|s| s.ne(input_name)) {
return None;
}
// get token to try matching with, if flag doesn't have one then that means it is matched (it is without any value)
let Some(value) = self.value.as_ref() else {
return Some(Ok(None));
};
// check if we have a non-empty flag value, we return error if not (because flag requested a value)
let Some(input_value) = input_value else {
return Some(Err(FlagMatchError::ValueMatchFailed(
FlagValueMatchError::ValueMissing,
)));
};
// try matching the value
match value.match_value(input_value) {
Ok(param) => Some(Ok(Some(param))),
Err(err) => Some(Err(FlagMatchError::ValueMatchFailed(
FlagValueMatchError::InvalidValue {
raw: input_value.into(),
msg: err,
},
))),
}
}
}
impl From<&str> for Flag {
fn from(name: &str) -> Self {
Flag::new(name)
}
}
impl From<(&str, ParameterKind)> for Flag {
fn from((name, value): (&str, ParameterKind)) -> Self {
Flag::new(name).value(value)
}
}
impl<const L: usize> From<(&str, [&str; L])> for Flag {
fn from((name, aliases): (&str, [&str; L])) -> Self {
let mut flag = Flag::new(name);
for alias in aliases {
flag = flag.alias(alias);
}
flag
}
}
impl<const L: usize> From<((&str, [&str; L]), ParameterKind)> for Flag {
fn from(((name, aliases), value): ((&str, [&str; L]), ParameterKind)) -> Self {
Flag::from((name, aliases)).value(value)
}
}

View file

@ -0,0 +1,566 @@
#![feature(anonymous_lifetime_in_impl_trait)]
#![feature(round_char_boundary)]
#![feature(iter_intersperse)]
use std::sync::Arc;
pub mod command;
pub mod flag;
pub mod parameter;
mod string;
pub mod token;
pub mod tree;
use core::panic;
use std::fmt::Write;
use std::ops::Not;
use std::{collections::HashMap, usize};
use command::Command;
use flag::{Flag, FlagMatchError, FlagValueMatchError};
use log::debug;
use parameter::ParameterValue;
use smol_str::SmolStr;
use string::MatchedFlag;
use token::{Token, TokenMatchResult};
// todo: this should come from the bot probably
const MAX_SUGGESTIONS: usize = 5;
pub type Tree = tree::TreeBranch;
#[derive(Debug)]
pub struct ParsedCommand {
pub command_def: Arc<Command>,
pub parameters: HashMap<String, ParameterValue>,
pub flags: HashMap<String, Option<ParameterValue>>,
}
#[derive(Clone, Debug)]
struct MatchedTokenState {
tree: Arc<Tree>,
token: Token,
match_result: TokenMatchResult,
start_pos: usize,
filtered_tokens: Vec<Token>,
}
pub fn parse_command(
command_tree: impl Into<Arc<Tree>>,
prefix: String,
input: String,
) -> Result<ParsedCommand, String> {
let input: SmolStr = input.into();
let mut local_tree = command_tree.into();
// end position of all currently matched tokens
let mut current_pos: usize = 0;
let mut current_token_idx: usize = 0;
let mut raw_flags: Vec<(usize, MatchedFlag)> = Vec::new();
let mut matched_tokens: Vec<MatchedTokenState> = Vec::new();
let mut filtered_tokens: Vec<Token> = Vec::new(); // these are tokens that we've already tried (and failed)
let mut last_optional_param_error: Option<(SmolStr, SmolStr)> = None;
// track the best attempt at parsing (deepest matched tokens)
// so we can use it for error messages/suggestions even if we backtrack later
let mut best_attempt: Option<(Arc<Tree>, Vec<MatchedTokenState>, usize)> = None;
loop {
let mut possible_tokens = local_tree
.possible_tokens()
.filter(|t| !filtered_tokens.contains(t))
.collect::<Vec<_>>();
// sort so parameters come last
// we always want to test values first
// parameters that parse the remainder come last (otherwise they would always match)
possible_tokens.sort_by(|a, b| match (a, b) {
(Token::Parameter(param), _) if param.is_remainder() => std::cmp::Ordering::Greater,
(_, Token::Parameter(param)) if param.is_remainder() => std::cmp::Ordering::Less,
(Token::Parameter(_), Token::Parameter(_)) => std::cmp::Ordering::Equal,
(Token::Parameter(_), _) => std::cmp::Ordering::Greater,
(_, Token::Parameter(_)) => std::cmp::Ordering::Less,
_ => std::cmp::Ordering::Equal,
});
debug!("possible: {:?}", possible_tokens);
let next = next_token(possible_tokens.iter().cloned(), &input, current_pos);
debug!("next: {:?}", next);
match &next {
Some((found_token, result, new_pos)) => {
match &result {
// todo: better error messages for these?
TokenMatchResult::MissingParameter { name } => {
return Err(format!(
"Expected parameter `{name}` in command `{prefix}{input} {found_token}`."
));
}
TokenMatchResult::ParameterMatchError { input: raw, msg } => {
// we can try other branches if the parameter is optional or skip-on-error parameter
if matches!(found_token, Token::Parameter(param) if param.is_optional() || param.is_skip())
&& possible_tokens.len() > 1
{
// save error for later, will be used if no other tokens match
last_optional_param_error = Some((raw.clone(), msg.clone()));
// try the other branches first
filtered_tokens.push(found_token.clone());
continue;
}
return Err(format!(
"Parameter `{raw}` in command `{prefix}{input}` could not be parsed: {msg}."
));
}
// don't use a catch-all here, we want to make sure compiler errors when new errors are added
TokenMatchResult::MatchedParameter { .. } | TokenMatchResult::MatchedValue => {
// clear the error since we successfully matched forward, we dont need it anymore
last_optional_param_error = None;
}
}
if let TokenMatchResult::MatchedParameter { .. } = result {
// we don't add params here, but wait until we matched a full command
// then we can use matched_tokens to extract the params
// this is so we don't have to keep track of "params" when trying branches
}
// move to the next branch
if let Some(next_tree) = local_tree.get_branch(&found_token) {
matched_tokens.push(MatchedTokenState {
tree: local_tree.clone(),
token: found_token.clone(),
match_result: result.clone(),
start_pos: current_pos,
filtered_tokens: filtered_tokens.clone(),
});
// update best attempt if we're deeper
if best_attempt.as_ref().map(|x| x.1.len()).unwrap_or(0) < matched_tokens.len()
{
best_attempt = Some((next_tree.clone(), matched_tokens.clone(), *new_pos));
}
filtered_tokens.clear(); // new branch, new tokens
local_tree = next_tree.clone();
} else {
panic!("found token {found_token:?} could not match tree, at {input}");
}
// advance our position on the input
current_pos = *new_pos;
current_token_idx += 1;
}
None => {
// redo the previous branches if we didnt match on a parameter
// this is a bit of a hack, but its necessary for making parameters on the same depth work
if let Some(state) = matched_tokens
.pop()
.and_then(|m| matches!(m.token, Token::Parameter(_)).then_some(m))
{
debug!("redoing previous branch: {:?}", state.token);
local_tree = state.tree;
current_pos = state.start_pos; // reset position to previous branch's start
filtered_tokens = state.filtered_tokens; // reset filtered tokens to the previous branch's
filtered_tokens.push(state.token);
continue;
}
if let Some((raw, msg)) = last_optional_param_error {
return Err(format!(
"Parameter `{raw}` in command `{prefix}{input}` could not be parsed: {msg}."
));
}
// restore best attempt if it's deeper than current state
// this helps when we backtracked out of the correct path because of a later error
if let Some((best_tree, best_matched, best_pos)) = best_attempt {
if best_matched.len() > matched_tokens.len() {
local_tree = best_tree;
matched_tokens = best_matched;
current_pos = best_pos;
}
}
let mut error = format!("Unknown command `{prefix}{input}`.");
// normalize input by replacing parameters with placeholders
let mut normalized_input = String::new();
for state in &matched_tokens {
write!(&mut normalized_input, "{} ", state.token).unwrap();
}
normalized_input.push_str(&input[current_pos..].trim_start());
let input_tokens = input.split_whitespace().collect::<Vec<_>>();
let mut possible_commands = rank_possible_commands(
&normalized_input,
local_tree.possible_commands(usize::MAX),
&input_tokens,
);
// checks if we matched a parameter last
// if we did, we might have matched a parameter "by accident" (ie. `pk;s renam` matched `s <system>`)
// so we also want to suggest commands from the *previous* branch
if let Some(state) = matched_tokens.last()
&& matches!(state.token, Token::Parameter(_))
{
let mut parent_input = String::new();
// recreate input string up to the parameter
for parent_state in matched_tokens.iter().take(matched_tokens.len() - 1) {
write!(&mut parent_input, "{} ", parent_state.token).unwrap();
}
// assume the user intended to type a command here, so we use the raw input
// (eg. `s renam` -> `s renam`)
parent_input.push_str(&input[state.start_pos..].trim_start());
let input_tokens = parent_input.split_whitespace().collect::<Vec<_>>();
let parent_commands = rank_possible_commands(
&parent_input,
state.tree.possible_commands(usize::MAX),
&input_tokens,
);
possible_commands.extend(parent_commands);
// re-deduplicate
possible_commands.dedup_by(|a, b| {
let cmd_a = a.0.original.as_deref().unwrap_or(&a.0);
let cmd_b = b.0.original.as_deref().unwrap_or(&b.0);
cmd_a == cmd_b
});
// re-sort after extending
possible_commands
.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
}
if possible_commands.is_empty().not() {
error.push_str(" Perhaps you meant one of the following commands:\n");
fmt_commands_list(&mut error, &prefix, possible_commands);
} else {
// add a space between the unknown command and "for a list of all possible commands"
// message if we didn't add any possible suggestions
error.push_str(" ");
}
error.push_str(
"For a list of all possible commands, see <https://pluralkit.me/commands>.",
);
// todo: check if last token is a common incorrect unquote (multi-member names etc)
// todo: check if this is a system name in pk;s command
return Err(error);
}
}
// match flags until there are none left
while let Some(matched_flag) = string::next_flag(&input, current_pos) {
current_pos = matched_flag.next_pos;
debug!("flag matched {matched_flag:?}");
raw_flags.push((current_token_idx, matched_flag));
}
// if we have a command, stop parsing and return it (only if there is no remaining input)
if current_pos >= input.len()
&& let Some(command) = local_tree.command()
{
// match the flags against this commands flags
let mut flags: HashMap<String, Option<ParameterValue>> = HashMap::new();
let mut misplaced_flags: Vec<MatchedFlag> = Vec::new();
let mut invalid_flags: Vec<MatchedFlag> = Vec::new();
let mut params: HashMap<String, ParameterValue> = HashMap::new();
for state in &matched_tokens {
if let TokenMatchResult::MatchedParameter { name, value } = &state.match_result {
params.insert(name.to_string(), value.clone());
}
}
for (token_idx, raw_flag) in raw_flags {
let Some(matched_flag) = match_flag(command.flags.iter(), raw_flag.clone()) else {
invalid_flags.push(raw_flag);
continue;
};
if token_idx != command.parse_flags_before {
misplaced_flags.push(raw_flag);
continue;
}
match matched_flag {
// a flag was matched
Ok((name, value)) => {
flags.insert(name.into(), value);
}
Err((flag, err)) => {
let error = match err {
FlagMatchError::ValueMatchFailed(FlagValueMatchError::ValueMissing) => {
format!(
"Flag `-{name}` in command `{prefix}{input}` is missing a value, try passing `{flag}`.",
name = flag.get_name()
)
}
FlagMatchError::ValueMatchFailed(
FlagValueMatchError::InvalidValue { msg, raw },
) => {
format!(
"Flag `-{name}` in command `{prefix}{input}` has a value (`{raw}`) that could not be parsed: {msg}.",
name = flag.get_name()
)
}
};
return Err(error);
}
}
}
let full_cmd = command.original.as_ref().unwrap_or(&command);
if misplaced_flags.is_empty().not() {
let mut error = format!(
"Flag{} ",
(misplaced_flags.len() > 1).then_some("s").unwrap_or("")
);
for (idx, matched_flag) in misplaced_flags.iter().enumerate() {
write!(&mut error, "`-{}`", matched_flag.name).expect("oom");
if idx < misplaced_flags.len() - 1 {
error.push_str(", ");
}
}
write!(
&mut error,
" in command `{prefix}{input}` {} misplaced. Try reordering to match the command usage `{prefix}{command}`.",
(misplaced_flags.len() > 1).then_some("are").unwrap_or("is"),
command = full_cmd
).expect("oom");
return Err(error);
}
if invalid_flags.is_empty().not() {
let mut error = format!(
"Flag{} ",
(invalid_flags.len() > 1).then_some("s").unwrap_or("")
);
for (idx, matched_flag) in invalid_flags.iter().enumerate() {
write!(&mut error, "`-{}`", matched_flag.name).expect("oom");
if idx < invalid_flags.len() - 1 {
error.push_str(", ");
}
}
write!(
&mut error,
" {} seem to be applicable in this command (`{prefix}{command}`).",
(invalid_flags.len() > 1)
.then_some("don't")
.unwrap_or("doesn't"),
command = full_cmd
)
.expect("oom");
return Err(error);
}
for (name, value) in &full_cmd.flag_values {
flags.insert(name.to_string(), value.clone());
}
debug!("{} {flags:?} {params:?}", full_cmd.cb);
return Ok(ParsedCommand {
command_def: full_cmd.clone(),
flags,
parameters: params,
});
}
}
}
fn match_flag<'a>(
possible_flags: impl Iterator<Item = &'a Flag>,
matched_flag: MatchedFlag<'a>,
) -> Option<Result<(SmolStr, Option<ParameterValue>), (&'a Flag, FlagMatchError)>> {
// check for all (possible) flags, see if token matches
for flag in possible_flags {
debug!("matching flag {flag:?}");
match flag.try_match(matched_flag.name, matched_flag.value) {
Some(Ok(param)) => return Some(Ok((flag.get_name().into(), param))),
Some(Err(err)) => return Some(Err((flag, err))),
None => {}
}
}
None
}
/// Find the next token from an either raw or partially parsed command string
///
/// Returns:
/// - nothing (none matched)
/// - matched token, to move deeper into the tree
/// - matched value (if this command matched an user-provided value such as a member name)
/// - end position of matched token
/// - error when matching
fn next_token<'a>(
possible_tokens: impl Iterator<Item = &'a Token>,
input: &str,
current_pos: usize,
) -> Option<(Token, TokenMatchResult, usize)> {
// get next parameter, matching quotes
let matched = string::next_param(&input, current_pos);
debug!("matched: {matched:?}\n---");
// iterate over tokens and run try_match
for token in possible_tokens {
let is_match_remaining_token =
|token: &Token| matches!(token, Token::Parameter(param) if param.is_remainder());
// check if this is a token that matches the rest of the input
let match_remaining = is_match_remaining_token(token);
// either use matched param or rest of the input if matching remaining
let input_to_match = matched.as_ref().map(|v| {
match_remaining
.then_some(&input[current_pos..])
.unwrap_or(v.value)
});
let next_pos = match matched {
// return last possible pos if we matched remaining,
Some(_) if match_remaining => input.len(),
// otherwise use matched param next pos,
Some(ref param) => param.next_pos,
// and if didnt match anything we stay where we are
None => current_pos,
};
match token.try_match(input_to_match) {
Some(result) => {
//debug!("matched token: {}", token);
return Some((token.clone(), result, next_pos));
}
None => {} // continue matching until we exhaust all tokens
}
}
None
}
// todo: should probably move this somewhere else
fn rank_possible_commands(
input: &str,
possible_commands: impl IntoIterator<Item = &Command>,
input_tokens: &[&str],
) -> Vec<(Command, String, f64, bool)> {
let mut commands_with_scores: Vec<(&Command, String, f64, bool)> = possible_commands
.into_iter()
.map(|cmd| cmd.original.as_deref().unwrap_or(cmd))
.filter(|cmd| cmd.show_in_suggestions)
.flat_map(|cmd| {
let versions = generate_command_versions(cmd, input_tokens);
versions
.into_iter()
.map(move |(display, scoring, is_alias)| {
let similarity = strsim::jaro_winkler(&input, &scoring);
// if similarity > 0.7 {
// debug!("DEBUG: ranking: '{}' vs '{}' = {}", input, scoring, similarity);
// }
(cmd, display, similarity, is_alias)
})
})
.collect();
commands_with_scores.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
// remove duplicate commands
let mut seen_commands = std::collections::HashSet::new();
let mut best_commands = Vec::new();
for (cmd, version, score, is_alias) in commands_with_scores {
if seen_commands.insert(cmd) {
best_commands.push((cmd, version, score, is_alias));
}
}
const MIN_SCORE_THRESHOLD: f64 = 0.8;
if best_commands.is_empty() || best_commands[0].2 < MIN_SCORE_THRESHOLD {
return Vec::new();
}
// if score falls off too much, don't show
let falloff_threshold: f64 = 0.2;
let best_score = best_commands[0].2;
let mut commands_to_show = Vec::new();
for (command, version, score, is_alias) in best_commands.into_iter().take(MAX_SUGGESTIONS) {
let delta = best_score - score;
if delta > falloff_threshold {
break;
}
commands_to_show.push((command.clone(), version, score, is_alias));
}
commands_to_show
}
fn fmt_commands_list(
f: &mut String,
prefix: &str,
commands_to_show: Vec<(Command, String, f64, bool)>,
) {
for (command, version, _, is_alias) in commands_to_show {
writeln!(
f,
"- **{prefix}{version}**{alias} - *{help}*",
help = command.help,
alias = is_alias
.then(|| format!(
" (alias of **{prefix}{base_version}**)",
base_version = build_command_string(&command, None, &[])
))
.unwrap_or_else(String::new),
)
.expect("oom");
}
}
fn generate_command_versions(cmd: &Command, input_tokens: &[&str]) -> Vec<(String, String, bool)> {
let mut versions = Vec::new();
// Start with base version using primary names
let base_display = build_command_string(cmd, None, &[]);
let base_scoring = build_command_string(cmd, None, input_tokens);
versions.push((base_display, base_scoring, false));
// Generate versions for each alias combination
for (idx, token) in cmd.tokens.iter().enumerate() {
if let Token::Value { aliases, .. } = token {
for alias in aliases {
let display = build_command_string(cmd, Some((idx, alias.as_str())), &[]);
let scoring = build_command_string(cmd, Some((idx, alias.as_str())), input_tokens);
versions.push((display, scoring, true));
}
}
}
versions
}
fn build_command_string(
cmd: &Command,
alias_replacement: Option<(usize, &str)>,
input_tokens: &[&str],
) -> String {
let mut result = String::new();
for (idx, token) in cmd.tokens.iter().enumerate() {
if idx > 0 {
result.push(' ');
}
// Check if we should use an alias for this token
let replacement = alias_replacement
.filter(|(i, _)| *i == idx)
.map(|(_, alias)| alias);
match token {
Token::Value { name, .. } => {
result.push_str(replacement.unwrap_or(name));
}
Token::Parameter(param) => {
// if we have an input token at this position, use it
// otherwise use the placeholder
if let Some(input_token) = input_tokens.get(idx) {
result.push_str(input_token);
} else {
write!(&mut result, "{param}").unwrap()
}
}
}
}
result
}

View file

@ -0,0 +1,520 @@
use std::{
fmt::{Debug, Display},
str::FromStr,
};
use regex::Regex;
use smol_str::{SmolStr, format_smolstr};
use crate::token::{Token, TokenMatchResult};
pub const MESSAGE_REF: ParameterKind = ParameterKind::MessageRef {
id: true,
link: true,
};
pub const MESSAGE_LINK: ParameterKind = ParameterKind::MessageRef {
id: false,
link: true,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ParameterKind {
OpaqueString,
OpaqueInt,
MemberRef,
MemberRefs,
GroupRef,
GroupRefs,
SystemRef,
UserRef,
MessageRef { id: bool, link: bool },
ChannelRef,
GuildRef,
MemberPrivacyTarget,
GroupPrivacyTarget,
SystemPrivacyTarget,
PrivacyLevel,
Toggle,
Avatar,
ProxySwitchAction,
}
impl ParameterKind {
pub(crate) fn default_name(&self) -> &str {
match self {
ParameterKind::OpaqueString => "string",
ParameterKind::OpaqueInt => "number",
ParameterKind::MemberRef => "target",
ParameterKind::MemberRefs => "targets",
ParameterKind::GroupRef => "target",
ParameterKind::GroupRefs => "targets",
ParameterKind::SystemRef => "target",
ParameterKind::UserRef => "target",
ParameterKind::MessageRef { .. } => "target",
ParameterKind::ChannelRef => "target",
ParameterKind::GuildRef => "target",
ParameterKind::MemberPrivacyTarget => "member_privacy_target",
ParameterKind::GroupPrivacyTarget => "group_privacy_target",
ParameterKind::SystemPrivacyTarget => "system_privacy_target",
ParameterKind::PrivacyLevel => "privacy_level",
ParameterKind::Toggle => "toggle",
ParameterKind::Avatar => "avatar",
ParameterKind::ProxySwitchAction => "proxy_switch_action",
}
}
}
#[derive(Debug, Clone)]
pub enum ParameterValue {
OpaqueString(String),
OpaqueInt(i32),
MemberRef(String),
MemberRefs(Vec<String>),
GroupRef(String),
GroupRefs(Vec<String>),
SystemRef(String),
UserRef(u64),
MessageRef(Option<u64>, Option<u64>, u64),
ChannelRef(u64),
GuildRef(u64),
MemberPrivacyTarget(String),
GroupPrivacyTarget(String),
SystemPrivacyTarget(String),
PrivacyLevel(String),
Toggle(bool),
Avatar(String),
ProxySwitchAction(ProxySwitchAction),
Null,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Parameter {
name: SmolStr,
kind: ParameterKind,
remainder: bool,
optional: bool,
skip: bool,
}
impl Parameter {
pub fn name(&self) -> &str {
&self.name
}
pub fn kind(&self) -> ParameterKind {
self.kind
}
pub fn remainder(mut self) -> Self {
self.remainder = true;
self
}
pub fn optional(mut self) -> Self {
self.optional = true;
self
}
pub fn skip(mut self) -> Self {
self.skip = true;
self
}
pub fn is_remainder(&self) -> bool {
self.remainder
}
pub fn is_optional(&self) -> bool {
self.optional
}
pub fn is_skip(&self) -> bool {
self.skip
}
pub fn match_value(&self, input: &str) -> Result<ParameterValue, SmolStr> {
match self.kind {
// TODO: actually parse image url
ParameterKind::OpaqueString => Ok(ParameterValue::OpaqueString(input.into())),
ParameterKind::OpaqueInt => input
.parse::<i32>()
.map(|num| ParameterValue::OpaqueInt(num))
.map_err(|err| format_smolstr!("invalid integer: {err}")),
ParameterKind::GroupRef => Ok(ParameterValue::GroupRef(input.into())),
ParameterKind::GroupRefs => Ok(ParameterValue::GroupRefs(
input.split(' ').map(|s| s.trim().to_string()).collect(),
)),
ParameterKind::MemberRef => Ok(ParameterValue::MemberRef(input.into())),
ParameterKind::MemberRefs => Ok(ParameterValue::MemberRefs(
input.split(' ').map(|s| s.trim().to_string()).collect(),
)),
ParameterKind::SystemRef => Ok(ParameterValue::SystemRef(input.into())),
ParameterKind::UserRef => parse_user_ref(input),
ParameterKind::MemberPrivacyTarget => MemberPrivacyTargetKind::from_str(input)
.map(|target| ParameterValue::MemberPrivacyTarget(target.as_ref().into())),
ParameterKind::GroupPrivacyTarget => GroupPrivacyTargetKind::from_str(input)
.map(|target| ParameterValue::GroupPrivacyTarget(target.as_ref().into())),
ParameterKind::SystemPrivacyTarget => SystemPrivacyTargetKind::from_str(input)
.map(|target| ParameterValue::SystemPrivacyTarget(target.as_ref().into())),
ParameterKind::PrivacyLevel => PrivacyLevelKind::from_str(input)
.map(|level| ParameterValue::PrivacyLevel(level.as_ref().into())),
ParameterKind::Toggle => {
Toggle::from_str(input).map(|t| ParameterValue::Toggle(t.into()))
}
ParameterKind::Avatar => Ok(ParameterValue::Avatar(input.into())),
ParameterKind::MessageRef { id, link } => {
if id {
if let Ok(message_id) = input.parse::<u64>() {
return Ok(ParameterValue::MessageRef(None, None, message_id));
}
}
if link {
static SERVER_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(
|| {
regex::Regex::new(
r"https://(?:\w+\.)?discord(?:app)?\.com/channels/(?P<guild>\d+)/(?P<channel>\d+)/(?P<message>\d+)",
)
.unwrap()
},
);
static DM_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(
|| {
regex::Regex::new(
r"https://(?:\w+\.)?discord(?:app)?\.com/channels/@me/(?P<channel>\d+)/(?P<message>\d+)",
)
.unwrap()
},
);
if let Some(captures) = SERVER_RE.captures(input) {
let guild_id = captures.parse_id("guild")?;
let channel_id = captures.parse_id("channel")?;
let message_id = captures.parse_id("message")?;
Ok(ParameterValue::MessageRef(
Some(guild_id),
Some(channel_id),
message_id,
))
} else if let Some(captures) = DM_RE.captures(input) {
let channel_id = captures.parse_id("channel")?;
let message_id = captures.parse_id("message")?;
Ok(ParameterValue::MessageRef(
None,
Some(channel_id),
message_id,
))
} else {
Err(SmolStr::new("invalid message reference"))
}
} else {
unreachable!("link and id both cant be false")
}
}
ParameterKind::ChannelRef => {
let mut text = input;
if text.len() > 3 && text.starts_with("<#") && text.ends_with('>') {
text = &text[2..text.len() - 1];
}
text.parse::<u64>()
.map(ParameterValue::ChannelRef)
.map_err(|_| SmolStr::new("invalid channel ID"))
}
ParameterKind::GuildRef => input
.parse::<u64>()
.map(ParameterValue::GuildRef)
.map_err(|_| SmolStr::new("invalid guild ID")),
ParameterKind::ProxySwitchAction => ProxySwitchAction::from_str(input)
.map(ParameterValue::ProxySwitchAction)
.map_err(|_| SmolStr::new("invalid proxy switch action, must be new/add/off")),
}
}
}
impl Display for Parameter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
ParameterKind::OpaqueString => {
write!(f, "[{}]", self.name)
}
ParameterKind::OpaqueInt => {
write!(f, "[{}]", self.name)
}
ParameterKind::MemberRef => write!(f, "<target member>"),
ParameterKind::MemberRefs => write!(f, "<member 1> <member 2> <member 3>"),
ParameterKind::GroupRef => write!(f, "<target group>"),
ParameterKind::GroupRefs => write!(f, "<group 1> <group 2> <group 3>"),
ParameterKind::SystemRef => write!(f, "<target system>"),
ParameterKind::UserRef => write!(f, "<target user>"),
ParameterKind::MessageRef { link, id } => write!(
f,
"<target message {}>",
link.then_some("link")
.into_iter()
.chain(id.then_some("id"))
.collect::<Vec<_>>()
.join("/")
),
ParameterKind::ChannelRef => write!(f, "<target channel>"),
ParameterKind::GuildRef => write!(f, "<target guild>"),
ParameterKind::MemberPrivacyTarget => write!(f, "<privacy target>"),
ParameterKind::GroupPrivacyTarget => write!(f, "<privacy target>"),
ParameterKind::SystemPrivacyTarget => write!(f, "<privacy target>"),
ParameterKind::PrivacyLevel => write!(f, "[privacy level]"),
ParameterKind::Toggle => write!(f, "<on|off>"),
ParameterKind::Avatar => write!(f, "<url|@mention>"),
ParameterKind::ProxySwitchAction => write!(f, "<new|add|off>"),
}?;
if self.is_remainder() {
write!(f, "...")?;
}
Ok(())
}
}
fn is_remainder(kind: ParameterKind) -> bool {
matches!(kind, ParameterKind::MemberRefs | ParameterKind::GroupRefs)
}
impl From<ParameterKind> for Parameter {
fn from(value: ParameterKind) -> Self {
Parameter {
name: value.default_name().into(),
kind: value,
remainder: is_remainder(value),
optional: false,
skip: false,
}
}
}
impl From<(&str, ParameterKind)> for Parameter {
fn from((name, kind): (&str, ParameterKind)) -> Self {
Parameter {
name: name.into(),
kind,
remainder: is_remainder(kind),
optional: false,
skip: false,
}
}
}
/// if no input is left to parse, this parameter matches to Null
#[derive(Clone)]
pub struct Optional<P: Into<Parameter>>(pub P);
impl<P: Into<Parameter>> From<Optional<P>> for Parameter {
fn from(value: Optional<P>) -> Self {
let p = value.0.into();
p.optional()
}
}
/// tells the parser to use the remainder of the input as the input to this parameter
#[derive(Clone)]
pub struct Remainder<P: Into<Parameter>>(pub P);
impl<P: Into<Parameter>> From<Remainder<P>> for Parameter {
fn from(value: Remainder<P>) -> Self {
let p = value.0.into();
p.remainder()
}
}
// todo: this should ideally be removed in favor of making Token::Parameter take multiple parameters
/// skips the branch this parameter is in if it does not match
#[derive(Clone)]
pub struct Skip<P: Into<Parameter>>(pub P);
impl<P: Into<Parameter>> From<Skip<P>> for Parameter {
fn from(value: Skip<P>) -> Self {
let p = value.0.into();
p.skip()
}
}
fn parse_user_ref(input: &str) -> Result<ParameterValue, SmolStr> {
if let Ok(user_id) = input.parse::<u64>() {
return Ok(ParameterValue::UserRef(user_id));
}
static RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"<@!?(\d{17,19})>").unwrap());
if let Some(captures) = RE.captures(&input) {
return captures[1]
.parse::<u64>()
.map(|id| ParameterValue::UserRef(id))
.map_err(|_| SmolStr::new("invalid user ID"));
}
Err(SmolStr::new("invalid user ID"))
}
macro_rules! define_enum {
($name:ident ($pretty_name:expr): $($variant:ident),* $(,)?) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum $name {
$($variant),*
}
impl $name {
pub const PRETTY_NAME: &'static str = $pretty_name;
pub fn variants() -> impl Iterator<Item = Self> {
[$(Self::$variant),*].into_iter()
}
pub fn variants_str() -> impl Iterator<Item = &'static str> {
[$(Self::$variant.as_ref()),*].into_iter()
}
pub fn get_error() -> SmolStr {
let pretty_name = Self::PRETTY_NAME;
let vars = Self::variants_str().intersperse("/").collect::<SmolStr>();
format_smolstr!("invalid {pretty_name}, must be one of {vars}")
}
}
};
}
macro_rules! str_enum {
($name:ident: $($variant:ident = $variant_str:literal),* $(,)?) => {
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
match self {
$(Self::$variant => $variant_str),*
}
}
}
};
}
macro_rules! auto_enum {
($name:ident ($pretty_name:expr): $($variant:ident = $variant_str:literal $(| $variant_matches:literal)*),* $(,)?) => {
define_enum!($name($pretty_name): $($variant),*);
str_enum!($name: $($variant = $variant_str),*);
impl FromStr for $name {
type Err = SmolStr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
$($variant_str $(| $variant_matches)* => Ok(Self::$variant),)*
_ => Err(Self::get_error()),
}
}
}
};
}
auto_enum! {
MemberPrivacyTargetKind("member privacy target"):
Visibility = "visibility",
Name = "name",
Description = "description",
Banner = "banner",
Avatar = "avatar",
Birthday = "birthday",
Pronouns = "pronouns",
Proxy = "proxy",
Metadata = "metadata",
}
auto_enum! {
GroupPrivacyTargetKind("group privacy target"):
Name = "name",
Icon = "icon" | "avatar",
Description = "description",
Banner = "banner",
List = "list",
Metadata = "metadata",
Visibility = "visibility",
}
auto_enum! {
SystemPrivacyTargetKind("system privacy target"):
Name = "name",
Avatar = "avatar" | "pfp" | "pic" | "icon",
Description = "description" | "desc" | "bio" | "info",
Banner = "banner" | "splash" | "cover",
Pronouns = "pronouns" | "prns" | "pn",
MemberList = "members" | "memberlist" | "list",
GroupList = "groups" | "gs",
Front = "front" | "fronter" | "fronters",
FrontHistory = "fronthistory" | "fh" | "switches",
}
auto_enum! {
PrivacyLevelKind("privacy level"):
Public = "public",
Private = "private",
}
define_enum!(Toggle("toggle"): On, Off);
str_enum!(Toggle: On = "on", Off = "off");
impl FromStr for Toggle {
type Err = SmolStr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let matches_self = |toggle: &Self| {
matches!(
Token::from(*toggle).try_match(Some(s)),
Some(TokenMatchResult::MatchedValue)
)
};
Self::variants()
.find(matches_self)
.ok_or_else(Self::get_error)
}
}
impl From<Toggle> for Token {
fn from(toggle: Toggle) -> Self {
match toggle {
Toggle::On => Self::from(("on", ["yes", "true", "enable", "enabled"])),
Toggle::Off => Self::from(("off", ["no", "false", "disable", "disabled"])),
}
}
}
impl Into<bool> for Toggle {
fn into(self) -> bool {
match self {
Toggle::On => true,
Toggle::Off => false,
}
}
}
define_enum!(ProxySwitchAction("proxy switch action"): New, Add, Off);
str_enum!(ProxySwitchAction: New = "new", Add = "add", Off = "off");
impl FromStr for ProxySwitchAction {
type Err = SmolStr;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::variants()
.find(|action| action.as_ref() == s)
.ok_or_else(Self::get_error)
}
}
trait ParseMessageLink {
fn parse_id(&self, name: &str) -> Result<u64, SmolStr>;
}
impl ParseMessageLink for regex::Captures<'_> {
fn parse_id(&self, name: &str) -> Result<u64, SmolStr> {
self.name(name)
.and_then(|m| m.as_str().parse::<u64>().ok())
.ok_or_else(|| SmolStr::new(format!("invalid {} ID in message link", name)))
}
}

View file

@ -0,0 +1,186 @@
use std::collections::HashMap;
use log::debug;
use smol_str::{SmolStr, ToSmolStr};
lazy_static::lazy_static! {
// Dictionary of (left, right) quote pairs
// Each char in the string is an individual quote, multi-char strings imply "one of the following chars"
// Certain languages can have quote patterns that have a different character for open and close
pub static ref QUOTE_PAIRS: HashMap<SmolStr, SmolStr> = {
let mut pairs: HashMap<SmolStr, SmolStr> = HashMap::new();
let mut insert_pair = |a: &'static str, b: &'static str| {
let a = SmolStr::new_static(a);
let b = SmolStr::new_static(b);
pairs.insert(a.clone(), b.clone());
// make it easier to look up right quotes
for char in a.chars() {
pairs.insert(char.to_smolstr(), b.clone());
}
};
// Basic
insert_pair( "'", "'" ); // ASCII single quotes
insert_pair( "\"", "\"" ); // ASCII double quotes
// "Smart quotes"
// Specifically ignore the left/right status of the quotes and match any combination of them
// Left string also includes "low" quotes to allow for the low-high style used in some locales
insert_pair( "\u{201C}\u{201D}\u{201F}\u{201E}", "\u{201C}\u{201D}\u{201F}" ); // double quotes
insert_pair( "\u{2018}\u{2019}\u{201B}\u{201A}", "\u{2018}\u{2019}\u{201B}" ); // single quotes
// Chevrons (normal and "fullwidth" variants)
insert_pair( "\u{00AB}\u{300A}", "\u{00BB}\u{300B}" ); // double chevrons, pointing away (<<text>>)
insert_pair( "\u{00BB}\u{300B}", "\u{00AB}\u{300A}" ); // double chevrons, pointing together (>>text<<)
insert_pair( "\u{2039}\u{3008}", "\u{203A}\u{3009}" ); // single chevrons, pointing away (<text>)
insert_pair( "\u{203A}\u{3009}", "\u{2039}\u{3008}" ); // single chevrons, pointing together (>text<)
// Other
insert_pair( "\u{300C}\u{300E}", "\u{300D}\u{300F}" ); // corner brackets (Japanese/Chinese)
pairs
};
}
// very very simple quote matching
// expects match_str to be trimmed (no whitespace, from the start at least)
// returns the position of an end quote if any is found
// quotes need to be at start/end of words, and are ignored if a closing quote is not present
// WTB POSIX quoting: https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html
fn find_quotes(match_str: &str) -> Option<usize> {
if let Some(right) = QUOTE_PAIRS.get(&match_str[0..match_str.ceil_char_boundary(1)]) {
// try matching end quote
for possible_quote in right.chars() {
for (pos, _) in match_str.match_indices(possible_quote) {
if match_str.len() == pos + 1
|| match_str.chars().nth(pos + 1).unwrap().is_whitespace()
{
return Some(pos);
}
}
}
}
None
}
#[derive(Debug)]
pub(super) struct MatchedParam<'a> {
pub(super) value: &'a str,
pub(super) next_pos: usize,
#[allow(dead_code)] // this'll prolly be useful sometime later
pub(super) in_quotes: bool,
}
pub(super) fn next_param<'a>(input: &'a str, current_pos: usize) -> Option<MatchedParam<'a>> {
if input.len() == current_pos {
return None;
}
let leading_whitespace_count =
input[..current_pos].len() - input[..current_pos].trim_start().len();
let substr_to_match = &input[current_pos + leading_whitespace_count..];
debug!("stuff: {input} {current_pos} {leading_whitespace_count}");
debug!("to match: {substr_to_match}");
if let Some(end_quote_pos) = find_quotes(substr_to_match) {
// return quoted string, without quotes
return Some(MatchedParam {
value: &substr_to_match[1..end_quote_pos],
next_pos: current_pos + end_quote_pos + 1,
in_quotes: true,
});
}
// find next whitespace character
for (pos, char) in substr_to_match.char_indices() {
if char.is_whitespace() {
return Some(MatchedParam {
value: &substr_to_match[..pos],
next_pos: current_pos + pos + 1,
in_quotes: false,
});
}
}
// if we're here, we went to EOF and didn't match any whitespace
// so we return the whole string
Some(MatchedParam {
value: substr_to_match,
next_pos: current_pos + substr_to_match.len(),
in_quotes: false,
})
}
#[derive(Debug, Clone)]
pub(super) struct MatchedFlag<'a> {
pub(super) name: &'a str,
pub(super) value: Option<&'a str>,
pub(super) next_pos: usize,
}
pub(super) fn next_flag<'a>(input: &'a str, mut current_pos: usize) -> Option<MatchedFlag<'a>> {
if input.len() == current_pos {
return None;
}
let leading_whitespace_count =
input[..current_pos].len() - input[..current_pos].trim_start().len();
let substr_to_match = &input[current_pos + leading_whitespace_count..];
// if the param is quoted, it should not be processed as a flag
if find_quotes(substr_to_match).is_some() {
return None;
}
debug!("flag input {substr_to_match}");
// strip the -
let original_len = substr_to_match.len();
let substr_without_dashes = substr_to_match.trim_start_matches('-');
let dash_count = original_len - substr_without_dashes.len();
if dash_count == 0 || dash_count > 2 {
// if it doesn't have one, then it is not a flag
// or if it has more dashes than 2, assume its not a flag
return None;
}
let substr_to_match = substr_without_dashes;
current_pos += dash_count;
// try finding = or whitespace
for (pos, char) in substr_to_match.char_indices() {
debug!("flag find char {char} at {pos}");
if char == '=' {
let name = &substr_to_match[..pos];
debug!("flag find {name}");
// try to get the value
let Some(param) = next_param(input, current_pos + pos + 1) else {
return Some(MatchedFlag {
name,
value: Some(""),
next_pos: current_pos + pos + 1,
});
};
return Some(MatchedFlag {
name,
value: Some(param.value),
next_pos: param.next_pos,
});
} else if char.is_whitespace() {
// no value if whitespace
return Some(MatchedFlag {
name: &substr_to_match[..pos],
value: None,
next_pos: current_pos + pos + 1,
});
}
}
// if eof then no value
Some(MatchedFlag {
name: substr_to_match,
value: None,
next_pos: current_pos + substr_to_match.len(),
})
}

View file

@ -0,0 +1,174 @@
use std::fmt::{Debug, Display};
use smol_str::SmolStr;
use crate::parameter::{Parameter, ParameterValue};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Token {
/// A bot-defined command / subcommand (usually) (eg. "member" in `pk;member MyName`)
Value {
name: SmolStr,
aliases: Vec<SmolStr>,
},
/// A parameter that must be provided a value
Parameter(Parameter),
}
#[derive(Clone, Debug)]
pub enum TokenMatchResult {
MatchedValue,
MatchedParameter {
name: SmolStr,
value: ParameterValue,
},
ParameterMatchError {
input: SmolStr,
msg: SmolStr,
},
MissingParameter {
name: SmolStr,
},
}
// q: why not have a NoMatch variant in TokenMatchResult?
// a: because we want to differentiate between no match and match failure (it matched with an error)
// "no match" has a different charecteristic because we want to continue matching other tokens...
// ...while "match failure" means we should stop matching and return the error
// Option fits this better (and it makes some code look a bit nicer)
pub type TryMatchResult = Option<TokenMatchResult>;
impl Token {
pub fn try_match(&self, input: Option<&str>) -> TryMatchResult {
let input = match input {
Some(input) => input,
None => {
// short circuit on:
return match self {
// missing paramaters
Self::Parameter(param) => Some(
param
.is_optional()
.then(|| TokenMatchResult::MatchedParameter {
name: param.name().into(),
value: ParameterValue::Null,
})
.unwrap_or_else(|| TokenMatchResult::MissingParameter {
name: param.name().into(),
}),
),
// everything else doesnt match if no input anyway
Self::Value { .. } => None,
// don't add a _ match here!
};
}
};
let input = input.trim();
// try actually matching stuff
match self {
Self::Value { name, aliases } => (aliases.iter().chain(std::iter::once(name)))
.any(|v| v.eq(input))
.then(|| TokenMatchResult::MatchedValue),
Self::Parameter(param) => Some(match param.match_value(input) {
Ok(matched) => TokenMatchResult::MatchedParameter {
name: param.name().into(),
value: matched,
},
Err(err) => TokenMatchResult::ParameterMatchError {
input: input.into(),
msg: err,
},
}),
// don't add a _ match here!
}
}
}
impl Display for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Value { name, .. } => write!(f, "{name}"),
Self::Parameter(param) => write!(f, "{param}"),
}
}
}
// (name, aliases) -> Token::Value
impl<const L: usize> From<(&str, [&str; L])> for Token {
fn from((name, aliases): (&str, [&str; L])) -> Self {
Self::Value {
name: name.into(),
aliases: aliases.into_iter().map(SmolStr::new).collect::<Vec<_>>(),
}
}
}
// name -> Token::Value
impl From<&str> for Token {
fn from(value: &str) -> Self {
Self::from((value, []))
}
}
// parameter -> Token::Parameter
impl<P: Into<Parameter>> From<P> for Token {
fn from(value: P) -> Self {
Self::Parameter(value.into())
}
}
/// Iterator that produces [`Token`]s.
///
/// This is more of a convenience type that the [`tokens!`] macro uses in order
/// to more easily combine tokens together.
#[derive(Debug, Clone)]
pub struct TokensIterator {
inner: Vec<Token>,
}
impl TokensIterator {
pub(crate) fn new(tokens: Vec<Token>) -> Self {
Self { inner: tokens }
}
}
impl Iterator for TokensIterator {
type Item = Token;
fn next(&mut self) -> Option<Self::Item> {
(self.inner.len() > 0).then(|| self.inner.remove(0))
}
}
impl From<Vec<Token>> for TokensIterator {
fn from(value: Vec<Token>) -> Self {
Self::new(value)
}
}
impl<T: Into<Token>> From<T> for TokensIterator {
fn from(value: T) -> Self {
Self::new(vec![value.into()])
}
}
impl<const L: usize> From<[Token; L]> for TokensIterator {
fn from(value: [Token; L]) -> Self {
Self::new(value.into_iter().collect())
}
}
impl<const L: usize> From<[Self; L]> for TokensIterator {
fn from(value: [Self; L]) -> Self {
Self::new(value.into_iter().map(|t| t.inner).flatten().collect())
}
}
#[macro_export]
macro_rules! tokens {
($($v:expr),+$(,)*) => {
$crate::token::TokensIterator::from([$($crate::token::TokensIterator::from($v.clone())),+])
};
}

View file

@ -0,0 +1,97 @@
use std::sync::Arc;
use ordermap::OrderMap;
use crate::{command::Command, token::Token};
#[derive(Debug, Clone)]
pub struct TreeBranch {
current_command: Option<Arc<Command>>,
branches: OrderMap<Token, Arc<TreeBranch>>,
}
impl Default for TreeBranch {
fn default() -> Self {
Self {
current_command: None,
branches: OrderMap::new(),
}
}
}
impl TreeBranch {
pub fn register_command(&mut self, command: Command) {
let mut current_branch = self;
// iterate over tokens in command
for (index, token) in command.tokens.clone().into_iter().enumerate() {
// if the token is an optional parameter, register rest of the tokens to a separate branch
// this allows optional parameters to work if they are not the last token
if matches!(token, Token::Parameter(ref param) if param.is_optional())
&& index < command.tokens.len() - 1
{
let mut new_command = command.clone();
new_command.tokens = command.tokens[index + 1..].to_vec();
new_command.original = command
.original
.clone()
.or_else(|| Some(Arc::new(command.clone())));
// if the optional parameter we're skipping is *before* the flag insertion point,
// we need to shift the index left by 1 to account for the removed token
if new_command.parse_flags_before > index {
new_command.parse_flags_before -= 1;
}
current_branch.register_command(new_command);
}
// recursively get or create a sub-branch for each token
current_branch = Arc::make_mut(
current_branch
.branches
.entry(token)
.or_insert_with(|| Arc::new(TreeBranch::default())),
);
}
// when we're out of tokens add the command to the last branch
current_branch.current_command = Some(Arc::new(command));
}
pub fn command(&self) -> Option<Arc<Command>> {
self.current_command.clone()
}
pub fn possible_tokens(&self) -> impl Iterator<Item = &Token> + Clone {
self.branches.keys()
}
pub fn possible_commands(&self, max_depth: usize) -> impl Iterator<Item = &Command> {
// dusk: i am too lazy to write an iterator for this without using recursion so we box everything
fn box_iter<'a>(
iter: impl Iterator<Item = &'a Command> + 'a,
) -> Box<dyn Iterator<Item = &'a Command> + 'a> {
Box::new(iter)
}
if max_depth == 0 {
return box_iter(std::iter::empty());
}
let mut commands = box_iter(std::iter::empty());
for branch in self.branches.values() {
if let Some(command) = branch.current_command.as_ref() {
commands = box_iter(commands.chain(std::iter::once(command.as_ref())));
// we dont need to look further if we found a command
continue;
}
commands = box_iter(commands.chain(branch.possible_commands(max_depth - 1)));
}
commands
}
pub fn get_branch(&self, token: &Token) -> Option<&Arc<Self>> {
self.branches.get(token)
}
pub fn branches(&self) -> impl Iterator<Item = (&Token, &Arc<Self>)> {
self.branches.iter()
}
}

View file

@ -0,0 +1,51 @@
use command_parser::{Tree, command::Command, parameter::*, parse_command, tokens};
/// this checks if we properly keep track of filtered tokens (eg. branches we failed on)
/// when we backtrack. a previous parser bug would cause infinite loops since it did not
/// (the parser would "flip-flop" between branches) this is here for reference.
#[test]
fn test_infinite_loop_repro() {
let p1 = Optional(("param1", ParameterKind::OpaqueString));
let p2 = Optional(("param2", ParameterKind::OpaqueString));
let cmd1 = Command::new(tokens!("s", p1, "A"), "cmd1");
let cmd2 = Command::new(tokens!("s", p2, "B"), "cmd2");
let mut tree = Tree::default();
tree.register_command(cmd1);
tree.register_command(cmd2);
let input = "s foo C";
// this should fail and not loop
let result = parse_command(tree, "pk;".to_string(), input.to_string());
assert!(result.is_err());
}
/// check if we have params from other branches when we trying to match them and they succeeded
/// but then we backtracked, making them invalid. this should no longer happen since we just
/// extract params from matched tokens when we match the command, but keeping here just for reference.
#[test]
fn test_dirty_params() {
let p1 = Optional(("param1", ParameterKind::OpaqueString));
let p2 = Optional(("param2", ParameterKind::OpaqueString));
let cmd1 = Command::new(tokens!("s", p1, "A"), "cmd1");
let cmd2 = Command::new(tokens!("s", p2, "B"), "cmd2");
let mut tree = Tree::default();
tree.register_command(cmd1);
tree.register_command(cmd2);
let input = "s foo B";
let result = parse_command(tree, "pk;".to_string(), input.to_string()).unwrap();
println!("params: {:?}", result.parameters);
assert!(
!result.parameters.contains_key("param1"),
"params should not contain 'param1' from failed branch"
);
assert!(
result.parameters.contains_key("param2"),
"params should contain 'param2'"
);
}

View file

@ -0,0 +1,55 @@
use command_parser::{Tree, command::Command, parameter::*, parse_command, tokens};
#[test]
fn test_typoed_command_with_parameter() {
let message_token = ("message", ["msg", "messageinfo"]);
let author_token = ("author", ["sender", "a"]);
// message <optional msg ref> author
let cmd = Command::new(
tokens!(message_token, Optional(MESSAGE_REF), author_token),
"message_author",
)
.help("Shows the author of a proxied message");
let mut tree = Tree::default();
tree.register_command(cmd);
let input = "message 1 auth";
let result = parse_command(tree, "pk;".to_string(), input.to_string());
match result {
Ok(_) => panic!("Should have failed to parse"),
Err(msg) => {
println!("Error: {}", msg);
assert!(msg.contains("Perhaps you meant one of the following commands"));
assert!(msg.contains("message <target message link/id> author"));
}
}
}
#[test]
fn test_typoed_command_with_flags() {
let message_token = ("message", ["msg", "messageinfo"]);
let author_token = ("author", ["sender", "a"]);
let cmd = Command::new(tokens!(message_token, author_token), "message_author")
.flag(("flag", ["f"]))
.flag(("flag2", ["f2"]))
.help("Shows the author of a proxied message");
let mut tree = Tree::default();
tree.register_command(cmd);
let input = "message auth -f -flag2";
let result = parse_command(tree, "pk;".to_string(), input.to_string());
match result {
Ok(_) => panic!("Should have failed to parse"),
Err(msg) => {
println!("Error: {}", msg);
assert!(msg.contains("Perhaps you meant one of the following commands"));
assert!(msg.contains("message author"));
}
}
}

View file

@ -0,0 +1,23 @@
[package]
name = "commands"
version = "0.1.0"
edition = "2021"
default-run = "commands"
[[bin]]
name = "write_cs_glue"
path = "src/write_cs_glue.rs"
[lib]
crate-type = ["cdylib", "lib"]
[dependencies]
lazy_static = { workspace = true }
command_parser = { path = "../command_parser"}
command_definitions = { path = "../command_definitions"}
uniffi = { version = "0.29" }
log = "0.4"
simple_logger = "4.3.3"
[build-dependencies]
uniffi = { version = "0.29", features = [ "build" ] }

3
crates/commands/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
uniffi::generate_scaffolding("src/commands.udl").unwrap();
}

View file

@ -0,0 +1,36 @@
namespace commands {
CommandResult parse_command(string prefix, string input);
string get_related_commands(string prefix, string input);
};
[Enum]
interface CommandResult {
Ok(ParsedCommand command);
Err(string error);
};
[Enum]
interface Parameter {
MemberRef(string member);
MemberRefs(sequence<string> members);
GroupRef(string group);
GroupRefs(sequence<string> groups);
SystemRef(string system);
UserRef(u64 user_id);
MessageRef(u64? guild_id, u64? channel_id, u64 message_id);
ChannelRef(u64 channel_id);
GuildRef(u64 guild_id);
MemberPrivacyTarget(string target);
GroupPrivacyTarget(string target);
SystemPrivacyTarget(string target);
PrivacyLevel(string level);
OpaqueString(string raw);
OpaqueInt(i32 raw);
Toggle(boolean toggle);
Avatar(string avatar);
ProxySwitchAction(string action);
Null();
};
dictionary ParsedCommand {
string command_ref;
record<string, Parameter> params;
record<string, Parameter?> flags;
};

183
crates/commands/src/lib.rs Normal file
View file

@ -0,0 +1,183 @@
use std::{
collections::HashMap,
fmt::Write,
sync::{Arc, Once},
};
use command_parser::{parameter::ParameterValue, token::TokenMatchResult, Tree};
uniffi::include_scaffolding!("commands");
lazy_static::lazy_static! {
pub static ref COMMAND_TREE: Arc<Tree> = {
let mut tree = Tree::default();
command_definitions::all().into_iter().for_each(|x| tree.register_command(x));
Arc::new(tree)
};
}
static LOG_INIT: Once = Once::new();
#[derive(Debug)]
pub enum CommandResult {
Ok { command: ParsedCommand },
Err { error: String },
}
#[derive(Debug, Clone)]
pub enum Parameter {
MemberRef {
member: String,
},
MemberRefs {
members: Vec<String>,
},
GroupRef {
group: String,
},
GroupRefs {
groups: Vec<String>,
},
SystemRef {
system: String,
},
UserRef {
user_id: u64,
},
MessageRef {
guild_id: Option<u64>,
channel_id: Option<u64>,
message_id: u64,
},
ChannelRef {
channel_id: u64,
},
GuildRef {
guild_id: u64,
},
MemberPrivacyTarget {
target: String,
},
GroupPrivacyTarget {
target: String,
},
SystemPrivacyTarget {
target: String,
},
PrivacyLevel {
level: String,
},
OpaqueString {
raw: String,
},
OpaqueInt {
raw: i32,
},
Toggle {
toggle: bool,
},
Avatar {
avatar: String,
},
ProxySwitchAction {
action: String,
},
Null,
}
impl From<ParameterValue> for Parameter {
fn from(value: ParameterValue) -> Self {
match value {
ParameterValue::MemberRef(member) => Self::MemberRef { member },
ParameterValue::MemberRefs(members) => Self::MemberRefs { members },
ParameterValue::GroupRef(group) => Self::GroupRef { group },
ParameterValue::GroupRefs(groups) => Self::GroupRefs { groups },
ParameterValue::SystemRef(system) => Self::SystemRef { system },
ParameterValue::UserRef(user_id) => Self::UserRef { user_id },
ParameterValue::MemberPrivacyTarget(target) => Self::MemberPrivacyTarget { target },
ParameterValue::GroupPrivacyTarget(target) => Self::GroupPrivacyTarget { target },
ParameterValue::SystemPrivacyTarget(target) => Self::SystemPrivacyTarget { target },
ParameterValue::PrivacyLevel(level) => Self::PrivacyLevel { level },
ParameterValue::OpaqueString(raw) => Self::OpaqueString { raw },
ParameterValue::OpaqueInt(raw) => Self::OpaqueInt { raw },
ParameterValue::Toggle(toggle) => Self::Toggle { toggle },
ParameterValue::Avatar(avatar) => Self::Avatar { avatar },
ParameterValue::MessageRef(guild_id, channel_id, message_id) => Self::MessageRef {
guild_id,
channel_id,
message_id,
},
ParameterValue::ChannelRef(channel_id) => Self::ChannelRef { channel_id },
ParameterValue::GuildRef(guild_id) => Self::GuildRef { guild_id },
ParameterValue::Null => Self::Null,
ParameterValue::ProxySwitchAction(action) => Self::ProxySwitchAction {
action: action.as_ref().to_string(),
},
}
}
}
#[derive(Debug)]
pub struct ParsedCommand {
pub command_ref: String,
pub params: HashMap<String, Parameter>,
pub flags: HashMap<String, Option<Parameter>>,
}
pub fn parse_command(prefix: String, input: String) -> CommandResult {
LOG_INIT.call_once(|| {
if let Err(err) = simple_logger::SimpleLogger::new()
.with_level(log::LevelFilter::Info)
.env()
.init()
{
eprintln!("cant initialize logger: {err}");
}
});
command_parser::parse_command(COMMAND_TREE.clone(), prefix, input).map_or_else(
|error| CommandResult::Err { error },
|parsed| CommandResult::Ok {
command: {
let command_ref = parsed.command_def.cb.clone().into();
let mut flags = HashMap::with_capacity(parsed.flags.capacity());
for (name, value) in parsed.flags {
flags.insert(name, value.map(Parameter::from));
}
let mut params = HashMap::with_capacity(parsed.parameters.capacity());
for (name, value) in parsed.parameters {
params.insert(name, Parameter::from(value));
}
ParsedCommand {
command_ref,
flags,
params,
}
},
},
)
}
pub fn get_related_commands(prefix: String, input: String) -> String {
let mut s = String::new();
for command in command_definitions::all() {
if !command.show_in_suggestions {
continue;
}
if command.tokens.first().map_or(false, |token| {
token
.try_match(Some(&input))
.map_or(false, |r| matches!(r, TokenMatchResult::MatchedValue))
}) {
writeln!(
&mut s,
"- **{prefix}{command}** - *{help}*",
help = command.help
)
.unwrap();
}
}
s
}

View file

@ -0,0 +1,51 @@
#![feature(iter_intersperse)]
use command_parser::Tree;
use commands::COMMAND_TREE;
fn main() {
parse();
}
fn related() {
let cmd = std::env::args().nth(1).unwrap();
let related = commands::get_related_commands("pk;".to_string(), cmd);
println!("Related commands:\n{related}");
}
fn parse() {
let cmd = std::env::args()
.skip(1)
.intersperse(" ".to_string())
.collect::<String>();
if !cmd.is_empty() {
use commands::CommandResult;
let parsed = commands::parse_command("pk;".to_string(), cmd);
match parsed {
CommandResult::Ok { command } => println!("{command:#?}"),
CommandResult::Err { error } => println!("{error}"),
}
} else {
for command in command_definitions::all() {
println!("{} => {} - {}", command.cb, command, command.help);
}
}
}
fn print_tree(tree: &Tree, depth: usize) {
println!();
for (token, branch) in tree.branches() {
for _ in 0..depth {
print!(" ");
}
for _ in 0..depth {
print!("-");
}
print!("> {token:?}");
if let Some(command) = branch.command() {
println!(": {}", command.cb)
} else {
print_tree(branch, depth + 1)
}
}
}

View file

@ -0,0 +1,318 @@
use std::{collections::HashSet, env, fmt::Write, fs, path::PathBuf, str::FromStr};
use command_parser::{
parameter::{Parameter, ParameterKind},
token::Token,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let write_location = env::args()
.nth(1)
.expect("file location should be provided");
let write_location = PathBuf::from_str(&write_location).unwrap();
let commands = command_definitions::all().collect::<Vec<_>>();
let mut glue = String::new();
writeln!(&mut glue, "#nullable enable\n")?;
writeln!(&mut glue, "using PluralKit.Core;\n")?;
writeln!(&mut glue, "using Myriad.Types;")?;
writeln!(&mut glue, "namespace PluralKit.Bot;\n")?;
let mut commands_seen = HashSet::new();
let mut record_fields = String::new();
for command in &commands {
if commands_seen.contains(&command.cb) {
continue;
}
writeln!(
&mut record_fields,
r#"public record {command_name}({command_name}Params parameters, {command_name}Flags flags): Commands;"#,
command_name = command_callback_to_name(&command.cb),
)?;
commands_seen.insert(command.cb.clone());
}
commands_seen.clear();
let mut match_branches = String::new();
for command in &commands {
if commands_seen.contains(&command.cb) {
continue;
}
let mut command_params_init = String::new();
let command_params = find_parameters(&command.tokens);
for param in &command_params {
writeln!(
&mut command_params_init,
r#"@{fieldName} = await ctx.ParamResolve{extract_fn_name}("{name}"){throw_null},"#,
fieldName = param.name().replace("-", "_"),
name = param.name(),
extract_fn_name = get_param_param_ty(param.kind()),
throw_null = param
.is_optional()
.then(String::new)
.unwrap_or(format!(" ?? throw new PKError(\"parameter {} not found but was required, this is a bug in the command parser, for command: {}!\")", param.name(), command.cb)),
)?;
}
let mut command_flags_init = String::new();
for flag in &command.flags {
if let Some(param) = flag.get_value() {
writeln!(
&mut command_flags_init,
r#"@{fieldName} = await ctx.FlagResolve{extract_fn_name}("{name}"),"#,
fieldName = flag.get_name().replace("-", "_"),
name = flag.get_name(),
extract_fn_name = get_param_param_ty(param.kind()),
)?;
} else {
writeln!(
&mut command_flags_init,
r#"@{fieldName} = ctx.Parameters.HasFlag("{name}"),"#,
fieldName = flag.get_name().replace("-", "_"),
name = flag.get_name(),
)?;
}
}
write!(
&mut match_branches,
r#"
"{command_callback}" => new {command_name}(
new {command_name}Params {{ {command_params_init} }},
new {command_name}Flags {{ {command_flags_init} }}
),
"#,
command_name = command_callback_to_name(&command.cb),
command_callback = command.cb,
)?;
commands_seen.insert(command.cb.clone());
}
write!(
&mut glue,
r#"
public abstract record Commands()
{{
{record_fields}
public static async Task<Commands?> FromContext(Context ctx)
{{
return ctx.Parameters.Callback() switch
{{
{match_branches}
_ => null,
}};
}}
}}
"#,
)?;
commands_seen.clear();
for command in &commands {
if commands_seen.contains(&command.cb) {
continue;
}
let mut command_params_fields = String::new();
let command_params = find_parameters(&command.tokens);
for param in &command_params {
writeln!(
&mut command_params_fields,
r#"public required {ty}{nullable} @{name};"#,
name = param.name().replace("-", "_"),
ty = get_param_ty(param.kind()),
nullable = param.is_optional().then_some("?").unwrap_or(""),
)?;
}
let mut command_flags_fields = String::new();
for flag in &command.flags {
if let Some(param) = flag.get_value() {
writeln!(
&mut command_flags_fields,
r#"public {ty}? @{name};"#,
name = flag.get_name().replace("-", "_"),
ty = get_param_ty(param.kind()),
)?;
} else {
writeln!(
&mut command_flags_fields,
r#"public required bool @{name};"#,
name = flag.get_name().replace("-", "_"),
)?;
}
}
let mut command_reply_format = String::new();
if command
.flags
.iter()
.any(|flag| flag.get_name() == "plaintext")
{
writeln!(
&mut command_reply_format,
r#"if (plaintext) return ReplyFormat.Plaintext;"#,
)?;
}
if command.flags.iter().any(|flag| flag.get_name() == "raw") {
writeln!(
&mut command_reply_format,
r#"if (raw) return ReplyFormat.Raw;"#,
)?;
}
command_reply_format.push_str("return ReplyFormat.Standard;\n");
let mut command_list_options = String::new();
let mut command_list_options_class = String::new();
let list_flags = command_definitions::utils::get_list_flags();
if list_flags.iter().all(|flag| command.flags.contains(&flag)) {
write!(&mut command_list_options_class, ": IHasListOptions")?;
writeln!(
&mut command_list_options,
r#"
public ListOptions GetListOptions(Context ctx, SystemId target)
{{
var directLookupCtx = ctx.DirectLookupContextFor(target);
var lookupCtx = ctx.LookupContextFor(target);
var p = new ListOptions();
p.Type = full ? ListType.Long : ListType.Short;
// Search description filter
p.SearchDescription = search_description;
// Sort property
if (by_name) p.SortProperty = SortProperty.Name;
if (by_display_name) p.SortProperty = SortProperty.DisplayName;
if (by_id) p.SortProperty = SortProperty.Hid;
if (by_message_count) p.SortProperty = SortProperty.MessageCount;
if (by_created) p.SortProperty = SortProperty.CreationDate;
if (by_last_fronted) p.SortProperty = SortProperty.LastSwitch;
if (by_last_message) p.SortProperty = SortProperty.LastMessage;
if (by_birthday) p.SortProperty = SortProperty.Birthdate;
if (random) p.SortProperty = SortProperty.Random;
// Sort reverse
p.Reverse = reverse;
// Privacy filter
if (all) p.PrivacyFilter = null;
if (private_only) p.PrivacyFilter = PrivacyLevel.Private;
// PERM CHECK: If we're trying to access non-public members of another system, error
if (p.PrivacyFilter != PrivacyLevel.Public && directLookupCtx != LookupContext.ByOwner)
// TODO: should this just return null instead of throwing or something? >.>
throw Errors.NotOwnInfo;
// this is for searching
p.Context = lookupCtx;
// Additional fields to include
p.IncludeLastSwitch = with_last_switch;
p.IncludeLastMessage = with_last_message;
p.IncludeMessageCount = with_message_count;
p.IncludeCreated = with_created;
p.IncludeAvatar = with_avatar;
p.IncludePronouns = with_pronouns;
p.IncludeDisplayName = with_display_name;
p.IncludeBirthday = with_birthday;
// Always show the sort property (unless short list and already showing something else)
if (p.Type != ListType.Short || p.includedCount == 0)
{{
if (p.SortProperty == SortProperty.DisplayName) p.IncludeDisplayName = true;
if (p.SortProperty == SortProperty.MessageCount) p.IncludeMessageCount = true;
if (p.SortProperty == SortProperty.CreationDate) p.IncludeCreated = true;
if (p.SortProperty == SortProperty.LastSwitch) p.IncludeLastSwitch = true;
if (p.SortProperty == SortProperty.LastMessage) p.IncludeLastMessage = true;
if (p.SortProperty == SortProperty.Birthdate) p.IncludeBirthday = true;
}}
p.AssertIsValid();
return p;
}}
"#,
)?;
}
write!(
&mut glue,
r#"
public class {command_name}Params
{{
{command_params_fields}
}}
public class {command_name}Flags {command_list_options_class}
{{
{command_flags_fields}
public ReplyFormat GetReplyFormat()
{{
{command_reply_format}
}}
{command_list_options}
}}
"#,
command_name = command_callback_to_name(&command.cb),
)?;
commands_seen.insert(command.cb.clone());
}
fs::write(write_location, glue)?;
Ok(())
}
fn command_callback_to_name(cb: &str) -> String {
cb.split("_")
.map(|w| w.chars().nth(0).unwrap().to_uppercase().collect::<String>() + &w[1..])
.collect()
}
fn get_param_ty(kind: ParameterKind) -> &'static str {
match kind {
ParameterKind::OpaqueString => "string",
ParameterKind::OpaqueInt => "int",
ParameterKind::MemberRef => "PKMember",
ParameterKind::MemberRefs => "List<PKMember>",
ParameterKind::GroupRef => "PKGroup",
ParameterKind::GroupRefs => "List<PKGroup>",
ParameterKind::SystemRef => "PKSystem",
ParameterKind::UserRef => "User",
ParameterKind::MemberPrivacyTarget => "MemberPrivacySubject",
ParameterKind::GroupPrivacyTarget => "GroupPrivacySubject",
ParameterKind::SystemPrivacyTarget => "SystemPrivacySubject",
ParameterKind::PrivacyLevel => "PrivacyLevel",
ParameterKind::Toggle => "bool",
ParameterKind::Avatar => "ParsedImage",
ParameterKind::MessageRef { .. } => "Message.Reference",
ParameterKind::ChannelRef => "Channel",
ParameterKind::GuildRef => "Guild",
ParameterKind::ProxySwitchAction => "SystemConfig.ProxySwitchAction",
}
}
fn get_param_param_ty(kind: ParameterKind) -> &'static str {
match kind {
ParameterKind::OpaqueString => "Opaque",
ParameterKind::OpaqueInt => "Number",
ParameterKind::MemberRef => "Member",
ParameterKind::MemberRefs => "Members",
ParameterKind::GroupRef => "Group",
ParameterKind::GroupRefs => "Groups",
ParameterKind::SystemRef => "System",
ParameterKind::UserRef => "User",
ParameterKind::MemberPrivacyTarget => "MemberPrivacyTarget",
ParameterKind::GroupPrivacyTarget => "GroupPrivacyTarget",
ParameterKind::SystemPrivacyTarget => "SystemPrivacyTarget",
ParameterKind::PrivacyLevel => "PrivacyLevel",
ParameterKind::Toggle => "Toggle",
ParameterKind::Avatar => "Avatar",
ParameterKind::MessageRef { .. } => "Message",
ParameterKind::ChannelRef => "Channel",
ParameterKind::GuildRef => "Guild",
ParameterKind::ProxySwitchAction => "ProxySwitchAction",
}
}
fn find_parameters(tokens: &[Token]) -> Vec<&Parameter> {
let mut result = Vec::new();
for token in tokens {
match token {
Token::Parameter(param) => result.push(param),
_ => {}
}
}
result
}

View file

@ -0,0 +1,2 @@
[bindings.csharp]
cdylib_name = "commands"

View file

@ -1,5 +1,4 @@
#![feature(if_let_guard)]
#![feature(duration_constructors)]
use chrono::Timelike;
use discord::gateway::cluster_config;

View file

@ -12,7 +12,7 @@ pk_macros = { path = "../macros" }
sentry = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
sqlx = { workspace = true, features = ["chrono"] }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true}

100
flake.lock generated
View file

@ -3,16 +3,16 @@
"crane": {
"flake": false,
"locked": {
"lastModified": 1727316705,
"narHash": "sha256-/mumx8AQ5xFuCJqxCIOFCHTVlxHkMT21idpbgbm/TIE=",
"lastModified": 1758758545,
"narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
"owner": "ipetkov",
"repo": "crane",
"rev": "5b03654ce046b5167e7b0bccbd8244cb56c16f0e",
"rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
"type": "github"
},
"original": {
"owner": "ipetkov",
"ref": "v0.19.0",
"ref": "v0.21.1",
"repo": "crane",
"type": "github"
}
@ -26,11 +26,11 @@
"pyproject-nix": "pyproject-nix"
},
"locked": {
"lastModified": 1754978539,
"narHash": "sha256-nrDovydywSKRbWim9Ynmgj8SBm8LK3DI2WuhIqzOHYI=",
"lastModified": 1765953015,
"narHash": "sha256-5FBZbbWR1Csp3Y2icfRkxMJw/a/5FGg8hCXej2//bbI=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "fbec3263cb4895ac86ee9506cdc4e6919a1a2214",
"rev": "69eb01fa0995e1e90add49d8ca5bcba213b0416f",
"type": "github"
},
"original": {
@ -62,7 +62,7 @@
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69"
},
"original": {
"type": "tarball",
@ -74,13 +74,13 @@
"locked": {
"lastModified": 1681286841,
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
"owner": "yusdacra",
"owner": "90-008",
"repo": "mk-naked-shell",
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
"type": "github"
},
"original": {
"owner": "yusdacra",
"owner": "90-008",
"repo": "mk-naked-shell",
"type": "github"
}
@ -104,26 +104,26 @@
]
},
"locked": {
"lastModified": 1756016279,
"narHash": "sha256-5BhsvOXsoMu4ZNe9HxQSynbbXm2FAZ0TIW5mWKlG5+Q=",
"owner": "yusdacra",
"lastModified": 1768285363,
"narHash": "sha256-n4dqIGCz2+/pyP0jtuTZxFTjuyBkgiKMwtOJrmbipDA=",
"owner": "90-008",
"repo": "nix-cargo-integration",
"rev": "4714a69e6de235cf750e6cc73f6a989cc7867579",
"rev": "90432aa96bd7bb603ff710ffa2c02959dc338bd3",
"type": "github"
},
"original": {
"owner": "yusdacra",
"owner": "90-008",
"repo": "nix-cargo-integration",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1755829505,
"narHash": "sha256-4/Jd+LkQ2ssw8luQVkqVs9spDBVE6h/u/hC/tzngsPo=",
"lastModified": 1768302833,
"narHash": "sha256-h5bRFy9bco+8QcK7rGoOiqMxMbmn21moTACofNLRMP4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f937f8ecd1c70efd7e9f90ba13dfb400cf559de4",
"rev": "61db79b0c6b838d9894923920b612048e1201926",
"type": "github"
},
"original": {
@ -134,11 +134,11 @@
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1753579242,
"narHash": "sha256-zvaMGVn14/Zz8hnp4VWT9xVnhc8vuL3TStRqwk22biA=",
"lastModified": 1765674936,
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "0f36c44e01a6129be94e3ade315a5883f0228a6e",
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
"type": "github"
},
"original": {
@ -152,11 +152,11 @@
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1754487366,
"narHash": "sha256-pHYj8gUBapuUzKV/kN/tR3Zvqc7o6gdFB9XKXIp1SQ8=",
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "af66ad14b28a127c5c0f3bbb298218fc63528a18",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
@ -167,11 +167,11 @@
},
"process-compose": {
"locked": {
"lastModified": 1749418557,
"narHash": "sha256-wJHHckWz4Gvj8HXtM5WVJzSKXAEPvskQANVoRiu2w1w=",
"lastModified": 1767863885,
"narHash": "sha256-XXekPAxzbv1DmHFo3Elmj/vDnvWc1V0jdDUvM0/Wf7k=",
"owner": "Platonic-Systems",
"repo": "process-compose-flake",
"rev": "91dcc48a6298e47e2441ec76df711f4e38eab94e",
"rev": "99bea96cf269cfd235833ebdf645b567069fd398",
"type": "github"
},
"original": {
@ -211,11 +211,11 @@
]
},
"locked": {
"lastModified": 1752481895,
"narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=",
"lastModified": 1763017646,
"narHash": "sha256-Z+R2lveIp6Skn1VPH3taQIuMhABg1IizJd8oVdmdHsQ=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "16ee295c25107a94e59a7fc7f2e5322851781162",
"rev": "47bd6f296502842643078d66128f7b5e5370790c",
"type": "github"
},
"original": {
@ -234,7 +234,8 @@
"process-compose": "process-compose",
"services": "services",
"systems": "systems",
"treefmt": "treefmt"
"treefmt": "treefmt",
"uniffi-bindgen-cs": "uniffi-bindgen-cs"
}
},
"rust-overlay": {
@ -245,11 +246,11 @@
]
},
"locked": {
"lastModified": 1756003222,
"narHash": "sha256-lmEMhIIbjt8Wp1EYbNqCojuU9ygyDFv8Tu0X1k8qIMc=",
"lastModified": 1768272338,
"narHash": "sha256-Tg/kL8eKMpZtceDvBDQYU8zowgpr7ucFRnpP/AtfuRM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "88ceedecde53e809b4bf8b5fd10d181889d9bac7",
"rev": "03dda130a8701b08b0347fcaf850a190c53a3c1e",
"type": "github"
},
"original": {
@ -260,11 +261,11 @@
},
"services": {
"locked": {
"lastModified": 1755996515,
"narHash": "sha256-1RQQIDhshp1g4PP5teqibcFLfk/ckTDOJRckecAHiU0=",
"lastModified": 1765168239,
"narHash": "sha256-NZ7H4lbbytPNwe4ZyvovycuS1BMBFwJrptgX7NiF+F0=",
"owner": "juspay",
"repo": "services-flake",
"rev": "e316d6b994fd153f0c35d54bd07d60e53f0ad9a9",
"rev": "8b6244f2b310f229568d5cadf7dfcb5ebe6f8bda",
"type": "github"
},
"original": {
@ -317,11 +318,11 @@
]
},
"locked": {
"lastModified": 1755934250,
"narHash": "sha256-CsDojnMgYsfshQw3t4zjRUkmMmUdZGthl16bXVWgRYU=",
"lastModified": 1768158989,
"narHash": "sha256-67vyT1+xClLldnumAzCTBvU0jLZ1YBcf4vANRWP3+Ak=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "74e1a52d5bd9430312f8d1b8b0354c92c17453e5",
"rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
"type": "github"
},
"original": {
@ -329,6 +330,25 @@
"repo": "treefmt-nix",
"type": "github"
}
},
"uniffi-bindgen-cs": {
"flake": false,
"locked": {
"lastModified": 1759932560,
"narHash": "sha256-CnfB7/n1W5hbeC+cniJZthkpWO9kLyog/q5ldL6yS9g=",
"ref": "refs/heads/main",
"rev": "66c316454a04c025a88f8cc8af495bfc2b422d2f",
"revCount": 181,
"submodules": true,
"type": "git",
"url": "https://github.com/90-008/uniffi-bindgen-cs"
},
"original": {
"ref": "refs/heads/main",
"submodules": true,
"type": "git",
"url": "https://github.com/90-008/uniffi-bindgen-cs"
}
}
},
"root": "root",

312
flake.nix
View file

@ -11,11 +11,13 @@
# rust
d2n.url = "github:nix-community/dream2nix";
d2n.inputs.nixpkgs.follows = "nixpkgs";
nci.url = "github:yusdacra/nix-cargo-integration";
nci.url = "github:90-008/nix-cargo-integration";
nci.inputs.parts.follows = "parts";
nci.inputs.nixpkgs.follows = "nixpkgs";
nci.inputs.dream2nix.follows = "d2n";
nci.inputs.treefmt.follows = "treefmt";
uniffi-bindgen-cs.url = "git+https://github.com/90-008/uniffi-bindgen-cs?ref=refs/heads/main&submodules=1";
uniffi-bindgen-cs.flake = false;
# misc
treefmt.url = "github:numtide/treefmt-nix";
treefmt.inputs.nixpkgs.follows = "nixpkgs";
@ -37,210 +39,188 @@
self',
pkgs,
lib,
system,
...
}:
let
# this is used as devshell for bot, and in the process-compose processes as environment
mkBotEnv =
cmd:
pkgs.buildFHSEnv {
name = "env";
targetPkgs =
pkgs: with pkgs; [
coreutils
git
dotnet-sdk_8
gcc
omnisharp-roslyn
bashInteractive
];
runScript = cmd;
};
uniffi-bindgen-cs = config.nci.lib.buildCrate {
src = inp.uniffi-bindgen-cs;
cratePath = "bindgen";
};
rustOutputs = config.nci.outputs;
composeCfg = config.process-compose."dev";
sourceDotenv = ''
# shellcheck disable=SC1091
[[ -f ".env" ]] && echo "sourcing .env file..." && set -a && source .env && set +a
'';
in
{
# _module.args.pkgs = import inp.nixpkgs {
# inherit system;
# config.permittedInsecurePackages = [ "dotnet-sdk-6.0.428" ];
# };
treefmt = {
projectRootFile = "flake.nix";
programs.nixfmt.enable = true;
};
nci.toolchainConfig = ./rust-toolchain.toml;
nci.projects."pluralkit-services" = {
nci.projects."pk-services" = {
path = ./.;
export = false;
};
# nci.crates."gateway" = {
# depsDrvConfig.mkDerivation = {
# nativeBuildInputs = [ pkgs.protobuf ];
# };
# drvConfig.mkDerivation = {
# nativeBuildInputs = [ pkgs.protobuf ];
# };
# };
nci.crates."commands" = rec {
depsDrvConfig.env = {
# we don't really need this since the lib is just used to generate the bindings
doNotRemoveReferencesToVendorDir = true;
};
depsDrvConfig.mkDerivation = {
# also not really needed
dontPatchShebangs = true;
};
drvConfig = depsDrvConfig;
};
apps = {
generate-command-parser-bindings.program = pkgs.writeShellApplication {
name = "generate-command-parser-bindings";
runtimeInputs = [
(config.nci.toolchains.mkBuild pkgs)
self'.devShells.services.stdenv.cc
pkgs.dotnet-sdk_8
pkgs.csharpier
pkgs.coreutils
uniffi-bindgen-cs
];
text = ''
set -x
commandslib="''${1:-}"
if [ "$commandslib" == "" ]; then
cargo -Z unstable-options build --package commands --lib --release --artifact-dir obj/
commandslib="obj/libcommands.so"
else
cp -f "$commandslib" obj/
fi
uniffi-bindgen-cs "$commandslib" --library --out-dir="''${2:-./PluralKit.Bot}"
cargo run --package commands --bin write_cs_glue -- "''${2:-./PluralKit.Bot}"/commandtypes.cs
'';
};
};
# TODO: expose other rust packages after it's verified they build and work properly
packages = lib.genAttrs ["gateway"] (name: rustOutputs.${name}.packages.release);
packages = lib.genAttrs [ "gateway" "commands" ] (name: rustOutputs.${name}.packages.release);
# TODO: package the bot itself (dotnet)
devShells = {
services = rustOutputs."pluralkit-services".devShell;
bot = (mkBotEnv "bash").env;
devShells = rec {
services = rustOutputs."pk-services".devShell;
bot = pkgs.mkShell {
name = "pkbot-devshell";
nativeBuildInputs = with pkgs; [
coreutils
git
dotnet-sdk_8
gcc
omnisharp-roslyn
bashInteractive
postgresql
];
};
all = (pkgs.mkShell.override { stdenv = services.stdenv; }) {
name = "pk-devshell";
nativeBuildInputs = bot.nativeBuildInputs ++ services.nativeBuildInputs;
shellHook = ''
${sourceDotenv}
'';
};
docs = pkgs.mkShellNoCC {
buildInputs = with pkgs; [ nodejs yarn ];
NODE_OPTIONS = "--openssl-legacy-provider";
};
};
process-compose."dev" = let
dataDir = ".nix-process-compose";
sourceDotenv = ''
# shellcheck disable=SC2046
[[ -f ".env" ]] && echo "sourcing .env file..." && export $(xargs < .env)
'';
in {
imports = [ inp.services.processComposeModules.default ];
process-compose."dev" =
let
dataDir = ".nix-process-compose";
in
{
imports = [ inp.services.processComposeModules.default ];
settings.log_location = "${dataDir}/log";
settings.log_location = "${dataDir}/log";
settings.environment = {
DOTNET_CLI_TELEMETRY_OPTOUT = "1";
NODE_OPTIONS = "--openssl-legacy-provider";
};
settings.environment = {
DOTNET_CLI_TELEMETRY_OPTOUT = "1";
NODE_OPTIONS = "--openssl-legacy-provider";
};
services.redis."redis" = {
enable = true;
dataDir = "${dataDir}/redis";
};
services.postgres."postgres" = {
enable = true;
dataDir = "${dataDir}/postgres";
initialScript.before = ''
CREATE DATABASE pluralkit;
CREATE USER postgres WITH password 'postgres';
GRANT ALL PRIVILEGES ON DATABASE pluralkit TO postgres;
ALTER DATABASE pluralkit OWNER TO postgres;
'';
};
services.redis."redis" = {
enable = true;
dataDir = "${dataDir}/redis";
};
services.postgres."postgres" = {
enable = true;
dataDir = "${dataDir}/postgres";
initialScript.before = ''
CREATE DATABASE pluralkit;
CREATE USER postgres WITH password 'postgres';
GRANT ALL PRIVILEGES ON DATABASE pluralkit TO postgres;
ALTER DATABASE pluralkit OWNER TO postgres;
'';
};
settings.processes =
let
procCfg = composeCfg.settings.processes;
mkServiceInitProcess =
{
name,
inputs ? [ ],
...
}:
let
shell = rustOutputs.${name}.devShell;
in
{
settings.processes =
let
mkServiceProcess =
name: attrs:
{
command = pkgs.writeShellApplication {
name = "pluralkit-${name}";
runtimeInputs = [ pkgs.coreutils ];
text = ''
${sourceDotenv}
set -x
nix develop .#services -c cargo run --package ${name}
'';
};
} // attrs;
in
{
### migrations ###
pluralkit-migrate = mkServiceProcess "migrate" {
depends_on.postgres.condition = "process_healthy";
};
### bot ###
pluralkit-bot = {
command = pkgs.writeShellApplication {
name = "pluralkit-${name}-init";
runtimeInputs =
(with pkgs; [
coreutils
shell.stdenv.cc
])
++ shell.nativeBuildInputs
++ inputs;
name = "pluralkit-bot";
runtimeInputs = [ pkgs.coreutils ];
text = ''
${sourceDotenv}
set -x
exec cargo build --bin ${name}
${self'.apps.generate-command-parser-bindings.program}
nix develop .#bot -c bash -c "dotnet build ./PluralKit.Bot/PluralKit.Bot.csproj -c Release -o obj/ && dotnet obj/PluralKit.Bot.dll"
'';
};
depends_on.postgres.condition = "process_healthy";
depends_on.redis.condition = "process_healthy";
depends_on.pluralkit-gateway.condition = "process_log_ready";
depends_on.pluralkit-migrate.condition = "process_completed_successfully";
# TODO: add liveness check
ready_log_line = "Connected! All is good (probably).";
availability.restart = "on_failure";
availability.max_restarts = 3;
};
in
{
### bot ###
pluralkit-bot-init = {
command = pkgs.writeShellApplication {
name = "pluralkit-bot-init";
runtimeInputs = [
pkgs.coreutils
pkgs.git
];
text = ''
${sourceDotenv}
set -x
exec ${mkBotEnv "dotnet build -c Release -o obj/"}/bin/env
'';
### gateway ###
pluralkit-gateway = mkServiceProcess "gateway" {
depends_on.postgres.condition = "process_healthy";
depends_on.redis.condition = "process_healthy";
# configure health checks
# TODO: don't assume port?
liveness_probe.exec.command = ''${pkgs.curl}/bin/curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/stats | ${pkgs.busybox}/bin/grep "302"'';
liveness_probe.period_seconds = 7;
# TODO: add actual listening or running line in gateway
ready_log_line = "Running ";
availability.restart = "on_failure";
availability.max_restarts = 3;
};
# TODO: add the rest of the services
};
pluralkit-bot = {
command = pkgs.writeShellApplication {
name = "pluralkit-bot";
runtimeInputs = [ pkgs.coreutils ];
text = ''
${sourceDotenv}
set -x
exec ${mkBotEnv "dotnet obj/PluralKit.Bot.dll"}/bin/env
'';
};
depends_on.pluralkit-bot-init.condition = "process_completed_successfully";
depends_on.postgres.condition = "process_healthy";
depends_on.redis.condition = "process_healthy";
depends_on.pluralkit-gateway.condition = "process_healthy";
# TODO: add liveness check
ready_log_line = "Received Ready";
};
### migrations ###
pluralkit-migrate-init = mkServiceInitProcess {
name = "migrate";
};
pluralkit-migrate = {
command = pkgs.writeShellApplication {
name = "pluralkit-migrate";
text = ''
${sourceDotenv}
set -x
exec target/debug/migrate
'';
};
depends_on.postgres.condition = "process_healthy";
depends_on.pluralkit-migrate-init.condition = "process_completed_successfully";
};
### gateway ###
pluralkit-gateway-init = mkServiceInitProcess {
name = "gateway";
};
pluralkit-gateway = {
command = pkgs.writeShellApplication {
name = "pluralkit-gateway";
runtimeInputs = with pkgs; [
coreutils
curl
gnugrep
];
text = ''
${sourceDotenv}
set -x
exec target/debug/gateway
'';
};
depends_on.postgres.condition = "process_healthy";
depends_on.redis.condition = "process_healthy";
depends_on.pluralkit-gateway-init.condition = "process_completed_successfully";
# configure health checks
# TODO: don't assume port?
liveness_probe.exec.command = ''curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/stats | grep "302"'';
liveness_probe.period_seconds = 5;
readiness_probe.exec.command = procCfg.pluralkit-gateway.liveness_probe.exec.command;
readiness_probe.period_seconds = 5;
readiness_probe.initial_delay_seconds = 3;
};
# TODO: add the rest of the services
};
};
};
};
};
}