From bffc320d01ed373ce29c562ed87a84d42d789f45 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 30 Jun 2026 11:25:05 +0300 Subject: [PATCH 1/4] fix: avoid double reply --- src/commands/ai/askai.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/commands/ai/askai.ts b/src/commands/ai/askai.ts index 4a5eee0..efd0a34 100644 --- a/src/commands/ai/askai.ts +++ b/src/commands/ai/askai.ts @@ -20,6 +20,9 @@ export default { async execute({ message, args, ctx }: CommandCallbackOpts) { if (message.author.bot) return; + if ((message as any)._processedByAskai) return; + (message as any)._processedByAskai = true; + const { imageUrl, mimeType } = getAttachmentData(message); const question = parseQuestion(args, !!imageUrl); From dfa7b0270812f94041d81aef3836a8ab4d997db7 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 30 Jun 2026 12:41:33 +0300 Subject: [PATCH 2/4] feat: add stats back --- README.md | 1 + src/commands/ai/askai.ts | 16 +++ src/commands/ai/stats.ts | 64 ++++++++++ src/utils/stats.ts | 203 ++++++++++++++++++++++++++++++++ src/visuals/statsCard.ts | 245 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 529 insertions(+) create mode 100644 src/commands/ai/stats.ts create mode 100644 src/utils/stats.ts create mode 100644 src/visuals/statsCard.ts diff --git a/README.md b/README.md index eca03c8..db21b4e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Example: $help $ping $usage +$stats ``` ## AI Command Usage diff --git a/src/commands/ai/askai.ts b/src/commands/ai/askai.ts index efd0a34..f5f352a 100644 --- a/src/commands/ai/askai.ts +++ b/src/commands/ai/askai.ts @@ -9,6 +9,7 @@ import { openRouter } from "../../utils/ai.ts"; import { addToContext, getContext } from "../../utils/context.ts"; import { pretty } from "../../utils/pretty.ts"; import { sanitizeForPrompt } from "../../utils/sanitize.ts"; +import { recordUsage } from "../../utils/stats.ts"; import { canUseAI, formatTimeLeft, setUsage } from "../../utils/usage.ts"; export let CURRENT_MODEL_INDEX = 0; @@ -82,10 +83,25 @@ export default { await message.reply({ content: pretty(result.text), }); + + /// token usage const tokensUsedByModel = result.usage?.totalTokens ?? 0; if (tokensUsedByModel > 0) { await setUsage(userId, tokensUsedByModel); } + + // stats + + const profile = { + username: message.author.username, + displayName: message.author.displayName, + avatar: message.author.displayAvatarURL({ + extension: "png", + size: 256, + }), + }; + + await recordUsage(userId, profile, tokensUsedByModel); } else { await message.reply( "Sorry, I encountered an issue processing your request right now.", diff --git a/src/commands/ai/stats.ts b/src/commands/ai/stats.ts new file mode 100644 index 0000000..5e92bc9 --- /dev/null +++ b/src/commands/ai/stats.ts @@ -0,0 +1,64 @@ +import { AttachmentBuilder } from "discord.js"; +import type { CommandCallbackOpts } from "../../types/command.ts"; +import { getUserStats } from "../../utils/stats.ts"; +import { renderStatsCard } from "../../visuals/statsCard.ts"; + +export default { + name: "stats", + description: "Show your AI usage statistics with heatmap", + aliases: ["mystats", "usage-stats"], + async execute({ message }: CommandCallbackOpts) { + if (message.author.bot) return; + + try { + const target = message.mentions?.users?.first() || message.author; + const userId = target.id; + + const stats = await getUserStats(userId); + + if (!stats.hasData) { + const who = + target.id === message.author.id + ? "You have" + : `${target.username} has`; + + await message.reply( + `${who} no AI usage recorded yet. Use \`$ai\` or \`,\` with a question to start tracking your stats.`, + ); + return; + } + + const member = message.guild + ? await message.guild.members.fetch(target.id).catch(() => null) + : null; + + const displayName = + member?.displayName || stats.displayName || target.username; + const avatarUrl = + target.displayAvatarURL({ extension: "png", size: 256 }) || + stats.avatar || + null; + + const buffer = await renderStatsCard({ + displayName, + handle: `@${target.username}`, + avatarUrl, + brand: "Rael", + heatmap: stats.heatmap, + lifetimeTokens: stats.lifetimeTokens, + peakDayTokens: stats.peakDayTokens, + currentStreak: stats.currentStreak, + longestStreak: stats.longestStreak, + }); + + const attachment = new AttachmentBuilder(buffer, { + name: `stats-${target.id}.png`, + }); + + await message.reply({ files: [attachment] }); + } catch (err) { + console.error("Stats command error:", err); + await message.reply("Could not generate your stats card right now."); + } + }, +}; diff --git a/src/utils/stats.ts b/src/utils/stats.ts new file mode 100644 index 0000000..be60dbe --- /dev/null +++ b/src/utils/stats.ts @@ -0,0 +1,203 @@ +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DATA_DIR = path.join(__dirname, "..", "..", "data"); +const STATS_FILE = path.join(DATA_DIR, "stats.json"); + +interface DailyRecord { + [date: string]: number; +} + +interface UserRecord { + username: string; + displayName: string; + avatar: string; + lifetimeTokens: number; + peakDayTokens: number; + daily: DailyRecord; +} + +interface StatsStore { + users: Record; +} + +let store: StatsStore = { users: {} }; +let isLoaded = false; +let writeQueued = false; +let isWriting = false; + +async function loadStore(): Promise { + if (isLoaded) return; + + try { + await fs.mkdir(DATA_DIR, { recursive: true }); + const raw = await fs.readFile(STATS_FILE, "utf-8"); + const parsed = JSON.parse(raw); + store = parsed?.users ? parsed : { users: {} }; + } catch { + store = { users: {} }; + } + isLoaded = true; +} + +async function saveStore(): Promise { + if (isWriting) { + writeQueued = true; + return; + } + + isWriting = true; + try { + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(STATS_FILE, JSON.stringify(store, null, 2), "utf-8"); + } catch (err) { + console.error("Failed to save stats.json:", err); + } finally { + isWriting = false; + if (writeQueued) { + writeQueued = false; + await saveStore(); + } + } +} + +function getDayKey(date = new Date()): string { + return date.toISOString().slice(0, 10); +} + +export async function recordUsage( + userId: string, + profile: { username?: string; displayName?: string; avatar?: string }, + tokens: number, +): Promise { + if (!userId) return; + + await loadStore(); + + if (!store.users[userId]) { + store.users[userId] = { + username: "", + displayName: "", + avatar: "", + lifetimeTokens: 0, + peakDayTokens: 0, + daily: {}, + }; + } + + const user = store.users[userId]; + + if (profile.username) user.username = profile.username; + if (profile.displayName) user.displayName = profile.displayName; + if (profile.avatar) user.avatar = profile.avatar; + + const safeTokens = Math.max(0, Math.floor(tokens)); + if (safeTokens > 0) { + const today = getDayKey(); + user.daily[today] = (user.daily[today] || 0) + safeTokens; + user.lifetimeTokens += safeTokens; + + if (user.daily[today] > user.peakDayTokens) { + user.peakDayTokens = user.daily[today]; + } + } + + await saveStore(); +} + +function computeStreaks(daily: DailyRecord = {}) { + const activeDays = new Set( + Object.keys(daily).filter((d) => daily[d] && daily[d] > 0), + ); + if (activeDays.size === 0) return { currentStreak: 0, longestStreak: 0 }; + + // Current streak + let currentStreak = 0; + const cursor = new Date(); + if (!activeDays.has(getDayKey(cursor))) { + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + while (activeDays.has(getDayKey(cursor))) { + currentStreak++; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } + + // Longest streak + const sorted = [...activeDays].sort(); + let longestStreak = 1; + let run = 1; + + for (let i = 1; i < sorted.length; i++) { + const prev = new Date(`${sorted[i - 1]}T00:00:00Z`); + const curr = new Date(`${sorted[i]}T00:00:00Z`); + const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000); + run = diff === 1 ? run + 1 : 1; + if (run > longestStreak) longestStreak = run; + } + + return { currentStreak, longestStreak }; +} + +export async function getUserStats(userId: string) { + await loadStore(); + const user = store.users[userId]; + + if (!user) { + return { + displayName: "", + username: "", + avatar: "", + lifetimeTokens: 0, + peakDayTokens: 0, + currentStreak: 0, + longestStreak: 0, + heatmap: { columns: [], max: 0 }, + hasData: false, + }; + } + + const { currentStreak, longestStreak } = computeStreaks(user.daily); + + // Build 30-week heatmap + const weeks = 30; + const today = new Date(); + const end = new Date( + Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()), + ); + end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay())); + + const totalDays = weeks * 7; + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - (totalDays - 1)); + + const columns: number[][] = []; + let max = 0; + const cursor = new Date(start); + + for (let w = 0; w < weeks; w++) { + const week: number[] = []; + for (let d = 0; d < 7; d++) { + const value = user.daily[getDayKey(cursor)] || 0; + if (value > max) max = value; + week.push(value); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + columns.push(week); + } + + return { + displayName: user.displayName, + username: user.username, + avatar: user.avatar, + lifetimeTokens: user.lifetimeTokens, + peakDayTokens: user.peakDayTokens, + currentStreak, + longestStreak, + heatmap: { columns, max }, + hasData: user.lifetimeTokens > 0, + }; +} diff --git a/src/visuals/statsCard.ts b/src/visuals/statsCard.ts new file mode 100644 index 0000000..b90fa83 --- /dev/null +++ b/src/visuals/statsCard.ts @@ -0,0 +1,245 @@ +import { createCanvas, GlobalFonts, loadImage } from "@napi-rs/canvas"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FONTS_DIR = path.join(__dirname, "..", "assets", "fonts"); + +let fontsRegistered = false; + +function ensureFonts() { + if (fontsRegistered) return; + try { + GlobalFonts.registerFromPath( + path.join(FONTS_DIR, "Inter-Regular.ttf"), + "Inter", + ); + GlobalFonts.registerFromPath( + path.join(FONTS_DIR, "Inter-SemiBold.ttf"), + "InterSemiBold", + ); + GlobalFonts.registerFromPath( + path.join(FONTS_DIR, "Inter-Bold.ttf"), + "InterBold", + ); + fontsRegistered = true; + } catch (err) { + console.error("Failed to register fonts:", err); + } +} + +const WIDTH = 1000; +const HEIGHT = 600; + +const COLORS = { + background: "#0a0a0a", + border: "#1c1c1e", + text: "#f2f2f2", + muted: "#7a7a7a", + divider: "#1c1c1e", + cellEmpty: "#1a1a1d", + ramp: ["#10213f", "#16306e", "#1d4ed8", "#3b82f6"], +}; + +function roundRectPath( + ctx: any, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const radius = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + ctx.closePath(); +} + +function formatTokens(value: number): string { + const n = Number(value) || 0; + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`; + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; + return String(n); +} + +function cellColor(value: number, max: number): string { + if (!value || value <= 0) return COLORS.cellEmpty; + const ratio = Math.sqrt(value / max); + const index = Math.min( + COLORS.ramp.length - 1, + Math.floor(ratio * COLORS.ramp.length), + ); + return COLORS.ramp[index] || ""; +} + +async function loadAvatar(url?: string | null) { + if (!url) return null; + try { + const pngUrl = url.replace(/\.(webp|gif)(\?|$)/i, ".png$2"); + const res = await fetch(pngUrl); + if (!res.ok) return null; + return await loadImage(Buffer.from(await res.arrayBuffer())); + } catch { + return null; + } +} + +function truncate(ctx: any, text: string, maxWidth: number): string { + if (ctx.measureText(text).width <= maxWidth) return text; + let result = text; + while (result.length > 1 && ctx.measureText(`${result}…`).width > maxWidth) { + result = result.slice(0, -1); + } + return `${result}…`; +} + +export interface StatsCardOptions { + displayName: string; + handle: string; + avatarUrl?: string | null; + brand?: string; + heatmap: { columns: number[][]; max: number }; + lifetimeTokens: number; + peakDayTokens: number; + currentStreak: number; + longestStreak: number; +} + +export async function renderStatsCard(opts: StatsCardOptions): Promise { + ensureFonts(); + + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = COLORS.background; + roundRectPath(ctx, 0, 0, WIDTH, HEIGHT, 28); + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = COLORS.border; + roundRectPath(ctx, 1, 1, WIDTH - 2, HEIGHT - 2, 28); + ctx.stroke(); + + const padX = 60; + + // Avatar + Header + const avatarSize = 110; + const avatarX = padX; + const avatarY = 56; + const avatar = await loadAvatar(opts.avatarUrl); + + ctx.save(); + ctx.beginPath(); + ctx.arc( + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize / 2, + 0, + Math.PI * 2, + ); + ctx.closePath(); + ctx.clip(); + if (avatar) { + ctx.drawImage(avatar, avatarX, avatarY, avatarSize, avatarSize); + } else { + ctx.fillStyle = "#1d4ed8"; + ctx.fillRect(avatarX, avatarY, avatarSize, avatarSize); + } + ctx.restore(); + + const textX = avatarX + avatarSize + 36; + ctx.fillStyle = COLORS.text; + ctx.font = "44px InterBold"; + ctx.textBaseline = "alphabetic"; + ctx.fillText( + truncate(ctx, opts.displayName || "Unknown", 460), + textX, + avatarY + 52, + ); + + ctx.fillStyle = COLORS.muted; + ctx.font = "26px Inter"; + ctx.fillText(truncate(ctx, opts.handle || "", 460), textX, avatarY + 90); + + if (opts.brand) { + ctx.fillStyle = COLORS.muted; + ctx.font = "34px InterSemiBold"; + ctx.textAlign = "right"; + ctx.fillText(opts.brand, WIDTH - padX, avatarY + 70); + ctx.textAlign = "left"; + } + + // Heatmap + const heatmap = opts.heatmap; + const weeks = heatmap.columns.length || 30; + const rows = 7; + const gridTop = 190; + const gridBottom = 430; + const gridLeft = padX; + const gridRight = WIDTH - padX; + + const gap = 6; + const cell = Math.min( + (gridRight - gridLeft - (weeks - 1) * gap) / weeks, + (gridBottom - gridTop - (rows - 1) * gap) / rows, + ); + const radius = Math.max(3, cell * 0.28); + + for (let c = 0; c < weeks; c++) { + const week = heatmap.columns[c] || []; + for (let r = 0; r < rows; r++) { + const value = week[r] || 0; + const x = gridLeft + c * (cell + gap); + const y = gridTop + r * (cell + gap); + ctx.fillStyle = cellColor(value, heatmap.max); + roundRectPath(ctx, x, y, cell, cell, radius); + ctx.fill(); + } + } + + // Metrics + const metricsTop = 480; + ctx.strokeStyle = COLORS.divider; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(padX, metricsTop - 24); + ctx.lineTo(WIDTH - padX, metricsTop - 24); + ctx.stroke(); + + const metrics = [ + { value: formatTokens(opts.lifetimeTokens), label: "lifetime tokens" }, + { value: formatTokens(opts.peakDayTokens), label: "peak day" }, + { value: `${opts.currentStreak} days`, label: "current streak" }, + { value: `${opts.longestStreak} days`, label: "longest streak" }, + ]; + + const colWidth = (WIDTH - padX * 2) / metrics.length; + + metrics.forEach((metric, i) => { + const centerX = padX + colWidth * i + colWidth / 2; + ctx.fillStyle = COLORS.text; + ctx.font = "46px InterBold"; + ctx.textAlign = "center"; + ctx.fillText(metric.value, centerX, metricsTop + 28); + + ctx.fillStyle = COLORS.muted; + ctx.font = "24px Inter"; + ctx.fillText(metric.label, centerX, metricsTop + 64); + + if (i > 0) { + const sepX = padX + colWidth * i; + ctx.strokeStyle = COLORS.divider; + ctx.beginPath(); + ctx.moveTo(sepX, metricsTop - 4); + ctx.lineTo(sepX, metricsTop + 56); + ctx.stroke(); + } + }); + + ctx.textAlign = "left"; + return canvas.toBuffer("image/png"); +} From 441f277d27f31f90984684899b4b66b2dc1aff81 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 30 Jun 2026 12:44:24 +0300 Subject: [PATCH 3/4] perf: store unformatted json --- src/utils/stats.ts | 2 +- src/utils/usage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index be60dbe..b632462 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -53,7 +53,7 @@ async function saveStore(): Promise { isWriting = true; try { await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(STATS_FILE, JSON.stringify(store, null, 2), "utf-8"); + await fs.writeFile(STATS_FILE, JSON.stringify(store), "utf-8"); } catch (err) { console.error("Failed to save stats.json:", err); } finally { diff --git a/src/utils/usage.ts b/src/utils/usage.ts index deb0a7f..2b130e3 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -36,7 +36,7 @@ async function loadUsage(): Promise { async function saveUsage(data: UsageData): Promise { usageCache = data; await fs.mkdir(path.dirname(USAGE_FILE), { recursive: true }); - await fs.writeFile(USAGE_FILE, JSON.stringify(data, null, 2), "utf-8"); + await fs.writeFile(USAGE_FILE, JSON.stringify(data), "utf-8"); } export async function getUsage(userId: string): Promise { From c2a78af09fca1dc076d3250583816fcaa8d04701 Mon Sep 17 00:00:00 2001 From: Caleb Date: Tue, 30 Jun 2026 12:51:13 +0300 Subject: [PATCH 4/4] fix: add error handler on recordUsage Co-authored-by: beetle-ai[bot] <221859081+beetle-ai[bot]@users.noreply.github.com> --- src/commands/ai/askai.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/ai/askai.ts b/src/commands/ai/askai.ts index f5f352a..a14c141 100644 --- a/src/commands/ai/askai.ts +++ b/src/commands/ai/askai.ts @@ -101,7 +101,9 @@ export default { }), }; - await recordUsage(userId, profile, tokensUsedByModel); + recordUsage(userId, profile, tokensUsedByModel).catch((err) => { + console.error("[Stats] Failed to record usage:", err); + }); } else { await message.reply( "Sorry, I encountered an issue processing your request right now.",