Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ShowcaseModule } from "./modules/showcase.module.js";
import { StarboardModule } from "./modules/starboard/starboard.module.js";
import SuggestModule from "./modules/suggest/suggest.module.js";
import { TokenScannerModule } from "./modules/tokenScanner.module.js";
import { UserModule } from "./modules/user/user.module.js";
import { XpModule } from "./modules/xp/xp.module.js";
import { initSentry } from "./sentry.js";
import { initStorage } from "./store/storage.js";
Expand Down Expand Up @@ -67,6 +68,7 @@ export const moduleManager = new ModuleManager(
StarboardModule,
ModmailModule,
LeaderboardModule,
UserModule,
],
);

Expand Down
42 changes: 42 additions & 0 deletions src/modules/user/profileCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
ApplicationCommandType,
Attachment,
AttachmentBuilder,
type GuildMember,
MessageFlags,
} from "discord.js";
import type { Command } from "djs-slash-helper";
import { getProfileEmbed } from "./user.js";

export const ProfileCommand: Command<ApplicationCommandType.ChatInput> = {
name: "profile",
description: "Look at your profile",
default_permission: false,
type: ApplicationCommandType.ChatInput,
options: [],
async handle(interaction) {
if (!interaction.member) {
await interaction.followUp({
content: "Sorry this can only be invoked in a guild!",
flags: "Ephemeral",
});
return;
}
const profile = await getProfileEmbed(interaction.member as GuildMember);
await interaction.reply({
flags: MessageFlags.Ephemeral,
files: [
new AttachmentBuilder(
Buffer.from(
profile.image.replace(/^data:image\/png;base64,/, ""),
"base64",
),
{
name: "profile.png",
description: "The Profile Image",
},
),
],
});
},
};
7 changes: 7 additions & 0 deletions src/modules/user/user.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type Module from "../module.js";
import { ProfileCommand } from "./profileCommand.js";

export const UserModule: Module = {
name: "user",
commands: [ProfileCommand],
};
249 changes: 249 additions & 0 deletions src/modules/user/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { type CanvasRenderingContext2D, registerFont } from "canvas";
import type { GuildMember } from "discord.js";
import { getBumpStreak } from "../../store/models/bumps.js";
import { type DDUser, getOrCreateUserById } from "../../store/models/DDUser.js";
import { branding } from "../../util/branding.js";
import {
createCanvasContext,
getTextSize,
loadAndDrawImage,
setFont,
} from "../../util/canvas.js";
import { xpForLevel } from "../xp/xpForMessage.util.js";

export const profileFont = "Cascadia Code";
registerFont(branding.font, {
family: profileFont,
});
export function getDDColorGradiant(
ctx: CanvasRenderingContext2D,
startX: number,
width: number,
) {
// Create a linear gradient for the divider
const gradient = ctx.createLinearGradient(startX, 0, startX + width, 0);
gradient.addColorStop(0, "#00AFC3FF"); // Red at start
gradient.addColorStop(0.5, "#8099FFFF"); // Green in middle
gradient.addColorStop(1, "#FF52F9FF"); // Blue at end
return gradient;
}

export function createLevelAndXPField(
canvas: CanvasRenderingContext2D,
user: GuildMember,
ddUser: DDUser,
x: number,
y: number,
barWidth: number = 300,
barHeight: number = 20,
) {
const { xp, level } = ddUser;

// XP bar configuration
const barX = x; // After avatar and padding
const barY = y; // Slightly above name

const xpForNextLevel = xpForLevel(ddUser.level + 1);
const xpProgress = Math.min(Number(xp) / Number(xpForNextLevel), 1);

// Draw XP bar background
canvas.fillStyle = "#444444";

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.

could we make the colour of the xp bar based on the user's highest role colour? i think that would make it more visually interesting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The background or foreground the bar ( green currently )

canvas.fillRect(barX, barY, barWidth, barHeight);
let userRoleColor = user.roles.highest.hexColor;
if (userRoleColor === "#000000" || userRoleColor === "#444444") {
// green
userRoleColor = "#FF52F9FF";
}
// Draw XP bar progress
canvas.fillStyle = userRoleColor;
canvas.fillRect(barX, barY, barWidth * xpProgress, barHeight);

// Draw XP text
setFont(canvas, 16);
canvas.fillStyle = "#ffffff";
canvas.textAlign = "left";
canvas.fillText(
`Level: ${level} | XP: ${xp}/${xpForNextLevel}`,
barX,
barY - 5,
);
}

