quizbase
Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs
by Maciej Dzierżek · published May 15, 2026 · 25 min read · Beginner
slack-discord-trivia-bot hero illustration
Illustration for: Slack and Discord daily trivia bot in Node.js · Generated with Nano Banana, brand style

Slack and Discord daily trivia bot in Node.js#

A daily trivia bot is the most reliably engaging community ritual you can ship. Two questions a day, scored by first correct reply, weekly leaderboard on Friday — it works for an engineering team Slack, an after-school Discord club, a learning community on either platform. This guide builds it twice (once for Slack with Bolt SDK, once for Discord with discord.js) in plain Node.js, no agent runtime needed. About 100 lines per platform.

This is the no-MCP cousin of the MCP-powered Slack trivia bot. The MCP version is the right choice when you want composability with other tools (Wikipedia, your HR system) via an agent. This guide is the right choice when you want just trivia, minimal dependencies, fastest to ship.

What you’ll build#

A bot installed in one workspace/server with:

  • Daily question at 10am — bot calls GET /api/v1/questions/random, posts to your configured channel, remembers the correct answer
  • Reaction-based scoring — first user to reply with the correct letter (A/B/C/D) in the thread wins the round; bot reacts with ✅
  • No-repeat dedup — bot tracks question IDs per channel in Map<channelId, Set<questionId>>, refetches on collision (3 retries)
  • Weekly leaderboard Fridays at 4pm
  • Per-channel topic#python channel gets ?tags=programming, #movies gets ?category=film, configurable in a JSON file

The mechanic — channel-keyed dedup with stable IDs#

QuizBase question IDs are UUID v7 — stable across the lifetime of the question. Per-channel dedup is one Set.has() call before posting:

const channelHistory = new Map<string, Set<string>>();

async function fetchUnique(channelId: string, retries = 3) {
	const seen = channelHistory.get(channelId) ?? new Set<string>();
	for (let i = 0; i < retries; i++) {
		const q = await fetchRandom();
		if (!seen.has(q.id)) {
			seen.add(q.id);
			channelHistory.set(channelId, seen);
			return q;
		}
	}
	throw new Error('No fresh question after 3 retries');
}

The same pattern is used by MCP Slack trivia bot’s agent variant and the Cursor pomodoro extension. See /docs/api/questions-by-id for the full client-mechanics catalogue.

Stack#

  • Node.js 20+ — async/await first-class
  • @slack/bolt for Slack, discord.js for Discord — both are the official picks
  • node-cron — scheduling
  • QuizBase publishable keyqb_pk_*, free tier 500 req/day is enough for 50+ channels × 2 questions/day

Step 1 — Set up the Slack app and credentials#

In api.slack.com/apps create a new app. Three values to copy:

  • Bot Token (xoxb-...) — scopes: chat:write, reactions:write, channels:history, app_mentions:read
  • Signing Secret — under “Basic Information”
  • App-Level Token (xapp-...) — enable Socket Mode (no public endpoint needed), scope connections:write

Invite the bot to a channel: /invite @triviabot.

Step 2 — Wire QuizBase fetch and channel state#

npm install @slack/bolt node-cron dotenv
// src/quizbase.ts
const KEY = process.env.QUIZBASE_KEY!; // qb_pk_*

export async function fetchRandom() {
	const r = await fetch('https://quizbase.runriva.com/api/v1/questions/random', {
		headers: { 'X-API-Key': KEY }
	});
	if (!r.ok) throw new Error(`QuizBase ${r.status}`);
	return r.json();
}

export const channelHistory = new Map<string, Set<string>>();

export async function fetchUnique(channelId: string, retries = 3) {
	const seen = channelHistory.get(channelId) ?? new Set<string>();
	for (let i = 0; i < retries; i++) {
		const q = await fetchRandom();
		if (!seen.has(q.id)) {
			seen.add(q.id);
			channelHistory.set(channelId, seen);
			return q;
		}
	}
	throw new Error('No fresh question after 3 retries');
}

Step 3 — Daily scheduler with no-repeat dedup#

// src/scheduler.ts
import cron from 'node-cron';
import { app } from './slack';
import { fetchUnique } from './quizbase';

const CHANNELS = process.env.SLACK_CHANNELS!.split(',');
const activeRounds = new Map<string, { correctAnswer: string; winner?: string }>();

