Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 8 additions & 1 deletion config.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@
"github": "everyone",
"rank": "everyone",
"leaderboard": "everyone",
"profile": "everyone"
"profile": "everyone",
"challenge": "everyone"
}
},
"help": {
Expand Down Expand Up @@ -200,5 +201,11 @@
"announceChannelId": null,
"levelThresholds": [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000],
"roleRewards": {}
},
"challenges": {
"enabled": false,
"channelId": null,
"postTime": "09:00",
"timezone": "America/New_York"
}
}
30 changes: 30 additions & 0 deletions migrations/011_challenges.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Migration 011 β€” Daily Coding Challenges
* Creates the challenge_solves table for tracking user solve history.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/52
*/

'use strict';

/**
* @param {import('pg').Pool} pool
*/
async function up(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS challenge_solves (
guild_id TEXT NOT NULL,
challenge_index INTEGER NOT NULL,
user_id TEXT NOT NULL,
solved_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (guild_id, challenge_index, user_id)
);
`);

await pool.query(`
CREATE INDEX IF NOT EXISTS idx_challenge_solves_guild
ON challenge_solves(guild_id);
`);
}

module.exports = { up };
1 change: 1 addition & 0 deletions src/api/utils/configAllowlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const SAFE_CONFIG_KEYS = new Set([
'reputation',
'engagement',
'github',
'challenges',
]);

export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging'];
Expand Down
221 changes: 221 additions & 0 deletions src/commands/challenge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* Challenge Command
* View today's coding challenge, check your streak, or see the leaderboard.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/52
*/

import { EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { getPool } from '../db.js';
import { info } from '../logger.js';
import {
buildChallengeButtons,
buildChallengeEmbed,
selectTodaysChallenge,
} from '../modules/challengeScheduler.js';
import { getConfig } from '../modules/config.js';
import { safeEditReply } from '../utils/safeSend.js';

export const data = new SlashCommandBuilder()
.setName('challenge')
.setDescription('Daily coding challenges')
.addSubcommand((sub) => sub.setName('today').setDescription("Show today's coding challenge"))
.addSubcommand((sub) =>
sub.setName('streak').setDescription('Show your solve streak and total solves'),
)
.addSubcommand((sub) =>
sub.setName('leaderboard').setDescription('Top 10 solvers this week and all-time'),
);

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

const subcommand = interaction.options.getSubcommand();
const config = getConfig(interaction.guildId);
const challengesCfg = config.challenges ?? {};

if (!challengesCfg.enabled) {
await safeEditReply(interaction, {
content: '❌ Daily coding challenges are not enabled on this server.',
});
return;
}

if (subcommand === 'today') {
await handleToday(interaction, challengesCfg);
} else if (subcommand === 'streak') {
await handleStreak(interaction);
} else if (subcommand === 'leaderboard') {
await handleLeaderboard(interaction);
}
}

/**
* Handle /challenge today
*
* @param {import('discord.js').ChatInputCommandInteraction} interaction
* @param {Object} challengesCfg
*/
async function handleToday(interaction, challengesCfg) {
const pool = getPool();
const timezone = challengesCfg.timezone ?? 'America/New_York';
const now = new Date();
const { challenge, index, dayNumber } = selectTodaysChallenge(now, timezone);

// Get current solve count
let solveCount = 0;
if (pool) {
const { rows } = await pool.query(
'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND challenge_index = $2',
[interaction.guildId, index],
);
solveCount = Number.parseInt(rows[0].total, 10);
}

const embed = buildChallengeEmbed(challenge, dayNumber, solveCount);
const buttons = buildChallengeButtons(index);

await safeEditReply(interaction, { embeds: [embed], components: [buttons] });

info('/challenge today used', {
userId: interaction.user.id,
guildId: interaction.guildId,
dayNumber,
challengeTitle: challenge.title,
});
}

/**
* Handle /challenge streak
*
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
async function handleStreak(interaction) {
const pool = getPool();
if (!pool) {
await safeEditReply(interaction, { content: '❌ Database unavailable.' });
return;
}

const { guildId } = interaction;
const userId = interaction.user.id;

// Total solves
const { rows: totalRows } = await pool.query(
'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND user_id = $2',
[guildId, userId],
);
const totalSolves = Number.parseInt(totalRows[0].total, 10);

// All solved challenge indices ordered by index to compute streak
const { rows: solvedRows } = await pool.query(
`SELECT challenge_index, solved_at
FROM challenge_solves
WHERE guild_id = $1 AND user_id = $2
ORDER BY challenge_index DESC`,
[guildId, userId],
);

// Compute streak: consecutive challenge indices ending at most-recent
let streak = 0;
if (solvedRows.length > 0) {
const indices = solvedRows.map((r) => r.challenge_index);
streak = 1;
for (let i = 0; i < indices.length - 1; i++) {
if (indices[i] - indices[i + 1] === 1) {
streak++;
} else {
break;
}
}
}

const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle(`πŸ“Š Challenge Stats β€” ${interaction.user.displayName}`)
.setThumbnail(interaction.user.displayAvatarURL())
.addFields(
{
name: 'πŸ”₯ Current Streak',
value: `**${streak}** challenge${streak !== 1 ? 's' : ''}`,
inline: true,
},
{
name: 'βœ… Total Solved',
value: `**${totalSolves}** challenge${totalSolves !== 1 ? 's' : ''}`,
inline: true,
},
)
.setFooter({ text: 'Keep solving to grow your streak!' })
.setTimestamp();

await safeEditReply(interaction, { embeds: [embed] });
}

/**
* Handle /challenge leaderboard
*
* @param {import('discord.js').ChatInputCommandInteraction} interaction
*/
async function handleLeaderboard(interaction) {
const pool = getPool();
if (!pool) {
await safeEditReply(interaction, { content: '❌ Database unavailable.' });
return;
}

const { guildId } = interaction;

// All-time top 10
const { rows: allTimeRows } = await pool.query(
`SELECT user_id, COUNT(*) AS total
FROM challenge_solves
WHERE guild_id = $1
GROUP BY user_id
ORDER BY total DESC
LIMIT 10`,
[guildId],
);

// This week top 10 (last 7 days)
const { rows: weekRows } = await pool.query(
`SELECT user_id, COUNT(*) AS total
FROM challenge_solves
WHERE guild_id = $1 AND solved_at >= NOW() - INTERVAL '7 days'
GROUP BY user_id
ORDER BY total DESC
LIMIT 10`,
[guildId],
);

const medals = ['πŸ₯‡', 'πŸ₯ˆ', 'πŸ₯‰'];

const formatBoard = (rows) => {
if (rows.length === 0) return '_No solves yet β€” be the first!_';
return rows
.map((row, i) => {
const prefix = medals[i] ?? `**${i + 1}.**`;
return `${prefix} <@${row.user_id}> β€” **${row.total}** solve${row.total !== 1 ? 's' : ''}`;
})
.join('\n');
};

const embed = new EmbedBuilder()
.setColor(0xfee75c)
.setTitle('πŸ† Challenge Leaderboard')
.addFields(
{ name: 'πŸ“… This Week', value: formatBoard(weekRows) },
{ name: '🌟 All-Time', value: formatBoard(allTimeRows) },
)
.setFooter({ text: 'Solve daily challenges to climb the ranks!' })
.setTimestamp();

await safeEditReply(interaction, { embeds: [embed] });

info('/challenge leaderboard used', { userId: interaction.user.id, guildId });
}
Loading
Loading