feat: implement system avatar commands

This commit is contained in:
dusk 2025-04-04 03:50:07 +09:00
parent 293570c91c
commit b62340cbb3
No known key found for this signature in database
11 changed files with 155 additions and 101 deletions

View file

@ -56,6 +56,19 @@ public partial class CommandTree
Commands.SystemShowPronouns(var param, var flags) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ShowPronouns(ctx, param.target, flags.GetReplyFormat())), Commands.SystemShowPronouns(var param, var flags) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ShowPronouns(ctx, param.target, flags.GetReplyFormat())),
Commands.SystemClearPronouns(var param, var flags) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ClearPronouns(ctx, ctx.System, flags.yes)), 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.SystemChangePronouns(var param, _) => ctx.Execute<SystemEdit>(SystemPronouns, m => m.ChangePronouns(ctx, ctx.System, param.pronouns)),
Commands.SystemShowAvatarSelf(_, var flags) => ((Func<Task>)(() =>
{
// 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));
// if no attachment show the avatar like intended
return ctx.Execute<SystemEdit>(SystemAvatar, m => m.ShowAvatar(ctx, ctx.System, flags.GetReplyFormat()));
}))(),
Commands.SystemShowAvatar(var param, var flags) => ctx.Execute<SystemEdit>(SystemAvatar, m => m.ShowAvatar(ctx, param.target, 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)),
_ => _ =>
// this should only ever occur when deving if commands are not implemented... // this should only ever occur when deving if commands are not implemented...
ctx.Reply( ctx.Reply(
@ -306,8 +319,6 @@ public partial class CommandTree
{ {
if (ctx.Match("banner", "splash", "cover")) if (ctx.Match("banner", "splash", "cover"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemBannerImage, m => m.BannerImage(ctx, target)); 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", else if (ctx.Match("serveravatar", "sa", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic",
"guildavatar", "guildpic", "guildicon", "sicon", "spfp")) "guildavatar", "guildpic", "guildicon", "sicon", "spfp"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemServerAvatar, m => m.ServerAvatar(ctx, target)); await ctx.CheckSystem(target).Execute<SystemEdit>(SystemServerAvatar, m => m.ServerAvatar(ctx, target));

View file

@ -6,34 +6,8 @@ namespace PluralKit.Bot;
public static class ContextAvatarExt 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 we have an attachment, use that
if (ctx.Message.Attachments.FirstOrDefault() is { } attachment) 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) // and if there are no attachments (which would have been caught just before)
return null; 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 static async Task<ParsedImage?> MatchImage(this Context ctx)
{
throw new NotImplementedException();
}
} }
public struct ParsedImage public struct ParsedImage

View file

@ -10,6 +10,14 @@ namespace PluralKit.Bot;
public static class ContextEntityArgumentsExt public static class ContextEntityArgumentsExt
{ {
public static async Task<User> 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<User> MatchUser(this Context ctx) public static async Task<User> MatchUser(this Context ctx)
{ {
var text = ctx.PeekArgument(); var text = ctx.PeekArgument();

View file

@ -51,4 +51,12 @@ public static class ContextParametersExt
param => (param as Parameter.Toggle)?.value 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
);
}
} }

View file

@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Myriad.Types;
using PluralKit.Core; using PluralKit.Core;
using uniffi.commands; using uniffi.commands;
@ -13,6 +14,7 @@ public abstract record Parameter()
public record PrivacyLevel(string level): Parameter; public record PrivacyLevel(string level): Parameter;
public record Toggle(bool value): Parameter; public record Toggle(bool value): Parameter;
public record Opaque(string value): Parameter; public record Opaque(string value): Parameter;
public record Avatar(ParsedImage avatar): Parameter;
} }
public class Parameters public class Parameters
@ -79,6 +81,8 @@ public class Parameters
return new Parameter.Toggle(toggle.toggle); return new Parameter.Toggle(toggle.toggle);
case uniffi.commands.Parameter.OpaqueString opaque: case uniffi.commands.Parameter.OpaqueString opaque:
return new Parameter.Opaque(opaque.raw); 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; return null;
} }

