From ccb89f50e96861d5c49e7a31277d3f0bf1b3afba Mon Sep 17 00:00:00 2001 From: the iris system Date: Thu, 2 Mar 2023 06:11:35 +1300 Subject: [PATCH] feat(bot): allow separate member avatars for proxied messages (#523) This allows for using one avatar for the member card, and a different avatar for proxied messages - so that users can set the main avatar to a "full" version of their avatar, and the "proxy" avatar to a cropped version. --- PluralKit.Bot/CommandMeta/CommandTree.cs | 2 + PluralKit.Bot/Commands/MemberAvatar.cs | 122 +++++++++++++----- PluralKit.Bot/Services/EmbedService.cs | 5 +- .../Database/Functions/ProxyMember.cs | 4 +- .../Database/Functions/functions.sql | 24 ++-- PluralKit.Core/Database/Migrations/33.sql | 6 + .../Database/Utils/DatabaseMigrator.cs | 2 +- PluralKit.Core/Models/PKMember.cs | 5 + PluralKit.Core/Models/Patch/MemberPatch.cs | 8 ++ docs/content/api/models.md | 1 + docs/content/command-list.md | 1 + docs/content/user-guide.md | 7 + 12 files changed, 138 insertions(+), 49 deletions(-) create mode 100644 PluralKit.Core/Database/Migrations/33.sql diff --git a/PluralKit.Bot/CommandMeta/CommandTree.cs b/PluralKit.Bot/CommandMeta/CommandTree.cs index 0d5eb3d7..1a3b77d4 100644 --- a/PluralKit.Bot/CommandMeta/CommandTree.cs +++ b/PluralKit.Bot/CommandMeta/CommandTree.cs @@ -304,6 +304,8 @@ public partial class CommandTree await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa")) + await ctx.Execute(MemberAvatar, m => m.WebhookAvatar(ctx, target)); else if (ctx.Match("banner", "splash", "cover")) await ctx.Execute(MemberBannerImage, m => m.BannerImage(ctx, target)); else if (ctx.Match("group", "groups")) diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index cce0dd9e..bcf1b3c0 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -15,10 +15,10 @@ public class MemberAvatar _client = client; } - private async Task AvatarClear(AvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) + private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs) { await UpdateAvatar(location, ctx, target, null); - if (location == AvatarLocation.Server) + if (location == MemberAvatarLocation.Server) { if (target.AvatarUrl != null) await ctx.Reply( @@ -26,6 +26,14 @@ public class MemberAvatar else await ctx.Reply($"{Emojis.Success} Member server avatar cleared. This member now has no avatar."); } + else if (location == MemberAvatarLocation.MemberWebhook) + { + if (mgs?.AvatarUrl != null) + await ctx.Reply( + $"{Emojis.Success} Member proxy avatar cleared. Note that this member has a server-specific avatar set here, type `pk;member {target.Reference(ctx)} serveravatar clear` if you wish to clear that too."); + else + await ctx.Reply($"{Emojis.Success} Member proxy avatar cleared. This member will now use the main avatar for proxied messages."); + } else { if (mgs?.AvatarUrl != null) @@ -36,18 +44,26 @@ public class MemberAvatar } } - private async Task AvatarShow(AvatarLocation location, Context ctx, PKMember target, + private async Task AvatarShow(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? guildData) { // 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 - var currentValue = location == AvatarLocation.Member ? target.AvatarUrl : guildData?.AvatarUrl; - var canAccess = location != AvatarLocation.Member || + var currentValue = location switch + { + MemberAvatarLocation.Server => guildData?.AvatarUrl, + MemberAvatarLocation.MemberWebhook => target.WebhookAvatarUrl, + MemberAvatarLocation.Member => target.AvatarUrl, + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + + var canAccess = location == MemberAvatarLocation.Server || target.AvatarPrivacy.CanAccess(ctx.DirectLookupContextFor(target.System)); + if (string.IsNullOrEmpty(currentValue) || !canAccess) { - if (location == AvatarLocation.Member) + if (location == MemberAvatarLocation.Member) { if (target.System == ctx.System?.Id) throw new PKSyntaxError( @@ -55,19 +71,24 @@ public class MemberAvatar throw new PKError("This member does not have an avatar set."); } - if (location == AvatarLocation.Server) + if (location == MemberAvatarLocation.MemberWebhook) + throw new PKError( + $"This member does not have a proxy avatar set. Type `pk;member {target.Reference(ctx)} avatar` to see their global avatar."); + + if (location == MemberAvatarLocation.Server) throw new PKError( $"This member does not have a server avatar set. Type `pk;member {target.Reference(ctx)} avatar` to see their global avatar."); } - var field = location == AvatarLocation.Server ? $"server avatar (for {ctx.Guild.Name})" : "avatar"; - var cmd = location == AvatarLocation.Server ? "serveravatar" : "avatar"; + var field = location.Name(); + if (location == MemberAvatarLocation.Server) + field += $" (for {ctx.Guild.Name})"; var eb = new EmbedBuilder() .Title($"{target.NameFor(ctx)}'s {field}") .Image(new Embed.EmbedImage(currentValue?.TryGetCleanCdnUrl())); if (target.System == ctx.System?.Id) - eb.Description($"To clear, use `pk;member {target.Reference(ctx)} {cmd} clear`."); + eb.Description($"To clear, use `pk;member {target.Reference(ctx)} {location.Command()} clear`."); await ctx.Reply(embed: eb.Build()); } @@ -75,7 +96,7 @@ public class MemberAvatar { ctx.CheckGuildContext(); var guildData = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id); - await AvatarCommandTree(AvatarLocation.Server, ctx, target, guildData); + await AvatarCommandTree(MemberAvatarLocation.Server, ctx, target, guildData); } public async Task Avatar(Context ctx, PKMember target) @@ -84,16 +105,23 @@ public class MemberAvatar ? await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id) : null; - await AvatarCommandTree(AvatarLocation.Member, ctx, target, guildData); + await AvatarCommandTree(MemberAvatarLocation.Member, ctx, target, guildData); } - private async Task AvatarCommandTree(AvatarLocation location, Context ctx, PKMember target, + 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() && await ctx.ConfirmClear(location == AvatarLocation.Server - ? "this member's server avatar" - : "this member's avatar")) + if (ctx.MatchClear() && await ctx.ConfirmClear("this member's " + location.Name())) { ctx.CheckSystem().CheckOwnMember(target); await AvatarClear(location, ctx, target, guildData); @@ -115,33 +143,30 @@ public class MemberAvatar await PrintResponse(location, ctx, target, avatarArg.Value, guildData); } - private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, + private Task PrintResponse(MemberAvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, MemberGuildSettings? targetGuildData) { - var typeFrag = location switch - { - AvatarLocation.Server => "server avatar", - AvatarLocation.Member => "avatar", - _ => throw new ArgumentOutOfRangeException(nameof(location)) - }; - var serverFrag = location switch { - AvatarLocation.Server => + MemberAvatarLocation.Server => $" This avatar will now be used when proxying in this server (**{ctx.Guild.Name}**).", - AvatarLocation.Member when targetGuildData?.AvatarUrl != null => + MemberAvatarLocation.Member when targetGuildData?.AvatarUrl != null => $"\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", + MemberAvatarLocation.MemberWebhook when targetGuildData?.AvatarUrl != null => + $" This avatar will now be used for this member's proxied messages, instead of their main avatar.\n{Emojis.Note} Note that this member *also* has a server-specific avatar set in this server (**{ctx.Guild.Name}**), and thus changing the global avatar will have no effect here.", + MemberAvatarLocation.MemberWebhook => + $" This avatar will now be used for this member's proxied messages, instead of their main avatar.", _ => "" }; var msg = avatar.Source switch { AvatarSource.User => - $"{Emojis.Success} Member {typeFrag} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", + $"{Emojis.Success} Member {location.Name()} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.", AvatarSource.Url => - $"{Emojis.Success} Member {typeFrag} changed to the image at the given URL.{serverFrag}", + $"{Emojis.Success} Member {location.Name()} changed to the image at the given URL.{serverFrag}", AvatarSource.Attachment => - $"{Emojis.Success} Member {typeFrag} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", + $"{Emojis.Success} Member {location.Name()} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.", _ => throw new ArgumentOutOfRangeException() }; @@ -152,18 +177,49 @@ public class MemberAvatar : ctx.Reply(msg); } - private Task UpdateAvatar(AvatarLocation location, Context ctx, PKMember target, string? url) + private Task UpdateAvatar(MemberAvatarLocation location, Context ctx, PKMember target, string? url) { switch (location) { - case AvatarLocation.Server: + case MemberAvatarLocation.Server: return ctx.Repository.UpdateMemberGuild(target.Id, ctx.Guild.Id, new MemberGuildPatch { AvatarUrl = url }); - case AvatarLocation.Member: + case MemberAvatarLocation.Member: return ctx.Repository.UpdateMember(target.Id, new MemberPatch { AvatarUrl = url }); + case MemberAvatarLocation.MemberWebhook: + return ctx.Repository.UpdateMember(target.Id, new MemberPatch { WebhookAvatarUrl = url }); default: throw new ArgumentOutOfRangeException($"Unknown avatar location {location}"); } } +} +internal enum MemberAvatarLocation +{ + Member, + MemberWebhook, + Server, +} - private enum AvatarLocation { Member, Server } +internal static class MemberAvatarLocationExt +{ + public static string Name(this MemberAvatarLocation location) + { + return location switch + { + MemberAvatarLocation.Server => "server avatar", + MemberAvatarLocation.MemberWebhook => "proxy avatar", + MemberAvatarLocation.Member => "avatar", + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + } + + public static string Command(this MemberAvatarLocation location) + { + return location switch + { + MemberAvatarLocation.Server => "serveravatar", + MemberAvatarLocation.MemberWebhook => "proxyavatar", + MemberAvatarLocation.Member => "avatar", + _ => throw new ArgumentOutOfRangeException(nameof(location)) + }; + } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index fb671565..0a8c56b5 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -135,7 +135,7 @@ public class EmbedService // sometimes Discord will just... not return the avatar hash with webhook messages var avatar = proxiedMessage.Author.Avatar != null ? proxiedMessage.Author.AvatarUrl() - : member.AvatarFor(LookupContext.ByNonOwner); + : member.WebhookAvatarFor(LookupContext.ByNonOwner); var embed = new EmbedBuilder() .Author(new Embed.EmbedAuthor($"#{channelName}: {name}", IconUrl: avatar)) .Thumbnail(new Embed.EmbedThumbnail(avatar)) @@ -175,6 +175,7 @@ public class EmbedService var guildSettings = guild != null ? await _repo.GetMemberGuild(guild.Id, member.Id) : null; var guildDisplayName = guildSettings?.DisplayName; + var webhook_avatar = guildSettings?.AvatarUrl ?? member.WebhookAvatarFor(ctx) ?? member.AvatarFor(ctx); var avatar = guildSettings?.AvatarUrl ?? member.AvatarFor(ctx); var groups = await _repo.GetMemberGroups(member.Id) @@ -183,7 +184,7 @@ public class EmbedService .ToListAsync(); var eb = new EmbedBuilder() - .Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}")) + .Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}")) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) .Color(color) .Footer(new Embed.EmbedFooter( diff --git a/PluralKit.Core/Database/Functions/ProxyMember.cs b/PluralKit.Core/Database/Functions/ProxyMember.cs index c0d41524..380110a7 100644 --- a/PluralKit.Core/Database/Functions/ProxyMember.cs +++ b/PluralKit.Core/Database/Functions/ProxyMember.cs @@ -23,9 +23,9 @@ public class ProxyMember public string Name { get; } = ""; public string? ServerAvatar { get; } + public string? WebhookAvatar { get; } public string? Avatar { get; } - public bool AllowAutoproxy { get; } public string? Color { get; } @@ -42,5 +42,5 @@ public class ProxyMember return memberName; } - public string? ProxyAvatar(MessageContext ctx) => ServerAvatar ?? Avatar ?? ctx.SystemAvatar; + public string? ProxyAvatar(MessageContext ctx) => ServerAvatar ?? WebhookAvatar ?? Avatar ?? ctx.SystemAvatar; } \ No newline at end of file diff --git a/PluralKit.Core/Database/Functions/functions.sql b/PluralKit.Core/Database/Functions/functions.sql index 0b9f3a76..a49fac8f 100644 --- a/PluralKit.Core/Database/Functions/functions.sql +++ b/PluralKit.Core/Database/Functions/functions.sql @@ -1,4 +1,4 @@ -create function message_context(account_id bigint, guild_id bigint, channel_id bigint) +create function message_context(account_id bigint, guild_id bigint, channel_id bigint) returns table ( system_id int, log_channel bigint, @@ -67,6 +67,7 @@ create function proxy_members(account_id bigint, guild_id bigint) name text, server_avatar text, + webhook_avatar text, avatar text, color char(6), @@ -76,22 +77,23 @@ create function proxy_members(account_id bigint, guild_id bigint) as $$ select -- Basic data - members.id as id, - members.proxy_tags as proxy_tags, - members.keep_proxy as keep_proxy, + members.id as id, + members.proxy_tags as proxy_tags, + members.keep_proxy as keep_proxy, -- Name info - member_guild.display_name as server_name, - members.display_name as display_name, - members.name as name, + member_guild.display_name as server_name, + members.display_name as display_name, + members.name as name, -- Avatar info - member_guild.avatar_url as server_avatar, - members.avatar_url as avatar, + member_guild.avatar_url as server_avatar, + members.webhook_avatar_url as webhook_avatar, + members.avatar_url as avatar, - members.color as color, + members.color as color, - members.allow_autoproxy as allow_autoproxy + members.allow_autoproxy as allow_autoproxy from accounts inner join systems on systems.id = accounts.system inner join members on members.system = systems.id diff --git a/PluralKit.Core/Database/Migrations/33.sql b/PluralKit.Core/Database/Migrations/33.sql new file mode 100644 index 00000000..fc648ff6 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/33.sql @@ -0,0 +1,6 @@ +-- database version 33 +-- add webhook_avatar_url to system members + +alter table members add column webhook_avatar_url text; + +update info set schema_version = 33; \ No newline at end of file diff --git a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs index 10183fdd..ff6efb4b 100644 --- a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs +++ b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs @@ -9,7 +9,7 @@ namespace PluralKit.Core; internal class DatabaseMigrator { private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files - private const int TargetSchemaVersion = 32; + private const int TargetSchemaVersion = 33; private readonly ILogger _logger; public DatabaseMigrator(ILogger logger) diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index bbae8440..ede4368c 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -39,6 +39,7 @@ public class PKMember public Guid Uuid { get; private set; } public SystemId System { get; private set; } public string Color { get; private set; } + public string WebhookAvatarUrl { get; private set; } public string AvatarUrl { get; private set; } public string BannerImage { get; private set; } public string Name { get; private set; } @@ -90,6 +91,9 @@ public static class PKMemberExt public static string AvatarFor(this PKMember member, LookupContext ctx) => member.AvatarPrivacy.Get(ctx, member.AvatarUrl.TryGetCleanCdnUrl()); + public static string WebhookAvatarFor(this PKMember member, LookupContext ctx) => + member.AvatarPrivacy.Get(ctx, (member.WebhookAvatarUrl ?? member.AvatarUrl).TryGetCleanCdnUrl()); + public static string DescriptionFor(this PKMember member, LookupContext ctx) => member.DescriptionPrivacy.Get(ctx, member.Description); @@ -128,6 +132,7 @@ public static class PKMemberExt o.Add("birthday", member.BirthdayFor(ctx)?.FormatExport()); o.Add("pronouns", member.PronounsFor(ctx)); o.Add("avatar_url", member.AvatarFor(ctx).TryGetCleanCdnUrl()); + o.Add("webhook_avatar_url", member.WebhookAvatarFor(ctx).TryGetCleanCdnUrl()); o.Add("banner", member.DescriptionPrivacy.Get(ctx, member.BannerImage).TryGetCleanCdnUrl()); o.Add("description", member.DescriptionFor(ctx)); o.Add("created", member.CreatedFor(ctx)?.FormatExport()); diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index da02557d..4101a7c6 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -12,6 +12,7 @@ public class MemberPatch: PatchObject public Partial Name { get; set; } public Partial Hid { get; set; } public Partial DisplayName { get; set; } + public Partial WebhookAvatarUrl { get; set; } public Partial AvatarUrl { get; set; } public Partial BannerImage { get; set; } public Partial Color { get; set; } @@ -34,6 +35,7 @@ public class MemberPatch: PatchObject .With("name", Name) .With("hid", Hid) .With("display_name", DisplayName) + .With("webhook_avatar_url", WebhookAvatarUrl) .With("avatar_url", AvatarUrl) .With("banner_image", BannerImage) .With("color", Color) @@ -62,6 +64,9 @@ public class MemberPatch: PatchObject if (AvatarUrl.Value != null) AssertValid(AvatarUrl.Value, "avatar_url", Limits.MaxUriLength, s => MiscUtils.TryMatchUri(s, out var avatarUri)); + if (WebhookAvatarUrl.Value != null) + AssertValid(WebhookAvatarUrl.Value, "webhook_avatar_url", Limits.MaxUriLength, + s => MiscUtils.TryMatchUri(s, out var webhookAvatarUri)); if (BannerImage.Value != null) AssertValid(BannerImage.Value, "banner", Limits.MaxUriLength, s => MiscUtils.TryMatchUri(s, out var bannerUri)); @@ -93,6 +98,7 @@ public class MemberPatch: PatchObject if (o.ContainsKey("name")) patch.Name = o.Value("name"); if (o.ContainsKey("color")) patch.Color = o.Value("color").NullIfEmpty()?.ToLower(); if (o.ContainsKey("display_name")) patch.DisplayName = o.Value("display_name").NullIfEmpty(); + if (o.ContainsKey("webhook_avatar_url")) patch.WebhookAvatarUrl = o.Value("webhook_avatar_url").NullIfEmpty(); if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value("avatar_url").NullIfEmpty(); if (o.ContainsKey("banner")) patch.BannerImage = o.Value("banner").NullIfEmpty(); @@ -177,6 +183,8 @@ public class MemberPatch: PatchObject o.Add("display_name", DisplayName.Value); if (AvatarUrl.IsPresent) o.Add("avatar_url", AvatarUrl.Value); + if (WebhookAvatarUrl.IsPresent) + o.Add("webhook_avatar_url", WebhookAvatarUrl.Value); if (BannerImage.IsPresent) o.Add("banner", BannerImage.Value); if (Color.IsPresent) diff --git a/docs/content/api/models.md b/docs/content/api/models.md index 35d159fe..c1d9c0e9 100644 --- a/docs/content/api/models.md +++ b/docs/content/api/models.md @@ -46,6 +46,7 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID. |birthday|?string|`YYYY-MM-DD` format, 0004 hides the year| |pronouns|?string|100-character-limit| |avatar_url|?string|256-character limit, must be a publicly-accessible URL| +|webhook_avatar_url|?string|256-character limit, must be a publicly-accessible URL| |banner|?string|256-character limit, must be a publicly-accessible URL| |description|?string|1000-character limit| |created|?datetime|| diff --git a/docs/content/command-list.md b/docs/content/command-list.md index b96e3861..75b3c0ba 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -65,6 +65,7 @@ Some arguments indicate the use of specific Discord features. These include: - `pk;member servername ` - Changes the display name of a member, only in the current server. - `pk;member description [description]` - Changes the description of a member. - `pk;member avatar [avatar url|@mention|upload]` - Changes the avatar of a member. +- `pk;member proxyavatar [avatar url|@mention|upload]` - Changes the avatar used for proxied messages sent by a member. - `pk;member serveravatar [avatar url|@mention|upload]` - Changes the avatar of a member in a specific server. - `pk;member banner [image url|upload]` - Changes the banner image of a member. - `pk;member privacy` - Displays a members current privacy settings. diff --git a/docs/content/user-guide.md b/docs/content/user-guide.md index 41acceb6..bb4f47e9 100644 --- a/docs/content/user-guide.md +++ b/docs/content/user-guide.md @@ -229,6 +229,13 @@ To preview the current avatar (if one is set), use the command with no arguments To clear your avatar, use the subcommand `avatar clear` (eg. `pk;member John avatar clear`). +### Member proxy avatar +If you want your member to have a different avatar for proxies messages than the one displayed on the member card, you can set a proxy avatar. To do so, use the `pk;member proxyavatar` command, in the same way as the normal avatar command above: + + pk;member John avatar + pk;member John proxyavatar http://placebeard.it/512.jpg + pk;member "Craig Johnson" proxyavatar (with an attached image) + ### Member server avatar You can also set an avatar for a specific server. This will "override" the normal avatar, and will be used when proxying messages and looking up member cards in that server. To do so, use the `pk;member serveravatar` command, in the same way as the normal avatar command above: