Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 7 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,8 @@
"afk": "everyone",
"github": "everyone",
"rank": "everyone",
"leaderboard": "everyone"
"leaderboard": "everyone",
"profile": "everyone"
}
},
"help": {
Expand Down Expand Up @@ -181,6 +182,11 @@
"afk": {
"enabled": false
},
"engagement": {
"enabled": false,
"trackMessages": true,
"trackReactions": true
},
"reputation": {
"enabled": false,
"xpPerMessage": [5, 15],
Expand Down
29 changes: 29 additions & 0 deletions migrations/008_user_stats.cjs
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)');
};

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS user_stats CASCADE');
};
1 change: 1 addition & 0 deletions src/api/utils/configAllowlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const SAFE_CONFIG_KEYS = new Set([
'tldr',
'afk',
'reputation',
'engagement',
'github',
]);

Expand Down
102 changes: 102 additions & 0 deletions src/commands/profile.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 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';

/**
* Return an activity badge based on days_active.
*
* @param {number} daysActive
* @returns {string}
*/
export function getActivityBadge(daysActive) {
if (daysActive >= 90) return '👑 Legend';
if (daysActive >= 30) return '🌳 Veteran';
if (daysActive >= 7) return '🌿 Regular';
return '🌱 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);

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.' });
}
}
110 changes: 110 additions & 0 deletions src/modules/engagement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* 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;

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.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<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
await 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.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)
const authorId = reaction.message.author?.id;
if (authorId && authorId !== user.id) {
await 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],
);
}
} catch (err) {
logError('Failed to track reaction engagement', {
userId: user.id,
guildId,
error: err.message,
});
throw err;
}
}
8 changes: 8 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading