diff --git a/src/index.ts b/src/index.ts index e5153ed0..005a1f05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -67,6 +68,7 @@ export const moduleManager = new ModuleManager( StarboardModule, ModmailModule, LeaderboardModule, + UserModule, ], ); diff --git a/src/modules/user/profileCommand.ts b/src/modules/user/profileCommand.ts new file mode 100644 index 00000000..a2a99c2e --- /dev/null +++ b/src/modules/user/profileCommand.ts @@ -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 = { + 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", + }, + ), + ], + }); + }, +}; diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts new file mode 100644 index 00000000..f7ac60c5 --- /dev/null +++ b/src/modules/user/user.module.ts @@ -0,0 +1,7 @@ +import type Module from "../module.js"; +import { ProfileCommand } from "./profileCommand.js"; + +export const UserModule: Module = { + name: "user", + commands: [ProfileCommand], +}; diff --git a/src/modules/user/user.ts b/src/modules/user/user.ts new file mode 100644 index 00000000..49a2d100 --- /dev/null +++ b/src/modules/user/user.ts @@ -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"; + 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 { + 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, + }; +} diff --git a/src/store/models/DDUser.ts b/src/store/models/DDUser.ts index 1eb8bcc8..b58ff6a9 100644 --- a/src/store/models/DDUser.ts +++ b/src/store/models/DDUser.ts @@ -9,6 +9,7 @@ import { import { AllowNull, Attribute, + HasMany, NotNull, PrimaryKey, Table, diff --git a/src/util/canvas.ts b/src/util/canvas.ts new file mode 100644 index 00000000..4fe27a8a --- /dev/null +++ b/src/util/canvas.ts @@ -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 { + 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, + }; +}