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 for CTF events with timezone support
- Channel Management that creates dedicated CTF channels automatically
- User Registration for tracking team member participation
- Database Storage for persistent CTF data and registrations
- CTFd Integration ready for platform integration (coming soon)
- Blockchain-Backed Tasks with an immutable audit trail
- Reputation System with +1/-1 rep via reactions, replies, or the
/repcommand - Document Anchoring that stores documents on the blockchain permanently
- Activity Points Economy with admin-granted points, a leaderboard, and a merchandise store (AP or Rp)
- Automatic Reminders sent to a dedicated channel for task deadlines
- Proactive Chat that lets the bot join conversations on its own, with web search and channel memory, within a per-channel budget
/scheduleschedules custom events with timezone support/createctfcreates a CTF text channel and schedules its event/registerctfregisters your participation for the CTF in the current channel/addchalctfadds challenges to a CTF/solvectfmarks a challenge as solved/archivectfarchives a completed CTF/chalptsshows challenge points/summarizectfshows the CTF summary/syncchallengessyncs challenges and solves from the configured platform/setctfplatformselects the CTF platform for this channel and sets its API details/pactmanages pacts
/task addcreates a new task (title, description, assignee, deadline, optional timezone)/task list [period]shows remaining tasks for this week/month/quarter/year/task done <task_id | task>marks a task as completed (by UUID or fuzzy title search)/task cancel <task_id | task>confirms and cancels 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 or cancelling by title. Pass the task title, or a distinctive part of it, as the task option. The bot fuzzy-searches all pending tasks. A fuzzy match can be wrong. Every title-based completion or cancellation therefore requires an ephemeral confirmation with Confirm and 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. UUID-based completion and cancellation also ask for confirmation for consistency. Cancellation additionally removes the task's reminders.
/rep [downvote]gives +1 or -1 rep. It must reply to someone's message, and the response is ephemeral./repleaderboard [limit]shows the reputation leaderboard
You can also give rep by reacting or replying.
- Reacting with π or π to someone's message
- Replying to someone with
+1,-1, π, or π
/document addanchors a document to the blockchain/document getretrieves an anchored document
/activitypoints [user]shows your activity points balance, or another user's/activityleaderboard [limit]shows the activity points leaderboard/giveap <user|role> <points>grants activity points to a user or every member of a role (admin only)/giveapbulk <csv>grants activity points to many users from a CSV withdiscord_idandpointscolumns (admin only)/storebrowses and buys from the merchandise store with AP or Rp (no subcommand)/store-confirm <purchase_id>confirms a pending Rp purchase once offline payment arrives (admin only)
/pingchecks bot responsiveness/helplists all available commands
Every CTF channel is bound to exactly one platform. The binding is stored on the
ctfs row, which commands read instead of guessing.
| Column | Purpose |
|---|---|
platform |
Adapter id, either ctfd or noctf. Defaults to ctfd for every pre-existing row. |
api_base_url |
API origin to talk to. Needed because noCTF serves its API from a different origin than its web UI. Falls back to the CTF URL when unset. |
platform_division_id |
Scoreboard division, required by noCTF and unused by CTFd. |
The token is still stored in ctfs.api_token. CTFd sends it as
Authorization: Token <api_token>, while noCTF sends it as
Authorization: Bearer <token> and expects a copied session token. A noCTF
session token lasts 7 days and has no refresh endpoint. Replace it
periodically with /setctfplatform.
/setctfplatform platform:noctf api_base_url:https://api-k17ctf.secso.cc api_token:<token> division_id:2
/setctfplatform show:true
/setctfplatform needs the Manage Channels permission, and admins and
governance users bypass that check like every other privileged command. The
command probes the platform before it persists anything, and it reports whether
the API answered, whether the CTF is currently active, and which division it
used.
| Concern | CTFd | noCTF |
|---|---|---|
| Challenge listing | /api/v1/challenges |
/challenges, gated on the competition being active unless the token carries admin policies |
| Category | category field |
Derived from tags.categories, taking the first comma separated entry, falling back to uncategorized |
| Solve attribution | Per user via /api/v1/challenges/:id/solves |
Team scoped. /challenges/:id/solves returns only a team id. The scoreboard endpoint recovers the user id. |
| Scoreboard | /api/v1/scoreboard |
/scoreboard/divisions/:id. A division id is required. |
| Bulk solve listing | Not available | /scoreboard/divisions/:id carries every solve in the division, not just the bot's teams |
When noCTF reports a solve for a team, the sync attributes it to a registered
member of that team. If no member is registered yet, the solve is parked under a
per-platform synthetic user id (noctf:<id> or ctfd:<id>) and /registerctf
claims it later. The prefix is per platform, which prevents a registration on
one platform from claiming a numeric id from the other.
The noCTF bulk listing is division-wide and the token cannot narrow it. The
platform serves the scoreboard and /users/query publicly and never filters by
the caller's membership. Scope therefore comes from the registrations in the
channel. A solve belonging to a registered Discord user is always recorded. An
unregistered solver is only parked when their team matches a team someone has
already registered. A sync therefore never imports the rest of the division.
Because scope is the registered teams, a solve is only parked once at least one
teammate has run /registerctf. The first member of a team to register has
nothing waiting to be claimed.
- Add an entry to
PLATFORMSinsrc/lib/constants/platforms.js. Theidis persisted inctfs.platformand must stay stable. - Add a factory to
FACTORIESinsrc/lib/platform/index.jsthat returns the normalized client interface documented at the top of that file. - Add a prefix to
PREFIX_BY_PLATFORMinsrc/lib/platform/syntheticUser.js.
Commands only ever call createPlatformClient. They pick the new platform up
without further changes.
- 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_TOKENholds your Discord bot tokenGUILD_IDholds your Discord server ID (for development)CTF_CATEGORY_IDholds the category ID where CTF channels will be createdCTFD_API_TOKENholds your CTFd API token (optional)LUCE_PORTholds the 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 the
botandapplications.commandsscopes - Select the
Manage Channels,Manage Events,Send Messages,Embed Links,Add Reactions,Read Message Historypermissions - Copy and visit the generated URL
src/
βββ commands/ # Slash command implementations
β βββ createctf.js # Create CTF channels and events
β βββ registerctf.js # User registration for CTFs
β βββ setctfplatform.js # Pick the CTF platform for a channel
β βββ syncchallenges.js # Sync challenges and solves from the platform
β βββ 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
β βββ platform/ # Platform registry and normalized client interface
β β βββ index.js # createPlatformClient and platform lookup
β β βββ ctfd.adapter.js # Wraps the existing CTFd client
β β βββ syntheticUser.js # Pending-solve user id prefixes per platform
β βββ noctf/
β β βββ index.js # noCTF client (separate API origin, Bearer auth)
β βββ 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)
ctfsstores CTF competition detailsctfs.platform,ctfs.api_base_url,ctfs.platform_division_idrecord which platform a CTF channel syncs from, and how to reach itctf_registrationsstores user registrationsctf_challengesstores CTF challengesctf_challenge_solvestracks challenge solvespactsstores pact agreementsadminsstores admin user IDs
Blockchain-backed tables (new)
tasksstores tasks with a reference to the blockchain block heighttask_remindersstores scheduled reminders for task deadlinesreputationsstores the reputation ledger (one entry per giver per day)documentsstores anchored documents with a blockchain referenceactivity_ledgerstores an append-only ledger of AP value movements (grants and spends)activity_balancesstores the current AP balance per userstore_itemsstores the merchandise catalog (Sticker, Keychain, Shirt, Jacket)purchasesstores purchase records (AP completed, Rp pending and then 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 a π or π reaction, a reply of +1, -1, π or π, or the /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, one of "reaction", "reply", or "" for the 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, with 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 or /giveap <role> (batch).
Single grant (v: 1).
{
"type": "ap_grant",
"v": 1,
"grantedTo": "123456789012345678",
"grantedBy": "987654321098765432",
"amount": 50
}Batch grant (v: 2) uses one block for the whole batch. It is 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. 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
The bot can watch governance channels (the GOVERNANCE_CATEGORIES where
/task is allowed) and, when admins decide to do something in the future,
reply with a suggestion card offering to create a task with reminders.
- Messages are buffered per channel. After 60 seconds of quiet (and at most once every 2 minutes per channel) the new messages are sent to an [OI]-compatible model, along with the channel's last 20 messages read from Discord history, which keeps context across a bot restart, and the suggestion cards that are still open.
- A message that corrects an open suggestion ("not Sunday, make it Friday") updates that card in place instead of creating a new one, and the bot replies with what changed and a link to the card.
- Mentioning the bot (
@ctfbot remind us to ...) skips the wait. It analyses right away, shows "typing..." until the model answers, and always replies, even when it finds nothing or the model call fails. - Requests ask for
reasoning_effort: "low", which [OI] and DeepSeek both read. Decision spotting does not need deep reasoning. - Only admin follow-up work becomes a card. That means an explicit "ingetin aku / buatin task" request, or admins agreeing that one of them will do something later. Assignments or homework given to members, announcements and "siap" replies do not. Unprompted cards need higher model confidence (0.85) than answers to a mention (0.7), and dismissed cards are shown to the model as things this channel does not want tracked.
- Each such task becomes a card with Create task, Edit & create (pre-filled form with title, assignee, deadline and description) and Dismiss. Nothing is created until someone clicks.
- Accepted suggestions go through the same path as
/task add, with blockchain anchoring, reminders and Google Calendar sync. - Only governance-channel messages are ever sent to the model.
- Suggestions are stored in the
task_suggestionstable (migration 026). A card keeps working across restarts, with no time limit, until someone creates the task or dismisses it. Clicking a card that was already handled says who handled it. - Dismiss deletes the card to keep the channel clean. The dismissal is still recorded, and the model then knows the channel did not want it.
- The bot remembers people. When someone states a lasting fact or preference ("aku biasanya baru bisa malam", "Budi pegang sponsor", "pakai bahasa Inggris aja ke aku"), the model saves it as a note or a language preference (migration 027) and the bot reacts with a brain emoji on that message. Later passes see these notes. Assignees and deadlines then follow each person's preferences.
- The model writes in Indonesian by default (set in its system prompt) for card titles, descriptions, notes and its answer to a mention. A person with a saved language gets that language instead.
- Deleted messages are forgotten. A deleted chat message is never sent to the model again, a deleted open card is dropped, and notes learned from a deleted message are removed. Cards deleted while the bot was offline are dropped on the next pass. Cards that already became a task are kept.
- The model is told to chat like a classmate in the group chat, in everyday Indonesian slang, with its humor setting at 10% (TARS from Interstellar), almost always to the point, with only a rare deadpan one-liner.
- No emojis in anything the bot writes. The prompt forbids them, and the output is stripped of Unicode and custom Discord emojis before it is posted, so chat messages, card titles, descriptions and memory notes stay plain text. Reaction controls (π, π, π, π§ ) are not affected.
- When mentioned, the bot shows "typing..." while the model thinks, then sends one to three short chat messages. The first is a quick acknowledgment that fits the message ("wait" for a request, "hmm" for a question) sent as a reply. The rest and any cards follow as plain channel messages, e.g. "wait", "oke ini ya", then the card.
/memory viewshows what the bot remembers about you (Manage Messages can view others)./memory remember,/memory forget <number>,/memory languageand/memory clearedit or delete it.
TASK_SUGGESTIONS_ENABLED=true
OPENAI_BASE_URL=https://api.openai.com/v1 # any [OI]-compatible endpoint
OPENAI_API_KEY=your_api_key
OPENAI_MODEL=gpt-4o-mini # model name as your provider spells it
OPENAI_TIMEOUT_MS=300000 # optional, default 5 min for reasoning modelsIf the log shows No reply from ... within Ns, the endpoint answered too
slowly. Raise OPENAI_TIMEOUT_MS, pick a faster (non-reasoning) model, or
check that OPENAI_BASE_URL is reachable from the bot's host. Messages from a
failed pass are retried when the next message arrives in that channel.
The Message Content privileged intent must be enabled for the bot in the Discord Developer Portal.
src/services/chat.js lets the bot join a conversation on its own, the way a
member of the server would. Examples include "gimana kalau...", "menurutku...",
a question worth thinking about, or a fact it just checked. There is no command
and no allowlist. It can speak in any channel it can post in.
The service enforces when not to speak in code.
- Silence is a tool. The model calls
stay_silentto decline. An empty reply is treated as silence too. A broken response can never become a message. - The bot never triggers itself. A pass only runs when a human spoke after the bot's last message. Its own messages are never buffered. It cannot talk itself into a monologue.
- Cost is gated before the model is called. A cheap check looks for a question, a link, an error, a verifiable claim, or a channel with at least six messages from three people in the last 15 minutes. With no signal, no model call happens at all.
- Being ignored is a signal. Two unanswered messages lengthen the cooldown to 20 minutes. Four silence the channel until the next day.
- Budget per channel and per guild. Defaults are 5 messages per hour and 20 per day per channel, 200 per day for the whole guild.
- Mute is per channel and lifts by itself. React with π on one of the bot's messages, or reply to it with "diem aja", and that channel stays quiet until the next local midnight. Other channels are unaffected.
The model can call four tools. None of them can send a message, create a thread or touch a task. The service alone decides to post.
| Tool | What it does |
|---|---|
search_web |
Google via SerpBase. Five organic results and three "people also ask" questions, compacted to a few hundred characters. |
remember |
Save a note about the channel (topic, jargon, house rules) or about a person (a lasting preference or role). |
recall |
Read back what is already remembered about the channel and the people in it. |
stay_silent |
Decline to speak, with a reason. |
Search results are wrapped in a <search_results> block and labelled as
untrusted data, because a snippet is text someone else wrote. The prompt tells
the model never to follow instructions found there, and to only cite URLs the
tool actually returned. Identical queries are cached for a day and charged
against a daily credit cap. A model that likes a phrase cannot drain the
account.
CHAT_ENABLED=true
SERPBASE_API_KEY=your_serpbase_key # optional, without it the bot talks without search
CHAT_QUIET_MS=20000 # wait this long after the last message
CHAT_COOLDOWN_MS=360000 # never speak twice faster than this per channel
CHAT_MAX_PER_HOUR=5
CHAT_MAX_PER_DAY=20
CHAT_GUILD_MAX_PER_DAY=200
CHAT_SPARK_PROBABILITY=0.15 # chance of joining a busy channel unprompted
CHAT_DENY_CHANNEL_IDS= # announcements, logs, ...
SERPBASE_DAILY_CREDITS=100Keep SERPBASE_API_KEY in .env only. It is sent as the X-API-Key header,
never in a URL, and never logged. Rotate it if it ever appears in a transcript
or a commit.
The service degrades instead of failing. If the provider rejects tool schemas,
it falls back to a JSON reply ({"say": ""} means silence) once, and keeps
working.
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. Foreign events are never imported as tasks. - Detects title changes, deadline changes, and event cancellation or deletion.
- A reconciliation cycle runs every 60 seconds when enabled.
-
Google Cloud Project. Create one at https://console.cloud.google.com/, and 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. Put the client ID and secret in
.env, then run the helper.pnpm google:auth
Open the printed URL, approve access, and copy the printed
GOOGLE_REFRESH_TOKENinto.env. The script listens on a local127.0.0.1port to receive the code. Google no longer supports the old copy-paste (urn:ietf:wg:oauth:2.0:oob) flow. Run it on the machine whose browser you approve in (with WSL,127.0.0.1from the Windows browser reaches WSL by default).If the OAuth consent screen is still in Testing status, add your Google account as a test user. Google expires refresh tokens for Testing apps after 7 days. Publish the app to In production for a token that doesn't expire.
-
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.jshandles OAuth 2.0 token refresh (fetch-based, no extra dependencies).src/lib/google/calendar.jsholds the Google Calendar API v3 REST wrappers and mapper functions (taskToCalendarEvent,calendarEventToTaskPatch).src/services/calendarSync.jsruns the poll loop (60s interval), push logic, and incremental pull reconciliation.migrations/019_add_calendar_sync.sqladdscalendar_event_id,calendar_synced_at,calendar_sourcecolumns totasks, plusgoogle_credentialsandcalendar_sync_statetables.
The bot is built on @sapphire/framework, which provides the following.
- Structured command handling
- Event listener system
- Plugin support
- TypeScript-ready architecture
pnpm testThe suite is fully offline. The platform adapters are exercised against the real
clients with only globalThis.fetch faked. No credentials and no network are
needed.
There is also an opt-in live probe that talks to a real noCTF deployment. It is read-only (it never registers, solves or writes) and skipped by default.
K17_LIVE=1 \
K17_API_TOKEN=<session token> \
K17_DIVISION_ID=2 \
K17_API_USERNAME=<username> \
pnpm test:liveK17_API_BASE_URL defaults to https://api-k17ctf.secso.cc. The token is
optional. While the competition is active, anonymous reads are enough for every
assertion. Set K17_API_TOKEN when the deployment gates the routes (before the
start, or when hidden challenges matter), and K17_API_USERNAME to exercise the
user lookup.
This project uses tabs for indentation.
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=infoThe task-suggestion bot ([TaskSuggest], [LLM]) logs at these levels.
infologs one summary per pass that did something or answered a mention (what the model returned, what was posted, updated, dropped and remembered), plus dropped proposals with the reasondebuglogs each step of a pass, including mention received or queued, prompt inputs, model request size, latency and token usage, the model's reply (first 2000 chars), every chat message, card, update and memory changetracelogs every buffered message, scheduling, history reads, and the full prompt and model reply
All 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, for example
logger.info(message, ...optionalArgs).
Contributions are welcome. Read these guides.
- Contributing Guide covers detailed contribution guidelines
- Project Structure explains the codebase
- Quick Reference lists common tasks
- Architecture covers system design and patterns
- Changelog records version history
- Fork the repository
- Clone your fork with
git clone https://github.com/YOUR_USERNAME/ctfbot.git - Install dependencies with
pnpm install - Create a branch with
git checkout -b feature/your-feature - Make your changes
- Run linting with
pnpm run lint:fix - Commit with
git commit -m "feat: add your feature" - Push with
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 at GitHub Issues
- Discord for support and discussions on our server
- Built with discord.js
- Powered by @sapphire/framework
- Database: better-sqlite3
- Blockchain: Luce
Made by UCS ITS