View file

@ -542,19 +542,60 @@ public class SystemEdit
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters." : "")); + $" 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.CheckSystemPrivacy(target.Id, target.AvatarPrivacy);
{ ctx.CheckSystem().CheckOwnSystem(target);
ctx.CheckOwnSystem(target);
if (await ctx.ConfirmClear("your system's icon", flagConfirmYes))
{
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = null }); await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = null });
await ctx.Reply($"{Emojis.Success} System icon cleared."); await ctx.Reply($"{Emojis.Success} System icon cleared.");
} }
}
async Task SetIcon(ParsedImage img) public async Task ShowAvatar(Context ctx, PKSystem target, ReplyFormat format)
{ {
ctx.CheckOwnSystem(target); ctx.CheckSystemPrivacy(target.Id, target.AvatarPrivacy);
var isOwnSystem = target.Id == ctx.System?.Id;
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 (format)
{
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
{
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); img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await _avatarHosting.VerifyAvatarOrThrow(img.Url); await _avatarHosting.VerifyAvatarOrThrow(img.Url);
@ -579,55 +620,6 @@ public class SystemEdit
: ctx.Reply(msg)); : 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." : ""));
}
}
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();
}
public async Task ServerAvatar(Context ctx, PKSystem target) public async Task ServerAvatar(Context ctx, PKSystem target)
{ {

View file

@ -141,6 +141,23 @@ pub fn edit() -> impl Iterator<Item = Command> {
] ]
.into_iter(); .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 system_new_cmd
.chain(system_name_self_cmd) .chain(system_name_self_cmd)
.chain(system_server_name_self_cmd) .chain(system_server_name_self_cmd)
@ -149,6 +166,7 @@ pub fn edit() -> impl Iterator<Item = Command> {
.chain(system_tag_self_cmd) .chain(system_tag_self_cmd)
.chain(system_server_tag_self_cmd) .chain(system_server_tag_self_cmd)
.chain(system_pronouns_self_cmd) .chain(system_pronouns_self_cmd)
.chain(system_avatar_self_cmd)
.chain(system_name_cmd) .chain(system_name_cmd)
.chain(system_server_name_cmd) .chain(system_server_name_cmd)
.chain(system_description_cmd) .chain(system_description_cmd)
@ -156,5 +174,6 @@ pub fn edit() -> impl Iterator<Item = Command> {
.chain(system_tag_cmd) .chain(system_tag_cmd)
.chain(system_server_tag_cmd) .chain(system_server_tag_cmd)
.chain(system_pronouns_cmd) .chain(system_pronouns_cmd)
.chain(system_avatar_cmd)
.chain(system_info_cmd) .chain(system_info_cmd)
} }

View file

@ -15,6 +15,7 @@ pub enum ParameterValue {
MemberPrivacyTarget(String), MemberPrivacyTarget(String),
PrivacyLevel(String), PrivacyLevel(String),
Toggle(bool), Toggle(bool),
Avatar(String),
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@ -44,6 +45,7 @@ impl Display for Parameter {
ParameterKind::MemberPrivacyTarget => write!(f, "<privacy target>"), ParameterKind::MemberPrivacyTarget => write!(f, "<privacy target>"),
ParameterKind::PrivacyLevel => write!(f, "[privacy level]"), ParameterKind::PrivacyLevel => write!(f, "[privacy level]"),
ParameterKind::Toggle => write!(f, "on/off"), ParameterKind::Toggle => write!(f, "on/off"),
ParameterKind::Avatar => write!(f, "<url|@mention>"),
} }
} }
} }
@ -75,6 +77,7 @@ pub enum ParameterKind {
MemberPrivacyTarget, MemberPrivacyTarget,
PrivacyLevel, PrivacyLevel,
Toggle, Toggle,
Avatar,
} }
impl ParameterKind { impl ParameterKind {
@ -87,6 +90,7 @@ impl ParameterKind {
ParameterKind::MemberPrivacyTarget => "member_privacy_target", ParameterKind::MemberPrivacyTarget => "member_privacy_target",
ParameterKind::PrivacyLevel => "privacy_level", ParameterKind::PrivacyLevel => "privacy_level",
ParameterKind::Toggle => "toggle", ParameterKind::Toggle => "toggle",
ParameterKind::Avatar => "avatar",
} }
} }
@ -96,6 +100,7 @@ impl ParameterKind {
pub(crate) fn match_value(&self, input: &str) -> Result<ParameterValue, SmolStr> { pub(crate) fn match_value(&self, input: &str) -> Result<ParameterValue, SmolStr> {
match self { match self {
// TODO: actually parse image url
ParameterKind::OpaqueString | ParameterKind::OpaqueStringRemainder => { ParameterKind::OpaqueString | ParameterKind::OpaqueStringRemainder => {
Ok(ParameterValue::OpaqueString(input.into())) Ok(ParameterValue::OpaqueString(input.into()))
} }
@ -108,6 +113,7 @@ impl ParameterKind {
ParameterKind::Toggle => { ParameterKind::Toggle => {
Toggle::from_str(input).map(|t| ParameterValue::Toggle(t.into())) Toggle::from_str(input).map(|t| ParameterValue::Toggle(t.into()))
} }
ParameterKind::Avatar => Ok(ParameterValue::Avatar(input.into())),
} }
} }
} }

