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
94 changes: 63 additions & 31 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 @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe just split files and emojis into two different subcommands, something along the lines of /badge emoji and /badge image. I am not a fan of having two arguments that could potentially conflict.

return this.run(context, "set");
Expand All @@ -62,6 +66,7 @@ export class BadgeCommand {
): 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 @@ -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);
Expand All @@ -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 ?? ""))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where webp the goat? This should probably be abstracted away into a constant.

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(/^<a?:\w+:(\d+)>$/);

if (customEmoji) {
const badge = await loadImage(`https://cdn.discordapp.com/emojis/${customEmoji[1]}.png?size=32&quality=lossless`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should probably be handling if the bot can't load the emoji for whatever reason.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a fan of the apple emoji. Can we get https://github.com/twitter/twemoji?

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

Expand Down
1 change: 1 addition & 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 Down
Loading