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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,19 @@ All configuration lives in `config.json` and can be updated at runtime via the `
| `selfStarAllowed` | boolean | Allow users to star their own messages |
| `ignoredChannels` | string[] | Channel IDs excluded from starboard tracking |

### Reputation (`reputation`)

| Key | Type | Description |
|-----|------|-------------|
| `enabled` | boolean | Enable the XP / leveling system |
| `xpPerMessage` | [number, number] | Random XP range awarded per message `[min, max]` (default: `[5, 15]`) |
| `xpCooldownSeconds` | number | Minimum seconds between XP awards per user (default: `60`) |
| `announceChannelId` | string\|null | Channel ID for level-up announcements (null = DM user) |
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning: Documentation says "null = DM user" but implementation skips announcements

The description for announceChannelId says (null = DM user), but the actual implementation in reputation.js:164-190 simply skips the announcement when announceChannelId is null — it never DMs the user. Either implement DM functionality or fix the docs:

Suggested change
| `announceChannelId` | string\|null | Channel ID for level-up announcements (null = DM user) |
| `announceChannelId` | string\|null | Channel ID for level-up announcements (null = no announcement) |

| `levelThresholds` | number[] | Cumulative XP required for each level (L1, L2, …). Must be strictly ascending. (default: `[100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000]`) |
| `roleRewards` | object | Map of level number → role ID to auto-assign on level-up (e.g. `{ "5": "123456789" }`) |

**Commands:** `/rank [user]` — show XP, level, and progress bar. `/leaderboard` — top 10 users by XP.

### Permissions (`permissions`)

| Key | Type | Description |
Expand Down
12 changes: 11 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@
"announce": "moderator",
"tldr": "everyone",
"afk": "everyone",
"github": "everyone"
"github": "everyone",
"rank": "everyone",
"leaderboard": "everyone"
}
},
"help": {
Expand Down Expand Up @@ -178,5 +180,13 @@
},
"afk": {
"enabled": false
},
"reputation": {
"enabled": false,
"xpPerMessage": [5, 15],
"xpCooldownSeconds": 60,
"announceChannelId": null,
"levelThresholds": [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000],
"roleRewards": {}
}
}
31 changes: 31 additions & 0 deletions migrations/007_reputation.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Add reputation table for XP/leveling system.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/45
*/

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS reputation (
id SERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 0,
messages_count INTEGER NOT NULL DEFAULT 0,
voice_minutes INTEGER NOT NULL DEFAULT 0,
helps_given INTEGER NOT NULL DEFAULT 0,
last_xp_gain TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(guild_id, user_id)
)
`);

pgm.sql('CREATE INDEX IF NOT EXISTS idx_reputation_guild_xp ON reputation(guild_id, xp DESC)');
};

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

export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging'];
Expand Down
79 changes: 79 additions & 0 deletions src/commands/leaderboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Leaderboard Command
* Show the top 10 users by XP in this server.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/45
*/

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

export const data = new SlashCommandBuilder()
.setName('leaderboard')
.setDescription('Show the top 10 members by XP in this server');

/**
* Execute the /leaderboard command.
*
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
export async function execute(interaction) {
await interaction.deferReply();

const cfg = getConfig(interaction.guildId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning: Missing guild-context guard

/rank has a !interaction.guildId guard (rank.js:31-33) but /leaderboard does not. If somehow invoked outside a guild context, getConfig(interaction.guildId) receives undefined and the query on line 39 uses undefined as a parameter.

Suggested change
const cfg = getConfig(interaction.guildId);
const cfg = getConfig(interaction.guildId);
if (!interaction.guildId || !cfg?.reputation?.enabled) {

if (!cfg?.reputation?.enabled) {
return safeEditReply(interaction, { content: 'Reputation system is not enabled.' });
}

try {
const pool = getPool();
const { rows } = await pool.query(
`SELECT user_id, xp, level
FROM reputation
WHERE guild_id = $1
ORDER BY xp DESC
LIMIT 10`,
[interaction.guildId],
);

if (rows.length === 0) {
await safeEditReply(interaction, {
content: '📭 No one has earned XP yet. Start chatting!',
});
return;
}

// Batch-fetch all members in a single API call
const memberMap = new Map();
try {
const members = await interaction.guild.members.fetch({ user: rows.map((r) => r.user_id) });
for (const [id, member] of members) memberMap.set(id, member.displayName);
} catch {
// Fall back — entries will use mention format
}

// Resolve display names
const lines = rows.map((row, i) => {
const displayName = memberMap.get(row.user_id) ?? `<@${row.user_id}>`;
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `**${i + 1}.**`;
return `${medal} ${displayName} — Level ${row.level} • ${row.xp} XP`;
});

const embed = new EmbedBuilder()
.setColor(0xfee75c)
.setTitle('🏆 XP Leaderboard')
.setDescription(lines.join('\n'))
.setFooter({ text: `Top ${rows.length} members` })
.setTimestamp();

await safeEditReply(interaction, { embeds: [embed] });
} catch (err) {
logError('Leaderboard command failed', { error: err.message, stack: err.stack });
await safeEditReply(interaction, {
content: '❌ Something went wrong fetching the leaderboard.',
});
}
}
102 changes: 102 additions & 0 deletions src/commands/rank.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Rank Command
* Show a user's level, XP, and progress bar.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/45
*/

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 { buildProgressBar, computeLevel } from '../modules/reputation.js';
import { REPUTATION_DEFAULTS } from '../modules/reputationDefaults.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
.setName('rank')
.setDescription("Show your (or another user's) level and XP")
.addUserOption((opt) =>
opt.setName('user').setDescription('User to look up (defaults to you)').setRequired(false),
);

/**
* Execute the /rank 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 cfg = getConfig(interaction.guildId);
if (!cfg?.reputation?.enabled) {
return safeEditReply(interaction, { content: 'Reputation system is not enabled.' });
}

try {
const pool = getPool();
const target = interaction.options.getUser('user') ?? interaction.user;
const repCfg = { ...REPUTATION_DEFAULTS, ...cfg.reputation };
const thresholds = repCfg.levelThresholds;

// Fetch reputation row
const { rows } = await pool.query(
'SELECT xp, level, messages_count FROM reputation WHERE guild_id = $1 AND user_id = $2',
[interaction.guildId, target.id],
);

const xp = rows[0]?.xp ?? 0;
const level = computeLevel(xp, thresholds);
const messagesCount = rows[0]?.messages_count ?? 0;

// XP within current level and needed for next
const currentThreshold = level > 0 ? thresholds[level - 1] : 0;
const nextThreshold = thresholds[level] ?? null; // null = max level

const xpInLevel = xp - currentThreshold;
const xpNeeded = nextThreshold !== null ? nextThreshold - currentThreshold : 0;
const progressBar =
nextThreshold !== null ? buildProgressBar(xpInLevel, xpNeeded) : `${'▓'.repeat(10)} MAX`;

// Rank position in guild
const rankRow = await pool.query(
`SELECT COUNT(*) + 1 AS rank
FROM reputation
WHERE guild_id = $1 AND xp > $2`,
[interaction.guildId, xp],
);
const rank = Number(rankRow.rows[0]?.rank ?? 1);

const levelLabel = `Level ${level}`;
const xpLabel = nextThreshold !== null ? `${xp} / ${nextThreshold} XP` : `${xp} XP (Max Level)`;

const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setAuthor({
name: target.displayName ?? target.username,
iconURL: target.displayAvatarURL(),
})
.setTitle(`🏆 ${levelLabel}`)
.addFields(
{ name: 'XP', value: xpLabel, inline: true },
{ name: 'Server Rank', value: `#${rank}`, inline: true },
{ name: 'Messages', value: String(messagesCount), inline: true },
{
name: nextThreshold !== null ? `Progress to Level ${level + 1}` : 'Progress',
value: progressBar,
inline: false,
},
)
.setThumbnail(target.displayAvatarURL())
.setTimestamp();

await safeEditReply(interaction, { embeds: [embed] });
} catch (err) {
logError('Rank command failed', { error: err.message, stack: err.stack });
await safeEditReply(interaction, { content: '❌ Something went wrong fetching your rank.' });
}
}
10 changes: 10 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getConfig } from './config.js';
import { checkLinks } from './linkFilter.js';
import { handlePollVote } from './pollHandler.js';
import { checkRateLimit } from './rateLimit.js';
import { handleXpGain } from './reputation.js';
import { isSpam, sendSpamAlert } from './spam.js';
import { handleReactionAdd, handleReactionRemove } from './starboard.js';
import { accumulateMessage, evaluateNow } from './triage.js';
Expand Down Expand Up @@ -151,6 +152,15 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) {
// Feed welcome-context activity tracker
recordCommunityActivity(message, guildConfig);

// XP gain (fire-and-forget, non-blocking)
handleXpGain(message).catch((err) => {
logError('XP gain handler failed', {
userId: message.author.id,
guildId: message.guild.id,
error: err?.message,
});
});

// AI chat — @mention or reply to bot → instant triage evaluation
if (guildConfig.ai?.enabled) {
const isMentioned = message.mentions.has(client.user);
Expand Down
Loading
Loading