Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 13 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,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],
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
112 changes: 112 additions & 0 deletions src/commands/profile.js
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.' });
}
}
114 changes: 114 additions & 0 deletions src/modules/engagement.js
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;

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<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
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;
}
}
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