cron.schedule('0 10 * * 1-5', async () => {
	for (const channelId of CHANNELS) {
		try {
			const q = await fetchUnique(channelId);
			const choices = [q.correctAnswer, ...q.incorrectAnswers]
				.sort(() => Math.random() - 0.5)
				.map((c, i) => `${'ABCD'[i]}. ${c}`)
				.join('\n');
			const result = await app.client.chat.postMessage({
				channel: channelId,
				text: `🧠 *Trivia time:* ${q.text}\n\n${choices}\n\n_Source: ${q.attribution.author} · ${q.attribution.license}_`
			});
			activeRounds.set(result.ts!, { correctAnswer: q.correctAnswer });
		} catch (e) {
			console.error(`Failed to post to ${channelId}:`, e);
		}
	}
});

The _Source: ... line at the bottom is required — every QuizBase response includes an attribution object (author + license + url), and CC BY-SA / CC BY / MIT compliance requires surfacing it on every redistribution. See /data for the full attribution rules. Don’t skip it.

Step 4 — Score first correct reply#

// src/scoring.ts
import { app } from './slack';
import { activeRounds } from './scheduler';

const weekScores: Record<string, number> = {};

app.message(async ({ message, client }) => {
	if (message.subtype || !message.thread_ts) return;
	const round = activeRounds.get(message.thread_ts);
	if (!round || round.winner) return;

	if (message.text?.toLowerCase().trim() === round.correctAnswer.toLowerCase()) {
		round.winner = message.user;
		weekScores[message.user!] = (weekScores[message.user!] ?? 0) + 1;
		await client.reactions.add({
			channel: message.channel,
			timestamp: message.ts,
			name: 'white_check_mark'
		});
	}
});

export { weekScores };

Step 5 — Weekly leaderboard Friday at 4pm#

import { weekScores } from './scoring';

cron.schedule('0 16 * * 5', async () => {
	const top = Object.entries(weekScores)
		.sort(([, a], [, b]) => b - a)
		.slice(0, 5);
	if (top.length === 0) return;
	const leaderboard = top.map(([user, score], i) => `${i + 1}. <@${user}> — ${score}`).join('\n');
	await app.client.chat.postMessage({
		channel: CHANNELS[0],
		text: `🏆 *This week's trivia leaderboard:*\n${leaderboard}\n\nReset Monday at 10am.`
	});
	// Reset for next week
	Object.keys(weekScores).forEach((k) => delete weekScores[k]);
});

The Discord variant — discord.js diff#

discord.js has the same surface, different names. The fetch + dedup + cron logic is byte-for-byte identical; only the Slack client calls change:

// src/discord.ts
import { Client, IntentsBitField } from 'discord.js';
const client = new Client({
	intents: [
		IntentsBitField.Flags.Guilds,
		IntentsBitField.Flags.GuildMessages,
		IntentsBitField.Flags.MessageContent
	]
});

// Post:
const channel = await client.channels.fetch(CHANNEL_ID);
await channel.send(`🧠 **Trivia time:** ${q.text}\n${choices}\n_Source: ${q.attribution.author}_`);

// Scoring listener (discord.js):
client.on('messageCreate', async (msg) => {
	if (!msg.reference || msg.author.bot) return;
	// ... same correctAnswer check, react with msg.react('✅')
});

The attribution line stays. Discord doesn’t enforce content licenses, but QuizBase content does.

Pitfalls#

  • In-memory channelHistory evaporates on restart — for production, persist to Redis or SQLite. Replit / Heroku free dynos restart hourly and you’ll see duplicates.
  • Bolt’s app.message fires for every message in channels where the bot is added — filter early on thread_ts. Discord.js has the same behaviour but doesn’t have a thread concept by default (use a reply check).
  • correctAnswer and incorrectAnswers are separate — there is no pre-shuffled choices array. See response format.
  • Rate limits per QuizBase account — 500 req/day free is plenty for one workspace; ten orgs running the same bot on free tier across shared rate-limit window is not. Upgrade if you scale.

What next#

  • Per-channel topic config — JSON file mapping channelId -> { category, tags, lang }, fetch with those filters. Engineering channel gets ?tags=programming.
  • Multi-language for school clubs — see languages and translations for the EN/PL/ES matrix.
  • Persistent leaderboard — Postgres + a /leaderboard slash command surfacing all-time scores.
  • MCP variant — when you want the bot to compose with other tools (Wikipedia explainers, calendar, internal docs), see MCP-powered Slack trivia bot.

Ready to wire it? Grab a free publishable key (500 req/day, no credit card), npm install @slack/bolt node-cron, copy the snippets, ship before lunch. Bug in a question? POST /api/v1/report accepts feedback by stable ID.