-
Notifications
You must be signed in to change notification settings - Fork 1
feat: /profile command with engagement tracking #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
30b3098
feat: /profile command with engagement tracking (#44)
BillChirico 0ebdb99
fix: respect trackMessages and trackReactions config flags in engagem…
BillChirico d05a614
fix(engagement): exclude bots, fix last_active on reactions_received,…
BillChirico 83731bd
fix(engagement): bot guard for reactions_received, last_active upsert…
BillChirico fc16e05
fix(engagement): don't update last_active on passive reactions_received
BillChirico bb3a11a
fix(engagement): don't update last_active on passive reactions_received
BillChirico 05fce9f
fix: guard days_active off-by-one when row seeded by reactions_received
BillChirico 740fa32
fix(engagement): days_active tracking on reactions_received, parallel…
BillChirico a7c855e
fix(engagement): don't inflate days_active for passive reactions_rece…
BillChirico 6862b82
feat(engagement): configurable activity badges via config + dashboard…
BillChirico File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)'); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| /** @param {import('node-pg-migrate').MigrationBuilder} pgm */ | ||
| exports.down = (pgm) => { | ||
| pgm.sql('DROP TABLE IF EXISTS user_stats CASCADE'); | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,7 @@ export const SAFE_CONFIG_KEYS = new Set([ | |
| 'tldr', | ||
| 'afk', | ||
| 'reputation', | ||
| 'engagement', | ||
| 'github', | ||
| ]); | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.' }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void>} | ||
| */ | ||
| 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; | ||
|
|
||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| END, | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| last_active = NOW()`, | ||
| [message.guild.id, message.author.id, now.toISOString()], | ||
| ); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } catch (err) { | ||
| logError('Failed to track message engagement', { | ||
| userId: message.author.id, | ||
| guildId: message.guild.id, | ||
| error: err.message, | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
| throw err; | ||
| } | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| /** | ||
| * 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<void>} | ||
| */ | ||
| 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 | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| END, | ||
| last_active = NOW()`, | ||
| [guildId, user.id, now.toISOString()], | ||
| ); | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // 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; | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| await Promise.all([givenQuery, receivedQuery].filter(Boolean)); | ||
| } catch (err) { | ||
| logError('Failed to track reaction engagement', { | ||
| userId: user.id, | ||
| guildId, | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| error: err.message, | ||
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
BillChirico marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }); | ||
| throw err; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.