diff --git a/config.json b/config.json index 58096056..212f3293 100644 --- a/config.json +++ b/config.json @@ -149,7 +149,8 @@ "afk": "everyone", "github": "everyone", "rank": "everyone", - "leaderboard": "everyone" + "leaderboard": "everyone", + "profile": "everyone" } }, "help": { @@ -181,6 +182,17 @@ "afk": { "enabled": false }, + "engagement": { + "enabled": false, + "trackMessages": true, + "trackReactions": true, + "activityBadges": [ + { "days": 90, "label": "👑 Legend" }, + { "days": 30, "label": "🌳 Veteran" }, + { "days": 7, "label": "🌿 Regular" }, + { "days": 0, "label": "🌱 Newcomer" } + ] + }, "reputation": { "enabled": false, "xpPerMessage": [5, 15], diff --git a/migrations/008_user_stats.cjs b/migrations/008_user_stats.cjs new file mode 100644 index 00000000..b0ab4996 --- /dev/null +++ b/migrations/008_user_stats.cjs @@ -0,0 +1,29 @@ +/** + * Add user_stats table for engagement tracking (/profile command). + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/44 + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS user_stats ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + messages_sent INTEGER DEFAULT 0, + reactions_given INTEGER DEFAULT 0, + reactions_received INTEGER DEFAULT 0, + days_active INTEGER DEFAULT 0, + first_seen TIMESTAMPTZ DEFAULT NOW(), + last_active TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (guild_id, user_id) + ) + `); + + pgm.sql('CREATE INDEX IF NOT EXISTS idx_user_stats_guild ON user_stats(guild_id)'); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS user_stats CASCADE'); +}; diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index f26a2917..18cac4fe 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -20,6 +20,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'tldr', 'afk', 'reputation', + 'engagement', 'github', ]); diff --git a/src/commands/profile.js b/src/commands/profile.js new file mode 100644 index 00000000..c26cd856 --- /dev/null +++ b/src/commands/profile.js @@ -0,0 +1,112 @@ +/** + * Profile Command + * Show a user's engagement stats (messages, reactions, days active). + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/44 + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { error as logError } from '../logger.js'; +import { getConfig } from '../modules/config.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +/** Default activity badge tiers (threshold in days → emoji + label). */ +const DEFAULT_BADGES = [ + { days: 90, label: '👑 Legend' }, + { days: 30, label: '🌳 Veteran' }, + { days: 7, label: '🌿 Regular' }, + { days: 0, label: '🌱 Newcomer' }, +]; + +/** + * Return an activity badge based on days_active and config. + * + * @param {number} daysActive + * @param {Array<{days: number, label: string}>} [badges] - Custom badge tiers from config, sorted descending by days. + * @returns {string} + */ +export function getActivityBadge(daysActive, badges) { + const tiers = badges?.length ? [...badges].sort((a, b) => b.days - a.days) : DEFAULT_BADGES; + for (const tier of tiers) { + if (daysActive >= tier.days) return tier.label; + } + return tiers[tiers.length - 1]?.label ?? '🌱 Newcomer'; +} + +export const data = new SlashCommandBuilder() + .setName('profile') + .setDescription("Show your (or another user's) engagement profile") + .addUserOption((opt) => + opt.setName('user').setDescription('User to look up (defaults to you)').setRequired(false), + ); + +/** + * Execute the /profile command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply(); + + if (!interaction.guildId) { + return safeEditReply(interaction, { content: '❌ This command can only be used in a server.' }); + } + + const config = getConfig(interaction.guildId); + if (!config?.engagement?.enabled) { + return safeEditReply(interaction, { + content: '❌ Engagement tracking is not enabled on this server.', + }); + } + + try { + const pool = getPool(); + const target = interaction.options.getUser('user') ?? interaction.user; + + const { rows } = await pool.query( + `SELECT messages_sent, reactions_given, reactions_received, days_active, first_seen, last_active + FROM user_stats + WHERE guild_id = $1 AND user_id = $2`, + [interaction.guildId, target.id], + ); + + const stats = rows[0] ?? { + messages_sent: 0, + reactions_given: 0, + reactions_received: 0, + days_active: 0, + first_seen: null, + last_active: null, + }; + + const badge = getActivityBadge(stats.days_active, config.engagement?.activityBadges); + + const formatDate = (d) => + d ? new Date(d).toLocaleDateString('en-US', { dateStyle: 'medium' }) : 'Never'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setAuthor({ + name: target.displayName ?? target.username, + iconURL: target.displayAvatarURL(), + }) + .addFields( + { name: 'Messages Sent', value: String(stats.messages_sent), inline: true }, + { name: 'Reactions Given', value: String(stats.reactions_given), inline: true }, + { name: 'Reactions Received', value: String(stats.reactions_received), inline: true }, + { name: 'Days Active', value: String(stats.days_active), inline: true }, + { name: 'Activity Badge', value: badge, inline: true }, + { name: '\u200b', value: '\u200b', inline: true }, + { name: 'First Seen', value: formatDate(stats.first_seen), inline: true }, + { name: 'Last Active', value: formatDate(stats.last_active), inline: true }, + ) + .setThumbnail(target.displayAvatarURL()) + .setTimestamp(); + + await safeEditReply(interaction, { embeds: [embed] }); + } catch (err) { + logError('Profile command failed', { error: err.message, stack: err.stack }); + await safeEditReply(interaction, { content: '❌ Something went wrong fetching the profile.' }); + } +} diff --git a/src/modules/engagement.js b/src/modules/engagement.js new file mode 100644 index 00000000..1faa471e --- /dev/null +++ b/src/modules/engagement.js @@ -0,0 +1,114 @@ +/** + * Engagement Tracking Module + * Tracks user activity stats (messages, reactions, days active) for the /profile command. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/44 + */ + +import { getPool } from '../db.js'; +import { error as logError } from '../logger.js'; +import { getConfig } from './config.js'; + +/** + * Track a message sent by a user in a guild. + * Fire-and-forget: caller should use `.catch(() => {})`. + * + * @param {import('discord.js').Message} message + * @returns {Promise} + */ +export async function trackMessage(message) { + if (!message.guild) return; + if (message.author?.bot) return; + + const config = getConfig(message.guild.id); + if (!config?.engagement?.enabled) return; + if (!config.engagement.trackMessages) return; + + try { + const pool = getPool(); + const now = new Date(); + + await pool.query( + `INSERT INTO user_stats (guild_id, user_id, messages_sent, days_active, first_seen, last_active) + VALUES ($1, $2, 1, 1, NOW(), NOW()) + ON CONFLICT (guild_id, user_id) DO UPDATE + SET messages_sent = user_stats.messages_sent + 1, + days_active = CASE + WHEN user_stats.days_active = 0 OR user_stats.last_active::date < $3::date + THEN user_stats.days_active + 1 + ELSE user_stats.days_active + END, + last_active = NOW()`, + [message.guild.id, message.author.id, now.toISOString()], + ); + } catch (err) { + logError('Failed to track message engagement', { + userId: message.author.id, + guildId: message.guild.id, + error: err.message, + }); + throw err; + } +} + +/** + * Track a reaction added by a user. + * Increments reactions_given for the reactor and reactions_received for the message author. + * Fire-and-forget: caller should use `.catch(() => {})`. + * + * @param {import('discord.js').MessageReaction} reaction + * @param {import('discord.js').User} user + * @returns {Promise} + */ +export async function trackReaction(reaction, user) { + const guildId = reaction.message.guild?.id; + if (!guildId) return; + if (user.bot) return; + + const config = getConfig(guildId); + if (!config?.engagement?.enabled) return; + if (!config.engagement.trackReactions) return; + + try { + const pool = getPool(); + const now = new Date(); + + // Increment reactions_given for the reactor + const givenQuery = pool.query( + `INSERT INTO user_stats (guild_id, user_id, reactions_given, days_active, first_seen, last_active) + VALUES ($1, $2, 1, 1, NOW(), NOW()) + ON CONFLICT (guild_id, user_id) DO UPDATE + SET reactions_given = user_stats.reactions_given + 1, + days_active = CASE + WHEN user_stats.days_active = 0 OR user_stats.last_active::date < $3::date + THEN user_stats.days_active + 1 + ELSE user_stats.days_active + END, + last_active = NOW()`, + [guildId, user.id, now.toISOString()], + ); + + // Increment reactions_received for message author (skip if author is the reactor or a bot) + const messageAuthor = reaction.message.author; + const authorId = messageAuthor?.id; + const receivedQuery = + authorId && authorId !== user.id && !messageAuthor?.bot + ? pool.query( + `INSERT INTO user_stats (guild_id, user_id, reactions_received, first_seen) + VALUES ($1, $2, 1, NOW()) + ON CONFLICT (guild_id, user_id) DO UPDATE + SET reactions_received = user_stats.reactions_received + 1`, + [guildId, authorId], + ) + : null; + + await Promise.all([givenQuery, receivedQuery].filter(Boolean)); + } catch (err) { + logError('Failed to track reaction engagement', { + userId: user.id, + guildId, + error: err.message, + }); + throw err; + } +} diff --git a/src/modules/events.js b/src/modules/events.js index db1cd02e..ecd4bc47 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -12,6 +12,7 @@ import { getUserFriendlyMessage } from '../utils/errors.js'; import { safeReply } from '../utils/safeSend.js'; import { handleAfkMentions } from './afkHandler.js'; import { getConfig } from './config.js'; +import { trackMessage, trackReaction } from './engagement.js'; import { checkLinks } from './linkFilter.js'; import { handlePollVote } from './pollHandler.js'; import { checkRateLimit } from './rateLimit.js'; @@ -152,6 +153,9 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { // Feed welcome-context activity tracker recordCommunityActivity(message, guildConfig); + // Engagement tracking (fire-and-forget, non-blocking) + trackMessage(message).catch(() => {}); + // XP gain (fire-and-forget, non-blocking) handleXpGain(message).catch((err) => { logError('XP gain handler failed', { @@ -269,6 +273,10 @@ export function registerReactionHandlers(client, _config) { if (!guildId) return; const guildConfig = getConfig(guildId); + + // Engagement tracking (fire-and-forget) + trackReaction(reaction, user).catch(() => {}); + if (!guildConfig.starboard?.enabled) return; try { diff --git a/tests/commands/profile.test.js b/tests/commands/profile.test.js new file mode 100644 index 00000000..54b024bb --- /dev/null +++ b/tests/commands/profile.test.js @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ engagement: { enabled: true } }), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: vi.fn(), +})); + +vi.mock('discord.js', () => { + class MockSlashCommandBuilder { + setName() { + return this; + } + setDescription() { + return this; + } + addUserOption(fn) { + fn({ + setName: () => ({ setDescription: () => ({ setRequired: () => ({}) }) }), + }); + return this; + } + } + + class MockEmbedBuilder { + setColor() { + return this; + } + setAuthor() { + return this; + } + addFields() { + return this; + } + setThumbnail() { + return this; + } + setTimestamp() { + return this; + } + } + + return { SlashCommandBuilder: MockSlashCommandBuilder, EmbedBuilder: MockEmbedBuilder }; +}); + +import { execute, getActivityBadge } from '../../src/commands/profile.js'; +import { getPool } from '../../src/db.js'; +import { getConfig } from '../../src/modules/config.js'; +import { safeEditReply } from '../../src/utils/safeSend.js'; + +function makeUser(id = 'user1') { + return { + id, + username: `User_${id}`, + displayName: `DisplayUser_${id}`, + displayAvatarURL: vi.fn().mockReturnValue(`http://avatar/${id}`), + }; +} + +function makeInteraction({ userId = 'user1', targetUser = null, guildId = 'guild1' } = {}) { + const user = makeUser(userId); + const target = targetUser ?? null; + return { + deferReply: vi.fn().mockResolvedValue(undefined), + guildId, + user, + options: { + getUser: vi.fn().mockReturnValue(target), + }, + }; +} + +function makePool(rows = []) { + return { query: vi.fn().mockResolvedValue({ rows }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + getConfig.mockReturnValue({ engagement: { enabled: true } }); +}); + +describe('getActivityBadge', () => { + it('returns Newcomer for <7 days', () => { + expect(getActivityBadge(0)).toBe('🌱 Newcomer'); + expect(getActivityBadge(6)).toBe('🌱 Newcomer'); + }); + + it('returns Regular for 7-29 days', () => { + expect(getActivityBadge(7)).toBe('🌿 Regular'); + expect(getActivityBadge(29)).toBe('🌿 Regular'); + }); + + it('returns Veteran for 30-89 days', () => { + expect(getActivityBadge(30)).toBe('🌳 Veteran'); + expect(getActivityBadge(89)).toBe('🌳 Veteran'); + }); + + it('returns Legend for 90+ days', () => { + expect(getActivityBadge(90)).toBe('👑 Legend'); + expect(getActivityBadge(200)).toBe('👑 Legend'); + }); + + it('uses custom badges from config', () => { + const custom = [ + { days: 365, label: '🏆 OG' }, + { days: 14, label: '⭐ Active' }, + { days: 0, label: '🆕 Fresh' }, + ]; + expect(getActivityBadge(400, custom)).toBe('🏆 OG'); + expect(getActivityBadge(14, custom)).toBe('⭐ Active'); + expect(getActivityBadge(5, custom)).toBe('🆕 Fresh'); + }); + + it('handles unsorted custom badges', () => { + const unsorted = [ + { days: 0, label: '🆕 New' }, + { days: 50, label: '🔥 Hot' }, + ]; + expect(getActivityBadge(50, unsorted)).toBe('🔥 Hot'); + expect(getActivityBadge(3, unsorted)).toBe('🆕 New'); + }); + + it('falls back to defaults when custom badges is empty', () => { + expect(getActivityBadge(90, [])).toBe('👑 Legend'); + }); +}); + +describe('/profile execute', () => { + it('returns error when engagement is disabled', async () => { + getConfig.mockReturnValue({ engagement: { enabled: false } }); + const interaction = makeInteraction(); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('not enabled') }), + ); + }); + + it('returns error when not in a guild', async () => { + const interaction = makeInteraction({ guildId: null }); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('only be used in a server') }), + ); + }); + + it('shows zeroed stats when user has no record', async () => { + const pool = makePool([]); + getPool.mockReturnValue(pool); + const interaction = makeInteraction(); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('shows stats for a user with a record', async () => { + const pool = makePool([ + { + messages_sent: 100, + reactions_given: 20, + reactions_received: 15, + days_active: 10, + first_seen: new Date('2024-01-01'), + last_active: new Date('2024-06-01'), + }, + ]); + getPool.mockReturnValue(pool); + const interaction = makeInteraction(); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('shows another user profile when user option is provided', async () => { + const pool = makePool([ + { + messages_sent: 50, + reactions_given: 5, + reactions_received: 3, + days_active: 95, + first_seen: new Date('2023-01-01'), + last_active: new Date('2024-05-01'), + }, + ]); + getPool.mockReturnValue(pool); + const targetUser = makeUser('other-user'); + const interaction = makeInteraction({ targetUser }); + interaction.options.getUser.mockReturnValue(targetUser); + await execute(interaction); + expect(pool.query).toHaveBeenCalledWith(expect.any(String), ['guild1', 'other-user']); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('returns error when db throws', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('DB down')), + }); + const interaction = makeInteraction(); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Something went wrong') }), + ); + }); + + it('uses self when no user option given', async () => { + const pool = makePool([]); + getPool.mockReturnValue(pool); + const interaction = makeInteraction(); + interaction.options.getUser.mockReturnValue(null); + await execute(interaction); + expect(pool.query).toHaveBeenCalledWith(expect.any(String), ['guild1', 'user1']); + }); +}); diff --git a/tests/modules/engagement.test.js b/tests/modules/engagement.test.js new file mode 100644 index 00000000..6e627f44 --- /dev/null +++ b/tests/modules/engagement.test.js @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + engagement: { enabled: true, trackMessages: true, trackReactions: true }, + }), +})); + +import { getPool } from '../../src/db.js'; +import { error as logError } from '../../src/logger.js'; +import { getConfig } from '../../src/modules/config.js'; +import { trackMessage, trackReaction } from '../../src/modules/engagement.js'; + +function makeMessage({ guildId = 'guild1', userId = 'user1' } = {}) { + return { + author: { id: userId }, + guild: { id: guildId }, + }; +} + +function makeReaction({ guildId = 'guild1', authorId = 'author1' } = {}) { + return { + message: { + guild: { id: guildId }, + author: { id: authorId }, + }, + }; +} + +function makeUser(id = 'reactor1') { + return { id }; +} + +function makePool() { + return { query: vi.fn().mockResolvedValue({ rows: [] }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + getConfig.mockReturnValue({ + engagement: { enabled: true, trackMessages: true, trackReactions: true }, + }); +}); + +describe('trackMessage', () => { + it('upserts message stat when enabled', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackMessage(makeMessage()); + expect(pool.query).toHaveBeenCalledTimes(1); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO user_stats'), + expect.arrayContaining(['guild1', 'user1']), + ); + }); + + it('does nothing when engagement is disabled', async () => { + getConfig.mockReturnValue({ engagement: { enabled: false } }); + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackMessage(makeMessage()); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when trackMessages is false', async () => { + getConfig.mockReturnValue({ + engagement: { enabled: true, trackMessages: false, trackReactions: true }, + }); + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackMessage(makeMessage()); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when message has no guild', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackMessage({ author: { id: 'user1' }, guild: null }); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when message author is a bot', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackMessage({ author: { id: 'bot1', bot: true }, guild: { id: 'guild1' } }); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('logs error and re-throws on db failure', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('connection refused')), + }); + await expect(trackMessage(makeMessage())).rejects.toThrow('connection refused'); + expect(logError).toHaveBeenCalled(); + }); +}); + +describe('trackReaction', () => { + it('increments reactions_given for reactor and reactions_received for author', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackReaction(makeReaction({ authorId: 'author1' }), makeUser('reactor1')); + expect(pool.query).toHaveBeenCalledTimes(2); + // First call: reactions_given for reactor + expect(pool.query.mock.calls[0][1]).toContain('reactor1'); + // Second call: reactions_received for author + expect(pool.query.mock.calls[1][1]).toContain('author1'); + }); + + it('skips reactions_received when reactor is the message author', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackReaction(makeReaction({ authorId: 'same-user' }), makeUser('same-user')); + expect(pool.query).toHaveBeenCalledTimes(1); + }); + + it('does nothing when engagement is disabled', async () => { + getConfig.mockReturnValue({ engagement: { enabled: false } }); + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackReaction(makeReaction(), makeUser()); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when trackReactions is false', async () => { + getConfig.mockReturnValue({ + engagement: { enabled: true, trackMessages: true, trackReactions: false }, + }); + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackReaction(makeReaction(), makeUser()); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when reaction has no guild', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + const reaction = { message: { guild: null, author: { id: 'a1' } } }; + await trackReaction(reaction, makeUser()); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('does nothing when reactor user is a bot', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + await trackReaction(makeReaction(), { id: 'bot1', bot: true }); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('skips reactions_received when message author is a bot', async () => { + const pool = makePool(); + getPool.mockReturnValue(pool); + const reaction = { + message: { guild: { id: 'guild1' }, author: { id: 'botAuthor', bot: true } }, + }; + await trackReaction(reaction, makeUser('reactor1')); + expect(pool.query).toHaveBeenCalledTimes(1); // only reactions_given + }); + + it('logs error and re-throws on db failure', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('timeout')), + }); + await expect(trackReaction(makeReaction(), makeUser())).rejects.toThrow('timeout'); + expect(logError).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index a1386a28..135f5348 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -48,7 +48,7 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== "object" || data === null || Array.isArray(data)) return false; const obj = data as Record; - const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "tldr", "reputation", "afk", "github"] as const; + const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "tldr", "reputation", "afk", "engagement", "github"] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; for (const key of knownSections) { @@ -1202,6 +1202,7 @@ export function ConfigEditor() { { key: "poll", label: "Polls", desc: "/poll for community voting" }, { key: "tldr", label: "TL;DR Summaries", desc: "/tldr for AI channel summaries" }, { key: "afk", label: "AFK System", desc: "/afk auto-respond when members are away" }, + { key: "engagement", label: "Engagement Tracking", desc: "/profile stats — messages, reactions, days active" }, ] as const).map(({ key, label, desc }) => (
@@ -1224,6 +1225,78 @@ export function ConfigEditor() { + {/* ═══ Engagement / Activity Badges ═══ */} + + + Activity Badges +

Configure the badge tiers shown on /profile. Each badge requires a minimum number of active days.

+ {(draftConfig.engagement?.activityBadges ?? [ + { days: 90, label: "👑 Legend" }, + { days: 30, label: "🌳 Veteran" }, + { days: 7, label: "🌿 Regular" }, + { days: 0, label: "🌱 Newcomer" }, + ]).map((badge: { days: number; label: string }, i: number) => ( +
+ { + const badges = [...(draftConfig.engagement?.activityBadges ?? [ + { days: 90, label: "👑 Legend" }, + { days: 30, label: "🌳 Veteran" }, + { days: 7, label: "🌿 Regular" }, + { days: 0, label: "🌱 Newcomer" }, + ])]; + badges[i] = { ...badges[i], days: Math.max(0, parseInt(e.target.value, 10) || 0) }; + setDraftConfig((prev) => ({ ...prev, engagement: { ...prev.engagement, activityBadges: badges } })); + }} + disabled={saving} + /> + days → + { + const badges = [...(draftConfig.engagement?.activityBadges ?? [ + { days: 90, label: "👑 Legend" }, + { days: 30, label: "🌳 Veteran" }, + { days: 7, label: "🌿 Regular" }, + { days: 0, label: "🌱 Newcomer" }, + ])]; + badges[i] = { ...badges[i], label: e.target.value }; + setDraftConfig((prev) => ({ ...prev, engagement: { ...prev.engagement, activityBadges: badges } })); + }} + disabled={saving} + /> + +
+ ))} + +
+
+ {/* ═══ Reputation / XP Settings ═══ */}