View file

@ -165,6 +165,7 @@ fn get_param_ty(kind: ParameterKind) -> &'static str {
ParameterKind::MemberPrivacyTarget => "MemberPrivacySubject", ParameterKind::MemberPrivacyTarget => "MemberPrivacySubject",
ParameterKind::PrivacyLevel => "string", ParameterKind::PrivacyLevel => "string",
ParameterKind::Toggle => "bool", ParameterKind::Toggle => "bool",
ParameterKind::Avatar => "ParsedImage",
} }
} }
@ -176,6 +177,7 @@ fn get_param_param_ty(kind: ParameterKind) -> &'static str {
ParameterKind::MemberPrivacyTarget => "MemberPrivacyTarget", ParameterKind::MemberPrivacyTarget => "MemberPrivacyTarget",
ParameterKind::PrivacyLevel => "PrivacyLevel", ParameterKind::PrivacyLevel => "PrivacyLevel",
ParameterKind::Toggle => "Toggle", ParameterKind::Toggle => "Toggle",
ParameterKind::Avatar => "Avatar",
} }
} }

View file

@ -14,6 +14,7 @@ interface Parameter {
PrivacyLevel(string level); PrivacyLevel(string level);
OpaqueString(string raw); OpaqueString(string raw);
Toggle(boolean toggle); Toggle(boolean toggle);
Avatar(string avatar);
}; };
dictionary ParsedCommand { dictionary ParsedCommand {
string command_ref; string command_ref;

View file

@ -28,6 +28,7 @@ pub enum Parameter {
PrivacyLevel { level: String }, PrivacyLevel { level: String },
OpaqueString { raw: String }, OpaqueString { raw: String },
Toggle { toggle: bool }, Toggle { toggle: bool },
Avatar { avatar: String },
} }
impl From<ParameterValue> for Parameter { impl From<ParameterValue> for Parameter {
@ -39,6 +40,7 @@ impl From<ParameterValue> for Parameter {
ParameterValue::PrivacyLevel(level) => Self::PrivacyLevel { level }, ParameterValue::PrivacyLevel(level) => Self::PrivacyLevel { level },
ParameterValue::OpaqueString(raw) => Self::OpaqueString { raw }, ParameterValue::OpaqueString(raw) => Self::OpaqueString { raw },
ParameterValue::Toggle(toggle) => Self::Toggle { toggle }, ParameterValue::Toggle(toggle) => Self::Toggle { toggle },
ParameterValue::Avatar(avatar) => Self::Avatar { avatar },
} }
} }
} }