From 1fa8a4408cebc0af4e2d8999cdb930f2a41274ac Mon Sep 17 00:00:00 2001 From: Cody Date: Wed, 10 Jun 2026 23:40:32 -0600 Subject: [PATCH 1/2] feat(badge): support emoji input --- .../src/commands/config/badge.command.tsx | 94 +++++++++++++------ locales/en-US/default.json | 1 + 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/apps/discord-bot/src/commands/config/badge.command.tsx b/apps/discord-bot/src/commands/config/badge.command.tsx index d04ea406a..6f00e4e86 100644 --- a/apps/discord-bot/src/commands/config/badge.command.tsx +++ b/apps/discord-bot/src/commands/config/badge.command.tsx @@ -16,6 +16,7 @@ import { IMessage, LocalizeFunction, SubCommand, + TextArgument, } from "@statsify/discord"; import { type Canvas, Image } from "skia-canvas"; import { DemoProfile } from "./demo.profile.js"; @@ -41,7 +42,10 @@ export class BadgeCommand { description: (t) => t("commands.badge-set"), tier: UserTier.GOLD, preview: "badge.png", - args: [new FileArgument("badge", true)], + args: [ + new FileArgument("badge"), + new TextArgument("emoji", (t) => t("arguments.emoji"), false), + ], }) public set(context: CommandContext) { return this.run(context, "set"); @@ -62,6 +66,7 @@ export class BadgeCommand { ): Promise { const userId = context.getInteraction().getUserId(); const file = context.option("badge"); + const emoji = context.option("emoji"); const user = context.getUser(); const t = context.t(); @@ -82,41 +87,13 @@ export class BadgeCommand { } case "set": { - if (!file) + if (!file && !emoji) throw new ErrorMessage( (t) => t("errors.unknown.title"), (t) => t("errors.unknown.description") ); - const canvas = createCanvas(32, 32); - const ctx = canvas.getContext("2d"); - ctx.imageSmoothingEnabled = false; - - if (!["image/png", "image/jpeg", "image/gif"].includes(file.content_type ?? "")) - throw new ErrorMessage( - (t) => t("errors.unsupportedFileType.title"), - (t) => t("errors.unsupportedFileType.description") - ); - - const badge = await loadImage(file.url); - - const ratio = Math.min(canvas.width / badge.width, canvas.height / badge.height); - const scaled = badge.width > 32 || badge.height > 32; - - const width = scaled ? badge.width * ratio : badge.width; - const height = scaled ? badge.height * ratio : badge.height; - - ctx.drawImage( - badge, - 0, - 0, - badge.width, - badge.height, - (canvas.width - width) / 2, - (canvas.height - height) / 2, - width, - height - ); + const canvas = file ? await this.getBadgeCanvas(file) : await this.getEmojiCanvas(emoji as string); await this.apiService.updateUserBadge(userId, await canvas.toBuffer("png")); const profile = await this.getProfile(t, user, canvas); @@ -141,6 +118,61 @@ export class BadgeCommand { } } + private async getBadgeCanvas(file: APIAttachment) { + const canvas = createCanvas(32, 32); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + if (!["image/png", "image/jpeg", "image/gif"].includes(file.content_type ?? "")) + throw new ErrorMessage( + (t) => t("errors.unsupportedFileType.title"), + (t) => t("errors.unsupportedFileType.description") + ); + + const badge = await loadImage(file.url); + + const ratio = Math.min(canvas.width / badge.width, canvas.height / badge.height); + const scaled = badge.width > 32 || badge.height > 32; + + const width = scaled ? badge.width * ratio : badge.width; + const height = scaled ? badge.height * ratio : badge.height; + + ctx.drawImage( + badge, + 0, + 0, + badge.width, + badge.height, + (canvas.width - width) / 2, + (canvas.height - height) / 2, + width, + height + ); + + return canvas; + } + + private async getEmojiCanvas(input: string) { + const canvas = createCanvas(32, 32); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + + const customEmoji = input.trim().match(/^$/); + + if (customEmoji) { + const badge = await loadImage(`https://cdn.discordapp.com/emojis/${customEmoji[1]}.png?size=32&quality=lossless`); + ctx.drawImage(badge, 0, 0, badge.width, badge.height, 0, 0, 32, 32); + return canvas; + } + + ctx.font = "28px Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(input.trim(), 16, 17); + + return canvas; + } + private async getProfile(t: LocalizeFunction, user: User, badge?: Image | Canvas) { if (!user?.uuid) throw new ErrorMessage("errors.unknown"); diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..007b125b2 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -1,6 +1,7 @@ { "arguments": { "choice": "Choose an option", + "emoji": "A Discord emoji", "file": "Upload a file", "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", From 4d902887e174ee6c05fe06acbc03b7d08d8ff446 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 16 Jun 2026 01:16:15 -0600 Subject: [PATCH 2/2] feat(badge): add support for emoji and image badges * Introduce new subcommands for setting badge images and emojis * Update localization for badge commands * Enhance error handling for invalid emoji input --- .../src/commands/config/badge.command.tsx | 120 ++++++++++++++---- locales/en-US/default.json | 6 + 2 files changed, 103 insertions(+), 23 deletions(-) diff --git a/apps/discord-bot/src/commands/config/badge.command.tsx b/apps/discord-bot/src/commands/config/badge.command.tsx index 6f00e4e86..ad89ef836 100644 --- a/apps/discord-bot/src/commands/config/badge.command.tsx +++ b/apps/discord-bot/src/commands/config/badge.command.tsx @@ -25,6 +25,10 @@ import { createCanvas, loadImage, render } from "@statsify/rendering"; import { getBackground, getLogo } from "@statsify/assets"; import { getTheme } from "#themes"; +const BADGE_IMAGE_TYPE_PREFIX = "image/"; +const CUSTOM_EMOJI_REGEX = /^$/; +const TWEMOJI_BASE_URL = "https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72"; + @Command({ description: (t) => t("commands.badge") }) export class BadgeCommand { public constructor(private readonly apiService: ApiService) {} @@ -39,16 +43,23 @@ export class BadgeCommand { } @SubCommand({ - description: (t) => t("commands.badge-set"), + description: (t) => t("commands.badge-image"), tier: UserTier.GOLD, preview: "badge.png", - args: [ - new FileArgument("badge"), - new TextArgument("emoji", (t) => t("arguments.emoji"), false), - ], + args: [new FileArgument("badge", true)], }) - public set(context: CommandContext) { - return this.run(context, "set"); + public image(context: CommandContext) { + return this.run(context, "image"); + } + + @SubCommand({ + description: (t) => t("commands.badge-emoji"), + tier: UserTier.GOLD, + preview: "badge.png", + args: [new TextArgument("emoji", (t) => t("arguments.emoji"))], + }) + public emoji(context: CommandContext) { + return this.run(context, "emoji"); } @SubCommand({ @@ -62,7 +73,7 @@ export class BadgeCommand { private async run( context: CommandContext, - mode: "view" | "set" | "reset" + mode: "view" | "image" | "emoji" | "reset" ): Promise { const userId = context.getInteraction().getUserId(); const file = context.option("badge"); @@ -86,14 +97,20 @@ export class BadgeCommand { }; } - case "set": { - if (!file && !emoji) - throw new ErrorMessage( - (t) => t("errors.unknown.title"), - (t) => t("errors.unknown.description") - ); + case "image": { + const canvas = await this.getBadgeCanvas(file as APIAttachment); + + await this.apiService.updateUserBadge(userId, await canvas.toBuffer("png")); + const profile = await this.getProfile(t, user, canvas); + + return { + content: t("config.badge.set") as string, + files: [{ name: "badge.png", data: profile, type: "image/png" }], + }; + } - const canvas = file ? await this.getBadgeCanvas(file) : await this.getEmojiCanvas(emoji as string); + case "emoji": { + const canvas = await this.getEmojiCanvas(emoji as string); await this.apiService.updateUserBadge(userId, await canvas.toBuffer("png")); const profile = await this.getProfile(t, user, canvas); @@ -123,13 +140,13 @@ export class BadgeCommand { const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; - if (!["image/png", "image/jpeg", "image/gif"].includes(file.content_type ?? "")) + if (!file.content_type?.startsWith(BADGE_IMAGE_TYPE_PREFIX)) throw new ErrorMessage( (t) => t("errors.unsupportedFileType.title"), (t) => t("errors.unsupportedFileType.description") ); - const badge = await loadImage(file.url); + const badge = await this.loadBadgeImage(file.url); const ratio = Math.min(canvas.width / badge.width, canvas.height / badge.height); const scaled = badge.width > 32 || badge.height > 32; @@ -157,22 +174,79 @@ export class BadgeCommand { const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; - const customEmoji = input.trim().match(/^$/); + const emoji = input.trim(); + const customEmoji = emoji.match(CUSTOM_EMOJI_REGEX); if (customEmoji) { - const badge = await loadImage(`https://cdn.discordapp.com/emojis/${customEmoji[1]}.png?size=32&quality=lossless`); + const badge = await this.loadEmojiImage( + `https://cdn.discordapp.com/emojis/${customEmoji[1]}.png?size=32&quality=lossless` + ); + ctx.drawImage(badge, 0, 0, badge.width, badge.height, 0, 0, 32, 32); return canvas; } - ctx.font = "28px Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(input.trim(), 16, 17); + const badge = await this.loadTwemojiImage(emoji); + + ctx.drawImage(badge, 0, 0, badge.width, badge.height, 0, 0, 32, 32); return canvas; } + private async loadBadgeImage(url: string) { + try { + return await loadImage(url); + } catch { + throw new ErrorMessage( + (t) => t("errors.unsupportedFileType.title"), + (t) => t("errors.unsupportedFileType.description") + ); + } + } + + private async loadEmojiImage(url: string) { + try { + return await loadImage(url); + } catch { + throw new ErrorMessage( + (t) => t("errors.invalidBadgeEmoji.title"), + (t) => t("errors.invalidBadgeEmoji.description") + ); + } + } + + private async loadTwemojiImage(input: string) { + const codepoints = this.toTwemojiCodepoints(input); + const urls = [...new Set(codepoints)].map((codepoint) => `${TWEMOJI_BASE_URL}/${codepoint}.png`); + + return Promise.any(urls.map((url) => loadImage(url))).catch(() => { + throw new ErrorMessage( + (t) => t("errors.invalidBadgeEmoji.title"), + (t) => t("errors.invalidBadgeEmoji.description") + ); + }); + } + + private toTwemojiCodepoints(input: string) { + const codepoints = [...input] + .map((char) => char.codePointAt(0)?.toString(16)) + .filter((codepoint): codepoint is string => !!codepoint); + + const emojiPresentationCodepoints = codepoints.filter((codepoint) => !["200d", "fe0f"].includes(codepoint)); + + if (emojiPresentationCodepoints.length === 0) { + throw new ErrorMessage( + (t) => t("errors.invalidBadgeEmoji.title"), + (t) => t("errors.invalidBadgeEmoji.description") + ); + } + + return [ + codepoints.join("-"), + codepoints.filter((codepoint) => codepoint !== "fe0f").join("-"), + ]; + } + private async getProfile(t: LocalizeFunction, user: User, badge?: Image | Canvas) { if (!user?.uuid) throw new ErrorMessage("errors.unknown"); diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 007b125b2..a8e03b926 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -23,6 +23,8 @@ "arenabrawl": "$t(commands.hypixel-command, { \"name\": \"Arena Brawl\" })", "available": "Check the availability of a Minecraft username", "badge": "Change your badge for every profile", + "badge-emoji": "Use an emoji as your profile badge", + "badge-image": "Upload a new profile badge", "badge-reset": "Reset your profile badge", "badge-set": "Upload a new profile badge", "badge-view": "View your current profile badge", @@ -375,6 +377,10 @@ "description": "You need to purchase a higher premium tier to unlock this icon!", "title": "Higher Tier Required" }, + "invalidBadgeEmoji": { + "description": "The emoji you provided could not be loaded!", + "title": "Invalid Emoji" + }, "invalidGuild": { "description_id": "A guild with the id of `{{tag}}` could not be found!", "description_name": "A guild by the name of `{{tag}}` could not be found!",