Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 146 additions & 40 deletions apps/discord-bot/src/commands/config/badge.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,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 = /^<a?:\w+:(\d+)>$/;
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) {}
Expand All @@ -38,13 +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", 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({
Expand All @@ -58,10 +73,11 @@ export class BadgeCommand {

private async run(
context: CommandContext,
mode: "view" | "set" | "reset"
mode: "view" | "image" | "emoji" | "reset"
): Promise<IMessage> {
const userId = context.getInteraction().getUserId();
const file = context.option<APIAttachment | null>("badge");
const emoji = context.option<string | null>("emoji");
const user = context.getUser();
const t = context.t();

Expand All @@ -81,42 +97,20 @@ export class BadgeCommand {
};
}

case "set": {
if (!file)
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
);
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" }],
};
}

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);
Expand All @@ -141,6 +135,118 @@ export class BadgeCommand {
}
}

private async getBadgeCanvas(file: APIAttachment) {
const canvas = createCanvas(32, 32);
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;

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 this.loadBadgeImage(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 emoji = input.trim();
const customEmoji = emoji.match(CUSTOM_EMOJI_REGEX);

if (customEmoji) {
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;
}

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");

Expand Down
7 changes: 7 additions & 0 deletions locales/en-US/default.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand All @@ -22,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",
Expand Down Expand Up @@ -374,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!",
Expand Down
Loading