UCS's specialized Discord bot for managing Capture The Flag (CTF) competitions, now with blockchain-backed task tracking, reputation, and document anchoring powered by Luce.
- π Event Scheduling β Schedule CTF events with timezone support
- ποΈ Channel Management β Automatically create dedicated CTF channels
- π₯ User Registration β Track team member participation
- ποΈ Database Storage β Persistent storage of CTF data and registrations
- π CTFd Integration β Ready for CTFd platform integration (coming soon)
- βοΈ Blockchain-Backed Tasks β Create and track tasks with immutable audit trail
- π Reputation System β Give +1/-1 rep via reactions, replies, or
/repcommand - π Document Anchoring β Permanently store documents on the blockchain
- π― Activity Points Economy β Admin-granted points, a leaderboard, and a merchandise store (AP or Rp)
- β° Automatic Reminders β Task deadline reminders sent to a dedicated channel
/scheduleβ Schedule custom events with timezone support/createctfβ Create a CTF text channel and schedule its event/registerctfβ Register your participation for the CTF in the current channel/addchalctfβ Add challenges to a CTF/solvectfβ Mark a challenge as solved/archivectfβ Archive a completed CTF/chalptsβ View challenge points/summarizectfβ View CTF summary/syncchallengesβ Sync challenges from CTFd/pactβ Manage pacts
/task addβ Create a new task (title, description, assignee, deadline, optional timezone)/task list [period]β View remaining tasks for this week/month/quarter/year/task done <task_id | task>β Mark a task as completed (by UUID or fuzzy title search)/task cancel <task_id | task>β Confirm and cancel a pending task (by UUID or fuzzy title search), removing its reminders
Task reports use Asia/Jakarta calendar boundaries. Large lists are split into multiple responses.
Completing/cancelling by title: Personnel no longer need to copy the UUID β pass the task title (or a distinctive part of it, even its description) as the task option and the bot fuzzy-searches all pending tasks. Because a fuzzy match can be wrong, every title-based completion/cancellation requires an ephemeral confirmation (Confirm / Deny buttons) before anything is recorded; nothing is written to the blockchain or the database until you confirm. If several tasks match closely, a picker shows one button per candidate β pick the one you meant. UUID-based completion and cancellation also ask for confirmation for consistency. Cancellation additionally removes the task's reminders.
/rep [downvote]β Give +1 or -1 rep (must reply to someone's message; response is ephemeral)/repleaderboard [limit]β View the reputation leaderboard
You can also give rep by:
- Reacting with π or π to someone's message
- Replying to someone with
+1,-1, π, or π
/document addβ Anchor a document to the blockchain/document getβ Retrieve an anchored document
/activitypoints [user]β View your (or another user's) activity points balance/activityleaderboard [limit]β View the activity points leaderboard/giveap <user|role> <points>β Grant activity points to a user or every member of a role (admin only)/giveapbulk <csv>β Grant activity points to many users from a CSV withdiscord_idandpointscolumns (admin only)/storeβ Browse and buy the merchandise store with AP or Rp (no subcommand)/store-confirm <purchase_id>β Confirm a pending Rp purchase once offline payment arrives (admin only)
/pingβ Check bot responsiveness/helpβ List all available commands
- Node.js v18.0.0 or higher
- pnpm package manager
- A Discord Bot Token (Create one here)
- Luce blockchain server running locally on port 5500
-
Clone the repository
git clone https://github.com/ucsits/ctfbot.git cd ctfbot -
Install dependencies
pnpm install
-
Configure environment variables
cp .env.example .env
Edit
.envand fill in the required values:DISCORD_TOKENβ Your Discord bot tokenGUILD_IDβ Your Discord server ID (for development)CTF_CATEGORY_IDβ Category ID where CTF channels will be createdCTFD_API_TOKENβ Your CTFd API token (optional)LUCE_PORTβ Luce blockchain RPC port (default: 5500)
-
Run the bot
Development mode (with auto-reload):
pnpm dev
Production mode:
pnpm start
-
Create a CTF Category
- Right-click in your server's channel list
- Select "Create Category"
- Name it "CTF" or similar
- Right-click the category β Copy ID
- Paste the ID in
.envasCTF_CATEGORY_ID
-
Invite the Bot
- Go to Discord Developer Portal
- Select your application
- Go to OAuth2 β URL Generator
- Select scopes:
bot,applications.commands - Select permissions:
Manage Channels,Manage Events,Send Messages,Embed Links,Add Reactions,Read Message History - Copy and visit the generated URL
src/
βββ commands/ # Slash command implementations
β βββ createctf.js # Create CTF channels and events
β βββ registerctf.js # User registration for CTFs
β βββ schedule.js # Generic event scheduling
β βββ task.js # Blockchain-backed task management
β βββ rep.js # Reputation command (ephemeral)
β βββ repleaderboard.js # Reputation leaderboard
β βββ document.js # Document anchoring
β βββ help.js # List all commands
β βββ ping.js # Health check
βββ listeners/ # Event listeners
β βββ ready.js # Bot ready event (starts reminder service)
β βββ messageCreate.js # Detects reply-based +1/-1/π/π
β βββ messageReactionAdd.js # Detects π/π reaction rep
β βββ applicationCommandRegistriesRegistered.js
βββ services/
β βββ reminder.js # Background reminder poller (every 30s)
βββ lib/
β βββ luce/
β β βββ index.js # Luce blockchain RPC client
β βββ constants/
β βββ embeds/
β βββ errors/
β βββ helpers/
β βββ middleware/
β βββ utils/
β βββ validators/
βββ database/
β βββ repositories/
β β βββ task.repository.js # Tasks + reminders queries
β β βββ reputation.repository.js # Reputation ledger queries
β β βββ document.repository.js # Document queries
β βββ connection.js
β βββ index.js
β βββ migrations.js
βββ migrations/ # SQL migration files
βββ index.js # Main entry point
Core tables (existing):
ctfsβ Stores CTF competition detailsctf_registrationsβ Stores user registrationsctf_challengesβ Stores CTF challengesctf_challenge_solvesβ Tracks challenge solvespactsβ Pact agreementsadminsβ Admin user IDs
Blockchain-backed tables (new):
tasksβ Tasks with reference to blockchain block heighttask_remindersβ Scheduled reminders for task deadlinesreputationsβ Reputation ledger (one entry per giver per day)documentsβ Anchored documents with blockchain referenceactivity_ledgerβ Append-only ledger of AP value movements (grants/spends)activity_balancesβ Current AP balance per userstore_itemsβ Merchandise catalog (Sticker, Keychain, Shirt, Jacket)purchasesβ Purchase records (AP completed, Rp pendingβconfirmed)
Every block stored on the Luce blockchain has a data field containing a JSON
string. Below are the supported schemas with their type discriminator.
Created when a user runs /task add.
{
"type": "task",
"v": 1,
"taskId": "a1b2c3d4-...",
"title": "Implement login page",
"description": "Build the user authentication UI",
"assignedTo": "123456789012345678",
"createdBy": "987654321098765432",
"deadline": 1740000000
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "task" |
v |
number |
Schema version (currently 1) |
taskId |
string |
UUID v4 |
title |
string |
Task title |
description |
string |
Task description (may be empty) |
assignedTo |
string |
Discord user ID of assignee |
createdBy |
string |
Discord user ID of creator |
deadline |
number |
Unix timestamp (seconds) |
Created when a user runs /task done.
{
"type": "task_done",
"v": 1,
"taskId": "a1b2c3d4-...",
"title": "Implement login page",
"assignedTo": "123456789012345678",
"completedBy": "123456789012345678"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "task_done" |
v |
number |
Schema version (currently 1) |
taskId |
string |
UUID of the completed task |
completedBy |
string |
Discord user ID of completer |
title |
string |
Task title, for recognizable audit events |
assignedTo |
string |
Discord user ID of assignee |
Created when a user runs /task cancel confirm:True.
{
"type": "task_cancel",
"v": 1,
"taskId": "a1b2c3d4-...",
"title": "Implement login page",
"assignedTo": "123456789012345678",
"cancelledBy": "987654321098765432"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "task_cancel" |
v |
number |
Schema version (currently 1) |
taskId |
string |
UUID of the cancelled task |
title |
string |
Task title |
assignedTo |
string |
Discord user ID of assignee |
cancelledBy |
string |
Discord user ID of canceller |
Created via reaction (π/π), reply (+1/-1/π/π), or /rep command.
{
"type": "rep",
"v": 1,
"toUser": "123456789012345678",
"fromUser": "987654321098765432",
"amount": 1,
"reason": "reaction",
"date": "2026-07-10"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "rep" |
v |
number |
Schema version (currently 1) |
toUser |
string |
Discord user ID of recipient |
fromUser |
string |
Discord user ID of giver |
amount |
number |
1 (upvote) or -1 (downvote) |
reason |
string |
Trigger source: "reaction", "reply", or "" (slash command) |
date |
string |
UTC date in YYYY-MM-DD format (enforces daily limit) |
Daily limit: A user may only give rep once per UTC calendar day, regardless of target or sign. This is enforced at both the application and database level.
Created when a user runs /document add.
{
"type": "document",
"v": 1,
"docId": "a1b2c3d4-...",
"title": "Meeting Notes 2026-07-10",
"content": "Discussed sprint goals for Q3...",
"author": "123456789012345678",
"mimeType": "text/plain"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "document" |
v |
number |
Schema version (currently 1) |
docId |
string |
UUID v4 |
title |
string |
Document title |
content |
string |
Document content (plain text; base64 for binary in future versions) |
author |
string |
Discord user ID of author |
mimeType |
string |
MIME type (default: "text/plain") |
Created by /giveap (single user) and /giveapbulk / /giveap <role> (batch).
Single grant (v: 1):
{
"type": "ap_grant",
"v": 1,
"grantedTo": "123456789012345678",
"grantedBy": "987654321098765432",
"amount": 50
}Batch grant (v: 2) β one block for the whole batch, used by /giveapbulk and
/giveap <role>:
{
"type": "ap_grant",
"v": 2,
"grantedBy": "987654321098765432",
"entries": [
{ "discord_id": "123456789012345678", "points": 50 },
{ "discord_id": "876543210987654321", "points": 50 }
],
"count": 2,
"total": 100
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "ap_grant" |
v |
number |
1 (single) or 2 (batch) |
grantedTo |
string |
Discord user ID of recipient (v1 only) |
grantedBy |
string |
Discord user ID of the granting admin |
amount |
number |
Points granted (v1 only) |
entries |
array |
{discord_id, points} list (v2 only) |
count |
number |
Number of recipients (v2 only) |
total |
number |
Total points granted (v2 only) |
Created when a user buys an item from the store. AP purchases complete
immediately; Rp purchases start pending and are confirmed later.
{
"type": "ap_purchase",
"v": 1,
"user": "123456789012345678",
"itemName": "Shirt",
"paymentMethod": "ap",
"costAp": 200,
"status": "completed"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "ap_purchase" |
v |
number |
Schema version (currently 1) |
user |
string |
Discord user ID of buyer |
itemName |
string |
Name of the purchased item |
paymentMethod |
string |
"ap" or "rp" |
costAp |
number |
AP cost (when paymentMethod is "ap") |
costRp |
number |
Rp cost (when paymentMethod is "rp") |
status |
string |
"completed" (AP) or "pending" (Rp) |
Created when an admin confirms a pending Rp purchase via /store-confirm.
{
"type": "ap_confirm",
"v": 1,
"user": "123456789012345678",
"confirmedBy": "987654321098765432",
"itemName": "Shirt",
"paymentMethod": "rp"
}| Field | Type | Description |
|---|---|---|
type |
string |
Always "ap_confirm" |
v |
number |
Schema version (currently 1) |
user |
string |
Discord user ID of buyer |
confirmedBy |
string |
Discord user ID of confirming admin |
itemName |
string |
Name of the purchased item |
paymentMethod |
string |
"rp" (or "ap" for completeness) |
The v field allows future schema evolution without breaking existing blocks.
When introducing a breaking change, increment v and handle both versions in
your parser.
A background service (src/services/reminder.js) polls every 30 seconds for
unsent task reminders. Reminders are sent to 1524933314119467200 one hour
before the deadline and at 09:00 Asia/Jakarta on the day before the deadline
when those times are still in the future. Failed sends remain retryable.
Daily digests run after 04:00 Asia/Jakarta and weekly digests run Monday after 05:00 Asia/Jakarta; Monday sends avoid redundant notifications. Digest ranges and task reports use Asia/Jakarta calendar boundaries, and empty results are explicitly reported.
Flow:
/task addβ creates blockchain block + DB row + reminder schedule- Reminder service polls
task_reminderstable every 30s - Due reminders are sent to channel
1524933314119467200 - Reminder is marked
sent = 1to prevent duplicates
A background service (src/services/calendarSync.js) pushes bot tasks to a
shared Google Calendar and reconciles bot-owned calendar events back into the
task DB. This lets the team see task deadlines on their calendar and edit them
there, with changes reflected back in Discord.
Push direction (bot β Calendar):
/task addcreates a new Calendar event (1 hour before the deadline)./task donedeletes the event from the calendar./task cancelpatches the event status tocancelled.
Pull direction (Calendar β bot):
- Incremental sync using Google's
syncTokento reduce API quota. - Only processes events with the private
x-ctfbot-taskextended property marker, so foreign events are never imported as tasks. - Detects: title changes, deadline changes, and event cancellation/deletion.
- A reconciliation cycle runs every 60 seconds when enabled.
-
Google Cloud Project β create one at https://console.cloud.google.com/, enable the Google Calendar API.
-
OAuth 2.0 credentials β create an OAuth 2.0 Client ID (Desktop app type). Copy the client ID and client secret.
-
Shared Calendar ID β create or choose a Google Calendar that the bot will own. Copy its ID (find it in Calendar settings β Integrate calendar β Calendar ID).
-
Refresh token β visit the authorization URL below (generated by the bot or manually) with the calendar scope to obtain a one-time code, then exchange it for a refresh token:
https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=https://www.googleapis.com/auth/calendar&access_type=offline&prompt=consent -
Configure
.env:GOOGLE_CALENDAR_ENABLED=true GOOGLE_CLIENT_ID=your_client_id GOOGLE_CLIENT_SECRET=your_client_secret GOOGLE_REFRESH_TOKEN=your_refresh_token GOOGLE_CALENDAR_ID=your_calendar_id
src/lib/google/oauth.jsβ OAuth 2.0 token refresh (fetch-based, no extra dependencies).src/lib/google/calendar.jsβ Google Calendar API v3 REST wrappers and mapper functions (taskToCalendarEvent,calendarEventToTaskPatch).src/services/calendarSync.jsβ poll loop (60s interval), push logic, incremental pull reconciliation.migrations/019_add_calendar_sync.sqlβ addscalendar_event_id,calendar_synced_at,calendar_sourcecolumns totasks, plusgoogle_credentialsandcalendar_sync_statetables.
The bot is built using the @sapphire/framework which provides:
- Structured command handling
- Event listener system
- Plugin support
- TypeScript-ready architecture
pnpm testThis project uses tabs for indentation. Please ensure your editor is configured accordingly.
The bot includes a unified logging system built on top of the Sapphire framework's logger.
Set the LOG_LEVEL environment variable to control verbosity:
| Level | Usage |
|---|---|
error |
Fatal errors and exceptions only |
warn |
Warnings + errors |
info |
Normal operational messages (default) |
debug |
Detailed debugging information |
trace |
Everything, including individual HTTP request logs |
LOG_LEVEL=infoAll logs are output to stdout (info/debug/trace) and stderr (error/warn). There is no file-based logging β use your process manager (e.g., systemd, Docker, PM2) to capture and rotate stdout/stderr.
The logger (src/lib/logger.js) works in two modes:
- Pre-init mode: Before the bot has logged into Discord, the logger
falls back to raw
consolecalls with ISO timestamps. - Post-init mode: Once the SapphireClient is ready, the logger delegates to Sapphire's structured logger for consistent formatting across all commands and listeners.
Child loggers with a context prefix are used throughout the codebase:
| Prefix | Module |
|---|---|
[ErrorHandler] |
src/lib/errorHandler.js |
[CTFd] |
src/lib/ctfd/index.js |
[DB] |
src/database/index.js |
[Migration] |
src/database/migrations.js |
[Luce] |
src/lib/luce/index.js |
const { logger } = require('./lib/logger');
const myLog = logger.child('MyModule');
myLog.info('Operation completed successfully');
myLog.error('Something went wrong', error);
myLog.debug('Detailed info: %s', detail);Logger methods follow the same signature as console.log:
logger.info(message, ...optionalArgs).
Contributions are welcome! Please read our comprehensive guides:
- π Contributing Guide β Detailed contribution guidelines
- ποΈ Project Structure β Understanding the codebase
- π Quick Reference β Fast reference for common tasks
- ποΈ Architecture β System design and patterns
- π Changelog β Version history
- Fork the repository
- Clone your fork:
git clone https://github.com/YOUR_USERNAME/ctfbot.git - Install dependencies:
pnpm install - Create a branch:
git checkout -b feature/your-feature - Make your changes
- Run linting:
pnpm run lint:fix - Commit:
git commit -m "feat: add your feature" - Push:
git push origin feature/your-feature - Open a Pull Request
See CONTRIBUTING.md for detailed guidelines.
- Blockchain-backed task management
- Reputation system (+1/-1 with daily limit)
- Document anchoring
- Additional rep triggers (message content, reactions on other platforms)
- Web dashboard for blockchain explorer
- Multi-chain support
This project is licensed under the MIT License β see the LICENSE file for details.
- Issues: GitHub Issues
- Discord: Join our server for support and discussions
- Built with discord.js
- Powered by @sapphire/framework
- Database: better-sqlite3
- Blockchain: Luce
Made with β€οΈ by UCS ITS