forked from syntaxbullet/AuroraBot-discord
feat: Implement graphical student ID card generation for user profiles.
This commit is contained in:
BIN
src/assets/fonts/IBMPlexMono-Bold.ttf
Normal file
BIN
src/assets/fonts/IBMPlexMono-Bold.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/IBMPlexSansCondensed-SemiBold.ttf
Normal file
BIN
src/assets/fonts/IBMPlexSansCondensed-SemiBold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/assets/student-id-class-badge.png
Normal file
BIN
src/assets/student-id-class-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/student-id-template.png
Normal file
BIN
src/assets/student-id-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
@@ -1,6 +1,8 @@
|
|||||||
import { createCommand } from "@/lib/utils";
|
import { createCommand } from "@/lib/utils";
|
||||||
import { SlashCommandBuilder, EmbedBuilder } from "discord.js";
|
import { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } from "discord.js";
|
||||||
import { userService } from "@/modules/user/user.service";
|
import { userService } from "@/modules/user/user.service";
|
||||||
|
import { classService } from "@/modules/class/class.service";
|
||||||
|
import { generateStudentIdCard } from "@/graphics/studentID";
|
||||||
|
|
||||||
export const profile = createCommand({
|
export const profile = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -16,24 +18,25 @@ export const profile = createCommand({
|
|||||||
|
|
||||||
const targetUser = interaction.options.getUser("user") || interaction.user;
|
const targetUser = interaction.options.getUser("user") || interaction.user;
|
||||||
const targetMember = await interaction.guild?.members.fetch(targetUser.id).catch(() => null);
|
const targetMember = await interaction.guild?.members.fetch(targetUser.id).catch(() => null);
|
||||||
|
const targetUserClass = await userService.getUserClass(targetUser.id);
|
||||||
|
const classBalance = await classService.getClassBalance(targetUserClass?.id || BigInt(0));
|
||||||
|
|
||||||
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
const user = await userService.getOrCreateUser(targetUser.id, targetUser.username);
|
||||||
|
|
||||||
const embed = new EmbedBuilder()
|
const cardBuffer = await generateStudentIdCard({
|
||||||
.setTitle(`${targetUser.username}'s Profile`)
|
username: targetUser.username,
|
||||||
.setThumbnail(targetUser.displayAvatarURL({ size: 512 }))
|
avatarUrl: targetUser.displayAvatarURL({ extension: 'png', size: 256 }),
|
||||||
.setColor(targetMember?.displayHexColor || "Random")
|
id: targetUser.id,
|
||||||
.addFields(
|
level: user!.level || 1,
|
||||||
{ name: "Level", value: `${user!.level || 1}`, inline: true },
|
xp: user!.xp || 0n,
|
||||||
{ name: "XP", value: `${user!.xp || 0}`, inline: true },
|
au: user!.balance || 0n,
|
||||||
{ name: "Balance", value: `${user!.balance || 0}`, inline: true },
|
cu: classBalance || 0n,
|
||||||
{ name: "Class", value: user!.class?.name || "None", inline: true },
|
className: user!.class?.name || "D"
|
||||||
{ name: "Joined Discord", value: `<t:${Math.floor(targetUser.createdTimestamp / 1000)}:R>`, inline: true },
|
});
|
||||||
{ name: "Joined Server", value: targetMember ? `<t:${Math.floor(targetMember.joinedTimestamp! / 1000)}:R>` : "Unknown", inline: true }
|
|
||||||
)
|
const attachment = new AttachmentBuilder(cardBuffer, { name: 'student-id.png' });
|
||||||
.setFooter({ text: `ID: ${targetUser.id}` })
|
|
||||||
.setTimestamp();
|
await interaction.editReply({ files: [attachment] }); // Send mostly just the image as requested, or maybe both? User said "show that user's student id". Image is primary.
|
||||||
|
|
||||||
await interaction.editReply({ embeds: [embed] });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
127
src/graphics/studentID.ts
Normal file
127
src/graphics/studentID.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { GlobalFonts, createCanvas, loadImage } from '@napi-rs/canvas';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Register Fonts
|
||||||
|
const fontDir = path.join(process.cwd(), 'src', 'assets', 'fonts');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexSansCondensed-SemiBold.ttf'), 'IBMPlexSansCondensed-SemiBold');
|
||||||
|
GlobalFonts.registerFromPath(path.join(fontDir, 'IBMPlexMono-Bold.ttf'), 'IBMPlexMono-Bold');
|
||||||
|
|
||||||
|
const textColor = '#FFE7C5';
|
||||||
|
const secondaryTextColor = '#9C7D53';
|
||||||
|
const accentColor = '#3F2923';
|
||||||
|
|
||||||
|
interface StudentCardData {
|
||||||
|
username: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
au: bigint; // Astral Units
|
||||||
|
cu: bigint; // Constellation Units
|
||||||
|
xp: bigint; // Experience
|
||||||
|
className?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStudentIdCard(data: StudentCardData): Promise<Buffer> {
|
||||||
|
const templatePath = path.join(process.cwd(), 'src', 'assets', 'student-id-template.png');
|
||||||
|
const template = await loadImage(templatePath);
|
||||||
|
|
||||||
|
const canvas = createCanvas(template.width, template.height);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw Template
|
||||||
|
ctx.drawImage(template, 0, 0);
|
||||||
|
|
||||||
|
// Draw Avatar
|
||||||
|
const avatarSize = 111;
|
||||||
|
const avatarX = 18;
|
||||||
|
const avatarY = 64;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = await loadImage(data.avatarUrl);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
// Square avatar
|
||||||
|
ctx.rect(avatarX, avatarY, avatarSize, avatarSize);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize);
|
||||||
|
ctx.restore();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load avatar", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw ID
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '12px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = secondaryTextColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`ID: ${data.id}`, 302, 30);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Username
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '32px IBMPlexSansCondensed-SemiBold';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(data.username, 140, 107);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Class
|
||||||
|
try {
|
||||||
|
const classBadge = await loadImage(path.join(process.cwd(), 'src', 'assets', 'student-id-class-badge.png'));
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.drawImage(classBadge, 100, 126, 58, 58);
|
||||||
|
ctx.font = '32px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(data.className || 'Unknown', 120, 162);
|
||||||
|
ctx.restore();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load class badge", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Astral Units
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '32px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(data.au.toString(), 163, 218);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Constellation Units
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = '32px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(data.cu.toString(), 163, 153);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Level
|
||||||
|
ctx.save();
|
||||||
|
// check how many digits the level has and adjust the position accordingly
|
||||||
|
const levelDigits = data.level.toString().length;
|
||||||
|
ctx.font = '12px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = secondaryTextColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
if (levelDigits === 1) {
|
||||||
|
ctx.fillText(data.level.toString(), 406, 265);
|
||||||
|
} else {
|
||||||
|
ctx.fillText(data.level.toString(), 400, 265);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Draw Next Level
|
||||||
|
ctx.save();
|
||||||
|
// check how many digits the next level has and adjust the position accordingly
|
||||||
|
const nextLevelDigits = (data.level + 1).toString().length;
|
||||||
|
ctx.font = '24px IBMPlexMono-Bold';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
if (nextLevelDigits === 1) {
|
||||||
|
ctx.fillText((data.level + 1).toString(), 438, 255);
|
||||||
|
} else {
|
||||||
|
ctx.fillText((data.level + 1).toString(), 431, 255);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
return canvas.toBuffer('image/png');
|
||||||
|
}
|
||||||
@@ -25,7 +25,12 @@ export const classService = {
|
|||||||
};
|
};
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||||
},
|
},
|
||||||
|
getClassBalance: async (classId: bigint) => {
|
||||||
|
const cls = await DrizzleClient.query.classes.findFirst({
|
||||||
|
where: eq(classes.id, classId),
|
||||||
|
});
|
||||||
|
return cls?.balance || 0n;
|
||||||
|
},
|
||||||
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => {
|
modifyClassBalance: async (classId: bigint, amount: bigint, tx?: any) => {
|
||||||
const execute = async (txFn: any) => {
|
const execute = async (txFn: any) => {
|
||||||
const cls = await txFn.query.classes.findFirst({
|
const cls = await txFn.query.classes.findFirst({
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export const userService = {
|
|||||||
};
|
};
|
||||||
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
return tx ? await execute(tx) : await DrizzleClient.transaction(execute);
|
||||||
},
|
},
|
||||||
|
getUserClass: async (id: string) => {
|
||||||
|
const user = await DrizzleClient.query.users.findFirst({
|
||||||
|
where: eq(users.id, BigInt(id)),
|
||||||
|
with: { class: true }
|
||||||
|
});
|
||||||
|
return user?.class;
|
||||||
|
},
|
||||||
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => {
|
createUser: async (id: string | bigint, username: string, classId?: bigint, tx?: any) => {
|
||||||
const execute = async (txFn: any) => {
|
const execute = async (txFn: any) => {
|
||||||
const [user] = await txFn.insert(users).values({
|
const [user] = await txFn.insert(users).values({
|
||||||
|
|||||||
Reference in New Issue
Block a user