export async function createUserBumpFields(
canvas: CanvasRenderingContext2D,
user: GuildMember,
ddUser: DDUser,
x: number,
y: number,
) {
const bumps = await ddUser.countBumps();
const bumpStreak = await getBumpStreak(ddUser);
const currentDailyStreak = ddUser.currentDailyStreak;
const highestDailyStreak = ddUser.highestDailyStreak;

// Set font for bump fields
setFont(canvas, 16);
canvas.fillStyle = "#ffffff";
canvas.textAlign = "left";

// Draw bump statistics
let currentY = y;

// Total bumps
canvas.fillText(
`Total Bumps: ${bumps} | Current Bump Streak: ${bumpStreak.current} | Highest Bump Streak: ${bumpStreak.highest}`,
x,
currentY,
);
currentY += 20;

// Daily streaks
canvas.fillText(
`Current Daily Streak: ${currentDailyStreak} | Highest Daily Streak: ${highestDailyStreak}`,
x,
currentY,
);
}
export function drawDivider(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
length: number,
orientation: "horizontal" | "vertical" = "horizontal",
color: string | CanvasGradient = "#444444",
thickness: number = 1,
) {
ctx.save();

// Set drawing properties
ctx.strokeStyle = color;
ctx.lineWidth = thickness;

if (orientation === "horizontal") {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + length, y);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x, y + length);
ctx.stroke();
}

ctx.restore();
}

export async function generateUserProfileImage(
user: GuildMember,
ddUser: DDUser,
): Promise<string> {
const w = 1048;
const h = 162;

const padding = 12;

// Create canvas with context
const ctx = createCanvasContext(w, h);

// Background gradient
ctx.fillStyle = "#171834";
ctx.fillRect(0, 0, w, h);

// Draw user's name

const displayNameX = 128 + 4 + padding;
const displayNameY = 52;

setFont(ctx, 32);
ctx.fillStyle = "#ffffff";
ctx.textAlign = "left";
ctx.fillText(user.displayName, displayNameX, displayNameY);

const displayNameSize = getTextSize(ctx, user.displayName);

//const debugImage =
// "https://external-content.duckduckgo.com/iu/?u=http%3A%2F%2Fwww.quickmeme.com%2Fimg%2F46%2F468394fc32d72c2bdc04abd04834782a2de7fee5834b5df2fa3b9295262db4cb.jpg&f=1&nofb=1&ipt=be4cd3afa2da068f2bb6b0639cb2ada1402241214afa20a08be89b7c7ee71485";
const roleIcon = user.roles.highest.iconURL({
size: 256,
extension: "jpg",
forceStatic: true,
});
if (roleIcon) {
// Draw user's role icon
const roleIconSize = 32;
await loadAndDrawImage(
ctx,
roleIcon,
displayNameX + displayNameSize.width + 8,
displayNameY - displayNameSize.height - 4,
roleIconSize,
roleIconSize,
);
}

// Draw user's avatar (scaled)
const avatarUrl = user.displayAvatarURL({ size: 256 });
await loadAndDrawImage(ctx, avatarUrl, padding, padding, 128, 128);

createLevelAndXPField(ctx, user, ddUser, 128 + 6 + padding, 52 + 25);

await createUserBumpFields(
ctx,
user,
ddUser,
128 + 6 + padding,
52 + 25 + 40,
);

const dividerWidth = w;

drawDivider(
ctx,
0,
h - 5,
dividerWidth,
"horizontal",
getDDColorGradiant(ctx, padding, dividerWidth),
2,
);
drawDeveloperDenText(ctx, w - padding, padding);
return ctx.canvas.toDataURL("image/png");
}

export function drawDeveloperDenText(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
) {
const text = "developer den";
setFont(ctx, 64);
const textSize = getTextSize(ctx, text);
const textWidth = textSize.width;
const textHeight = textSize.height;

drawDivider(
ctx,
x - textWidth,
y + textHeight - 8,
textWidth,
"horizontal",
getDDColorGradiant(ctx, x - textWidth, textWidth),
5,
);
ctx.fillStyle = "#ffffff";
ctx.textAlign = "right";
ctx.textBaseline = "bottom";
ctx.fillText(text, x, y + textHeight);

return textWidth;
}

export async function getProfileEmbed(user: GuildMember) {
const ddUser = await getOrCreateUserById(BigInt(user.id));
const image = await generateUserProfileImage(user, ddUser);

return {
image: image,
};
}
1 change: 1 addition & 0 deletions src/store/models/DDUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import {
AllowNull,
Attribute,
HasMany,
NotNull,
PrimaryKey,
Table,
Expand Down
50 changes: 50 additions & 0 deletions src/util/canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type CanvasRenderingContext2D, createCanvas, loadImage } from "canvas";
import { logger } from "../logging.js";
import { profileFont } from "../modules/user/user.js";
import { font } from "./imageUtils.js";

export function createCanvasContext(
width: number,
height: number,
): CanvasRenderingContext2D {
const canvas = createCanvas(width, height);
return canvas.getContext("2d");
}

export async function loadAndDrawImage(
ctx: CanvasRenderingContext2D,
imageUrl: string,
x: number,
y: number,
width: number,
height: number,
): Promise<void> {
try {
const image = await loadImage(imageUrl);
ctx.drawImage(image, x, y, width, height);
} catch (error) {
throw new Error(`Failed to load image from ${imageUrl}: ${error}`);
}
}

export function setFont(
ctx: CanvasRenderingContext2D,
size: number,
fontFamily: string = profileFont,
): void {
ctx.font = `bold ${size}px ${fontFamily}`;
}

export function getTextSize(
ctx: CanvasRenderingContext2D,
text: string,
): {
width: number;
height: number;
} {
const metrics = ctx.measureText(text);
return {
width: metrics.width,
height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
};
}
Loading