diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 2446940e..ab46b58e 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -56,6 +56,19 @@ public partial class CommandTree Commands.SystemShowPronouns(var param, var flags) => ctx.Execute(SystemPronouns, m => m.ShowPronouns(ctx, param.target, flags.GetReplyFormat())), Commands.SystemClearPronouns(var param, var flags) => ctx.Execute(SystemPronouns, m => m.ClearPronouns(ctx, ctx.System, flags.yes)), Commands.SystemChangePronouns(var param, _) => ctx.Execute(SystemPronouns, m => m.ChangePronouns(ctx, ctx.System, param.pronouns)), + Commands.SystemShowAvatarSelf(_, var flags) => ((Func)(() => + { + // 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(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, image)); + // if no attachment show the avatar like intended + return ctx.Execute(SystemAvatar, m => m.ShowAvatar(ctx, ctx.System, flags.GetReplyFormat())); + }))(), + Commands.SystemShowAvatar(var param, var flags) => ctx.Execute(SystemAvatar, m => m.ShowAvatar(ctx, param.target, flags.GetReplyFormat())), + Commands.SystemClearAvatar(var param, var flags) => ctx.Execute(SystemAvatar, m => m.ClearAvatar(ctx, ctx.System, flags.yes)), + Commands.SystemChangeAvatar(var param, _) => ctx.Execute(SystemAvatar, m => m.ChangeAvatar(ctx, ctx.System, param.avatar)), _ => // this should only ever occur when deving if commands are not implemented... ctx.Reply( @@ -306,8 +319,6 @@ public partial class CommandTree { if (ctx.Match("banner", "splash", "cover")) await ctx.CheckSystem(target).Execute(SystemBannerImage, m => m.BannerImage(ctx, target)); - else if (ctx.Match("avatar", "picture", "icon", "image", "pic", "pfp")) - await ctx.CheckSystem(target).Execute(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(SystemServerAvatar, m => m.ServerAvatar(ctx, target)); diff --git a/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs index 3501b53b..0c99c94f 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextAvatarExt.cs @@ -6,34 +6,8 @@ namespace PluralKit.Bot; public static class ContextAvatarExt { - public static async Task 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 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,33 @@ public static class ContextAvatarExt // and if there are no attachments (which would have been caught just before) return null; } + public static async Task 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 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 static async Task MatchImage(this Context ctx) + { + throw new NotImplementedException(); + } } public struct ParsedImage diff --git a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs index 104f1ad8..6eba5039 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextEntityArgumentsExt.cs @@ -10,6 +10,14 @@ namespace PluralKit.Bot; public static class ContextEntityArgumentsExt { + public static async Task ParseUser(this Context ctx, string arg) + { + if (arg.TryParseMention(out var id)) + return await ctx.Cache.GetOrFetchUser(ctx.Rest, id); + + return null; + } + public static async Task MatchUser(this Context ctx) { var text = ctx.PeekArgument(); diff --git a/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs index 4a34a43a..cf2fa6c7 100644 --- a/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs +++ b/PluralKit.Bot/CommandSystem/Context/ContextParametersExt.cs @@ -51,4 +51,12 @@ public static class ContextParametersExt param => (param as Parameter.Toggle)?.value ); } + + public static async Task ParamResolveAvatar(this Context ctx, string param_name) + { + return await ctx.Parameters.ResolveParameter( + ctx, param_name, + param => (param as Parameter.Avatar)?.avatar + ); + } } \ No newline at end of file diff --git a/PluralKit.Bot/CommandSystem/Parameters.cs b/PluralKit.Bot/CommandSystem/Parameters.cs index d4aa2e79..48aaa583 100644 --- a/PluralKit.Bot/CommandSystem/Parameters.cs +++ b/PluralKit.Bot/CommandSystem/Parameters.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Myriad.Types; using PluralKit.Core; using uniffi.commands; @@ -13,6 +14,7 @@ public abstract record Parameter() public record PrivacyLevel(string level): Parameter; public record Toggle(bool value): Parameter; public record Opaque(string value): Parameter; + public record Avatar(ParsedImage avatar): Parameter; } public class Parameters @@ -79,6 +81,8 @@ public class Parameters return new Parameter.Toggle(toggle.toggle); case uniffi.commands.Parameter.OpaqueString opaque: return new Parameter.Opaque(opaque.raw); + case uniffi.commands.Parameter.Avatar avatar: + return new Parameter.Avatar(await ctx.GetUserPfp(avatar.avatar) ?? ctx.ParseImage(avatar.avatar)); } return null; } diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index fd46a6e2..582d0df3 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -542,90 +542,82 @@ public class SystemEdit + $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters." : "")); } - public async Task Avatar(Context ctx, PKSystem target) + public async Task ClearAvatar(Context ctx, PKSystem target, bool flagConfirmYes) { - async Task ClearIcon() - { - ctx.CheckOwnSystem(target); + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + if (await ctx.ConfirmClear("your system's icon", flagConfirmYes)) + { await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = null }); await ctx.Reply($"{Emojis.Success} System icon cleared."); } + } - async Task SetIcon(ParsedImage img) + public async Task ShowAvatar(Context ctx, PKSystem target, ReplyFormat format) + { + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); + var isOwnSystem = target.Id == ctx.System?.Id; + + if ((target.AvatarUrl?.Trim() ?? "").Length > 0) { - ctx.CheckOwnSystem(target); + if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) + throw new PKSyntaxError("This system does not have an icon set or it is private."); - img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); - await _avatarHosting.VerifyAvatarOrThrow(img.Url); - - await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url }); - - var msg = img.Source switch + switch (format) { - AvatarSource.User => - $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", - AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", - AvatarSource.HostedCdn => $"{Emojis.Success} System icon changed to attached image.", - AvatarSource.Attachment => - $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system 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)); - } - - async Task ShowIcon() - { - if ((target.AvatarUrl?.Trim() ?? "").Length > 0) - { - if (!target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.Id))) - throw new PKSyntaxError("This system does not have an icon set or it is private."); - switch (ctx.MatchFormat()) - { - case ReplyFormat.Raw: - await ctx.Reply($"`{target.AvatarUrl.TryGetCleanCdnUrl()}`"); - break; - case ReplyFormat.Plaintext: - var ebP = new EmbedBuilder() - .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); - await ctx.Reply(text: $"<{target.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); - break; - default: - var ebS = new EmbedBuilder() - .Title("System icon") - .Image(new Embed.EmbedImage(target.AvatarUrl.TryGetCleanCdnUrl())); - if (target.Id == ctx.System?.Id) - ebS.Description($"To clear, use `{ctx.DefaultPrefix}system icon clear`."); - await ctx.Reply(embed: ebS.Build()); - break; - } - } - else - { - var isOwner = target.Id == ctx.System?.Id; - throw new PKSyntaxError( - $"This system does not have an icon set{(isOwner ? "" : " or it is private")}." - + (isOwner ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + case ReplyFormat.Raw: + await ctx.Reply($"`{target.AvatarUrl.TryGetCleanCdnUrl()}`"); + break; + case ReplyFormat.Plaintext: + var ebP = new EmbedBuilder() + .Description($"Showing icon for system {target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)"); + await ctx.Reply(text: $"<{target.AvatarUrl.TryGetCleanCdnUrl()}>", embed: ebP.Build()); + break; + default: + var ebS = new EmbedBuilder() + .Title("System icon") + .Image(new Embed.EmbedImage(target.AvatarUrl.TryGetCleanCdnUrl())); + if (target.Id == ctx.System?.Id) + ebS.Description($"To clear, use `{ctx.DefaultPrefix}system icon clear`."); + await ctx.Reply(embed: ebS.Build()); + break; } } - - if (target != null && target?.Id != ctx.System?.Id) - { - await ShowIcon(); - return; - } - - if (ctx.MatchClear() && await ctx.ConfirmClear("your system's icon")) - await ClearIcon(); - else if (await ctx.MatchImage() is { } img) - await SetIcon(img); else - await ShowIcon(); + { + throw new PKSyntaxError( + $"This system does not have an icon set{(isOwnSystem ? "" : " or it is private")}." + + (isOwnSystem ? " Set one by attaching an image to this command, or by passing an image URL or @mention." : "")); + } + } + + public async Task ChangeAvatar(Context ctx, PKSystem target, ParsedImage img) + { + ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy); + ctx.CheckSystem().CheckOwnSystem(target); + + img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System); + await _avatarHosting.VerifyAvatarOrThrow(img.Url); + + await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url }); + + var msg = img.Source switch + { + AvatarSource.User => + $"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.", + AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.", + AvatarSource.HostedCdn => $"{Emojis.Success} System icon changed to attached image.", + AvatarSource.Attachment => + $"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system 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 ServerAvatar(Context ctx, PKSystem target) diff --git a/crates/command_definitions/src/system.rs b/crates/command_definitions/src/system.rs index 08386c94..0980ca87 100644 --- a/crates/command_definitions/src/system.rs +++ b/crates/command_definitions/src/system.rs @@ -141,6 +141,23 @@ pub fn edit() -> impl Iterator { ] .into_iter(); + let system_avatar = tokens!(system_target, ("avatar", ["pfp"])); + let system_avatar_cmd = + [command!(system_avatar => "system_show_avatar").help("Shows the system's avatar")] + .into_iter(); + + let system_avatar_self = tokens!(system, ("avatar", ["pfp"])); + let system_avatar_self_cmd = [ + command!(system_avatar_self => "system_show_avatar_self") + .help("Shows your system's avatar"), + command!(system_avatar_self, ("clear", ["c"]) => "system_clear_avatar") + .flag(("yes", ["y"])) + .help("Clears your system's avatar"), + command!(system_avatar_self, ("avatar", Avatar) => "system_change_avatar") + .help("Changes your system's avatar"), + ] + .into_iter(); + system_new_cmd .chain(system_name_self_cmd) .chain(system_server_name_self_cmd) @@ -149,6 +166,7 @@ pub fn edit() -> impl Iterator { .chain(system_tag_self_cmd) .chain(system_server_tag_self_cmd) .chain(system_pronouns_self_cmd) + .chain(system_avatar_self_cmd) .chain(system_name_cmd) .chain(system_server_name_cmd) .chain(system_description_cmd) @@ -156,5 +174,6 @@ pub fn edit() -> impl Iterator { .chain(system_tag_cmd) .chain(system_server_tag_cmd) .chain(system_pronouns_cmd) + .chain(system_avatar_cmd) .chain(system_info_cmd) } diff --git a/crates/command_parser/src/parameter.rs b/crates/command_parser/src/parameter.rs index b2c9d5ff..14e79437 100644 --- a/crates/command_parser/src/parameter.rs +++ b/crates/command_parser/src/parameter.rs @@ -15,6 +15,7 @@ pub enum ParameterValue { MemberPrivacyTarget(String), PrivacyLevel(String), Toggle(bool), + Avatar(String), } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -44,6 +45,7 @@ impl Display for Parameter { ParameterKind::MemberPrivacyTarget => write!(f, ""), ParameterKind::PrivacyLevel => write!(f, "[privacy level]"), ParameterKind::Toggle => write!(f, "on/off"), + ParameterKind::Avatar => write!(f, ""), } } } @@ -75,6 +77,7 @@ pub enum ParameterKind { MemberPrivacyTarget, PrivacyLevel, Toggle, + Avatar, } impl ParameterKind { @@ -87,6 +90,7 @@ impl ParameterKind { ParameterKind::MemberPrivacyTarget => "member_privacy_target", ParameterKind::PrivacyLevel => "privacy_level", ParameterKind::Toggle => "toggle", + ParameterKind::Avatar => "avatar", } } @@ -96,6 +100,7 @@ impl ParameterKind { pub(crate) fn match_value(&self, input: &str) -> Result { match self { + // TODO: actually parse image url ParameterKind::OpaqueString | ParameterKind::OpaqueStringRemainder => { Ok(ParameterValue::OpaqueString(input.into())) } @@ -108,6 +113,7 @@ impl ParameterKind { ParameterKind::Toggle => { Toggle::from_str(input).map(|t| ParameterValue::Toggle(t.into())) } + ParameterKind::Avatar => Ok(ParameterValue::Avatar(input.into())), } } } diff --git a/crates/commands/src/bin/write_cs_glue.rs b/crates/commands/src/bin/write_cs_glue.rs index 1d64a91a..6eb1f593 100644 --- a/crates/commands/src/bin/write_cs_glue.rs +++ b/crates/commands/src/bin/write_cs_glue.rs @@ -165,6 +165,7 @@ fn get_param_ty(kind: ParameterKind) -> &'static str { ParameterKind::MemberPrivacyTarget => "MemberPrivacySubject", ParameterKind::PrivacyLevel => "string", ParameterKind::Toggle => "bool", + ParameterKind::Avatar => "ParsedImage", } } @@ -176,6 +177,7 @@ fn get_param_param_ty(kind: ParameterKind) -> &'static str { ParameterKind::MemberPrivacyTarget => "MemberPrivacyTarget", ParameterKind::PrivacyLevel => "PrivacyLevel", ParameterKind::Toggle => "Toggle", + ParameterKind::Avatar => "Avatar", } } diff --git a/crates/commands/src/commands.udl b/crates/commands/src/commands.udl index d1b5ae1c..5ab50c63 100644 --- a/crates/commands/src/commands.udl +++ b/crates/commands/src/commands.udl @@ -14,6 +14,7 @@ interface Parameter { PrivacyLevel(string level); OpaqueString(string raw); Toggle(boolean toggle); + Avatar(string avatar); }; dictionary ParsedCommand { string command_ref; diff --git a/crates/commands/src/lib.rs b/crates/commands/src/lib.rs index 21c1db13..0af12856 100644 --- a/crates/commands/src/lib.rs +++ b/crates/commands/src/lib.rs @@ -28,6 +28,7 @@ pub enum Parameter { PrivacyLevel { level: String }, OpaqueString { raw: String }, Toggle { toggle: bool }, + Avatar { avatar: String }, } impl From for Parameter { @@ -39,6 +40,7 @@ impl From for Parameter { ParameterValue::PrivacyLevel(level) => Self::PrivacyLevel { level }, ParameterValue::OpaqueString(raw) => Self::OpaqueString { raw }, ParameterValue::Toggle(toggle) => Self::Toggle { toggle }, + ParameterValue::Avatar(avatar) => Self::Avatar { avatar }, } } }