- JavaScript 100%
| commands | ||
| commission | ||
| db | ||
| embeds | ||
| handlers | ||
| interactions | ||
| panels | ||
| queue | ||
| schedule | ||
| utils | ||
| .gitignore | ||
| .gitignore.txt | ||
| bot.js | ||
| config.js | ||
| deploy.bat | ||
| DiscordBot.zip | ||
| log.js | ||
| package-lock.json | ||
| package.json | ||
| purge.js | ||
| README.md | ||
| server.js | ||
Tabb — Discord Commission Queue Bot
Node.js · discord.js v14 · better-sqlite3 · CommonJS
Setup
Requirements
- Node.js ≥ 18
- A Discord bot token with the following privileged intents enabled in the Developer Portal:
- Server Members Intent
- Message Content Intent
Install
npm install
Environment variables
Create a .env file in the project root (gitignored):
# Required
BOT_TOKEN=your_bot_token_here
CLIENT_ID=your_application_id
OWNER_ID=your_discord_user_id
# Optional but recommended
GUILD_ID=your_server_id # Used for avatar fetching and Discord username verification
ENCRYPTION_KEY=64_hex_chars # Required for PayPal email storage
# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
TZ=Europe/Brussels # Timezone for scheduled tasks (default: UTC)
DB_PATH=/path/to/bot.db # Default: ../bot.db relative to the bot folder
STREAM_PING_ROLE=role_id # Role pinged on /streamstart (default: hardcoded ID)
# Combined static asset + API server
ASSET_BASE_URL=https://yourserver.com # Base URL for media served by the bot (no trailing slash)
ASSET_SERVER_PORT=4040 # Default: 4040
API_SECRET=64_hex_chars # Required for all /api/ requests from the site
First run
npm start
On first boot the bot will:
- Register all slash commands with Discord
- Purge and repaint all managed channels
- Set
bot_initializedin the DB — subsequent restarts skip steps 1 and 2
If you add new slash commands, run /clearmeta bot_initialized in Discord and restart.
Channel layout
| Channel | Purpose |
|---|---|
QUEUE_PUBLIC |
Public queue embeds — one per queue, numbered list, Apply/Leave buttons |
QUEUE_ADMIN |
Admin queue cards — per-entry with status, invoice, move, remove |
COMMISSION_INFO |
Commission types with Apply buttons + How to Apply footer |
COMMISSION_TYPES |
Admin panel — create/edit/archive commission types |
ADMIN_DASHBOARD |
Status selector, stream status, utility buttons, asset list |
ADMIN_INBOX |
Application triage cards (pending/rejected) + payment todo cards |
ADMIN_VIEW |
Live viewer — dropdown to select a queue and browse active commissions with timeline |
EMBEDS_PANEL |
Live embed editor — all bot embeds and reaction emojis |
QUEUE_PANEL |
Queue management — create/edit/open/close/archive queues |
ART_OUTPUT |
Post finished or WIP art here — bot replies ephemerally to categorise |
ART_PRIVATE |
Private commission art (not publicly announced) |
ANNOUNCEMENTS |
Queue open/close announcements, stream status |
ROLE_CHANNEL |
Role selection buttons |
LOG |
Bot activity log + media review cards (SFW/NSFW tagging) |
ASSET_UPLOAD |
Permanent record of every /uploadart file |
ASSET_LIST |
Asset panel — browse, get URLs, delete embed artwork |
Commission lifecycle
draft → pending → accepted → in_progress → in_review → complete
↘ rejected ↗
↘ refund_requested → refunded
↘ cancelled
is_invoiced and is_paid are flags on in_progress / in_review commissions — not separate statuses.
Application flow
- Client clicks Apply in the commission info or queue channel
- Bot checks DMs are open, reserves a slot (status:
draft), sends a DM with Complete Application / Release Slot buttons - Complete Application → visibility selector (Public / Anonymous / Stay Private)
- Visibility selection → pitch modal (description, character names, optional unit count)
- Pitch submit → PayPal email prompt (if crypto configured). Status stays
draft— client can now add notes and attach refs via hub buttons - Client hits Submit Application in their commission hub → status moves to
pending, bot pings you - Draft commissions not completed within 24 hours are auto-cancelled by the daily cleanup job
Review
- Accept → status
accepted, hub updated, client notified if at position 1 - Reject (reason + optional suggested revision) → client notified, can revise and resubmit
- Rejected commissions auto-cancel after 2 days if not resubmitted
- Request Info → modal, sends a DM to the client
In progress
- Start →
in_progress, client notified - Post art in
ART_OUTPUT→ bot replies ephemerally: Finished / WIP / Other / Personal Work - Mark In Review (from dashboard or Finished flow) →
in_review, client gets review DM with Looks Good / Changes Needed buttons
Delivery
- Select Finished in the art prompt → pick commission from dropdown
- Invoice gate: if not yet invoiced, bot shows invoice link / Mark as Invoiced button
- On invoiced → status moves to
in_review, client receives review DM with the art attached (served from local file, not an expiring CDN URL) - Looks Good → status
complete, final file re-sent as fresh attachment, tracked DMs cleaned up, public/private announcement posted, hub deleted, next-in-queue notified - Changes Needed → file moved to WIPs folder, status back to
in_progress, you get a DM notification
WIP flow
Select WIP → pick commission → logged in wips table, file moved to wips/ folder, client notified with the image
Cancellation
- Client can cancel (confirm step) from their hub at any status
- Admin can cancel from the dashboard or admin queue cards
- Cancelling a paid commission auto-redirects to
refund_requestedinstead of cancelled
Refunds
- Client requests refund from hub (modal for reason; only shown after
payment_sentis flagged) - You get a DM with Approve / Deny buttons
- Approve → status
refunded, tracked DMs cleaned up, client notified - Deny → status restored to
in_progress
Client DM hub
Every client with an active commission receives a persistent Commission Management embed in their DMs. It updates automatically on every status change. Notes and images are submitted via Add Note and Attach Image buttons directly on the hub — raw DM messages are no longer processed.
Buttons per state
| State | Buttons |
|---|---|
draft |
Submit Application, Edit Pitch / Details, Change Type, Add Note, Attach Image, Cancel |
pending |
Edit Pitch / Details, Change Type, Add Note, Attach Image, Cancel |
rejected |
Submit Revision, Edit Pitch / Details, Change Type, Accept Suggestion¹, Add Note, Attach Image, Cancel |
accepted |
Change Type, Change Visibility, Add Note, Attach Image, Cancel |
in_progress |
Change Visibility, Add Note, Attach Image, Cancel² |
in_review |
Change Visibility, Add Note, Attach Image, Cancel² |
refund_requested |
(none — awaiting admin decision) |
complete / cancelled / refunded |
(none — commission closed) |
¹ Only shown if a suggested revision exists on the rejection
² Cancel on a paid commission redirects to refund_requested
Clients with multiple active commissions see a dropdown to switch between them.
Notes and media
- Add Note → modal prompt, submitted text saved as a note attached to the commission
- Attach Image → modal prompt for a URL, downloaded immediately. Client prompted for SFW/NSFW. On confirm, posted to your log channel for review. Once reviewed, appears in the hub timeline.
- Raw DM text and image messages are no longer captured — all input goes through buttons/modals.
Hub timeline
Below the hub embed, the bot posts each saved note and ref image as individual messages. These are tracked via CommissionSurfaces under the hub_timeline surface and updated in-place when items change.
Architecture
Singletons and authority boundaries
All mutable state flows through three singleton classes. Nothing outside these classes should write commission status, mutate queue state, or send client-facing DMs directly.
CommissionManager (commission/manager.js)
Single authority for commission lifecycle. Instantiate with CommissionManager.find(id, client). Every lifecycle method (accept, reject, cancel, complete, etc.) writes DB state and calls updateEntries() to fan out UI refreshes. Never call db.updateCommissionStatus() directly from interaction handlers.
const manager = await CommissionManager.find(commissionId, client);
if (manager) await manager.accept();
updateEntries(hubOpts, scope) has three scopes:
'full'(default) — full repost of inbox + admin view for this commission's queue. Use for structural changes (entry/exit, position change).'card'— edit-in-place only: hub embed, single inbox card, single view card. Use for status-only mutations that don't change queue order.'extras'— like'card'but specifically for extras edits; skips position DMs.
QueueManager (queue/manager.js)
Static class. Single authority for queue config mutations and downstream panel refreshes.
await QueueManager.open(queueType, client); // → queueMessages + offerings + queuePanel
await QueueManager.onEntryChange(type, client); // → queueEmbedOnly + offerings (join/leave)
QueueManager.onTypeChange(client); // → offerings + typesPanel
ClientDM (handlers/client_dm.js)
Single interface for all client-facing DMs. All notification methods swallow DM failures internally — callers don't need try/catch. Instantiate with ClientDM.for(discordUserId, client) (async) or ClientDM.fromUser(user, client) (sync).
const dm = await ClientDM.for(commission.discord_user_id, client);
if (dm) await dm.notifyAccepted(commission);
Tracked notification DMs are registered in CommissionSurfaces under hub_notification and cleaned up on commission close via dm.deleteTracked(commissionId).
Hub state machine
commission/hub_machine.js is the single source of truth for hub state: which embed variant to use, which colour to show, which buttons are available, and which transitions are legal. The resolved state is simply commission.status — there is no separate edit mode meta-state.
const state = resolveHubState(commission);
const actions = getAvailableActions(state, commission);
const target = resolveStatus(state, 'submit'); // throws if illegal
Status writes go through CommissionManager._writeStatus() or CommissionManager._transition() — never directly from interaction handlers.
CommissionSurfaces
commission/surfaces.js is the single registry for every Discord message that represents a commission. It replaces the old scattered discord_meta key namespace for message tracking. All entries live in the commission_surfaces DB table.
CommissionSurfaces.upsert(commissionId, SURFACES.HUB, messageId, channelId);
CommissionSurfaces.register(commissionId, SURFACES.HUB_TIMELINE, messageId, channelId, metaKey);
await CommissionSurfaces.deleteAll(commissionId, client); // terminal cleanup
Always use SURFACES.* constants, never bare strings:
| Constant | Surface | Type |
|---|---|---|
SURFACES.HUB |
Client DM hub embed | single |
SURFACES.HUB_TIMELINE |
Client DM notes + media timeline | multi |
SURFACES.HUB_NOTIFICATION |
Client DM tracked notification messages | multi |
SURFACES.OWNER_REVIEW |
Owner DM accept/reject embed | single |
SURFACES.OWNER_REVIEW_TIMELINE |
Owner DM review thread timeline | multi |
SURFACES.OWNER_PAYMENT |
Owner DM "payment sent" ping | single |
SURFACES.INBOX |
ADMIN_INBOX application card |
single |
SURFACES.INBOX_TODO |
ADMIN_INBOX payment/todo card |
single |
SURFACES.VIEW |
ADMIN_VIEW viewer card (when selected) |
single |
SURFACES.VIEW_TIMELINE |
ADMIN_VIEW timeline messages |
multi |
Some state is still tracked via discord_meta for backwards compatibility:
| Key | Purpose |
|---|---|
hub_msg_{userId} |
Client hub embed message ID |
active_commission_{userId} |
Which commission is shown in hub |
art_pending_{messageId} |
Pending art categorisation meta (JSON) |
art_localpath_{commId} |
Path to final art file awaiting review approval |
paypal_pending_{userId} |
Pending PayPal gate state (JSON) |
Timeline centralisation
Notes and media are rendered chronologically in three surfaces: the client DM hub, the admin inbox (pending triage), and the admin view channel. All build/post/edit logic lives in panels/commission_cards.js:
buildTimelineItemEmbed(item, jumpUrl?)— single source of truth for note/media embed appearancepostCommissionTimeline(commissionId, post, trackItem?)— iterates sorted timeline, callspost(payload)for each itemeditTimelineItemInPlace(item, metaKey, channel, jumpUrl?)— edits a tracked message in place, returns false if not tracked
Hub update coalescing
sendOrUpdateHub serialises concurrent calls per user via a module-level Map of in-flight promises (_hubInFlight). Rapid successive calls chain rather than race, preventing duplicate hub messages.
Slash commands
Queue management (admin)
| Command | Description |
|---|---|
/open queue |
Open a queue, post announcement |
/close queue |
Close a queue, post announcement |
/setmax queue max |
Set max slots for a queue |
/add user queue [position] [private] [notes] |
Manually add a user to a queue |
/remove user |
Remove a user from a queue (multi-queue shows selector) |
/position [user] |
Check position across all queues |
/reopen user |
Reopen a user's most recently cancelled commission (within 3 days) |
/markpaid queue user |
Mark a commission as paid and move to in_progress |
/update_queue queue |
Refresh a specific queue's public and admin messages |
Admin controls
| Command | Description |
|---|---|
/setstatus status |
Set your working status (Working / Away / Event / Ill / Project) |
/update_panels |
Refresh all admin panels |
/update_offerings |
Refresh the commission info channel |
/update_info |
Repost the role management message |
/purge |
Wipe all managed channels and repaint everything from scratch |
/ping |
Re-pin the queue channel header to the top |
/scrape_users |
Add all server members to the DB |
Emergency / manual override
| Command | Description |
|---|---|
/resetcommission user queue |
Reset a commission to slot-reserved state and resend the application DM |
/resethub user |
Force-resend the commission hub to a user's DMs |
/forcecomplete user queue |
Force-complete a commission, bypassing client review — fires announcement, next-in-queue ping, DMs client to check the art channel |
/clearmeta key |
Clear a specific meta key from the DB. Pass all to wipe the entire discord_meta table — nuclear escape hatch for badly stuck state |
User data
| Command | Description |
|---|---|
/invoice user |
Open PayPal invoice creator for a user |
/setemail user email |
Manually set a user's stored PayPal email |
/lookup user |
View a user's DB record and active commissions |
/setuser user field value |
Correct a stored user field (username, DM pref, Twitch) |
Announcements
| Command | Description |
|---|---|
/announce message [title] [color] [image_url] [thumbnail_url] [footer] |
Post an announcement embed |
/announce_preview ... |
Preview before posting — shows Confirm/Cancel buttons |
/embed |
Post a custom embed in the current channel via modal |
Stream
| Command | Description |
|---|---|
/streamstart [tagline] [footer] |
Post a stream start announcement pinging the stream role |
Assets
| Command | Description |
|---|---|
/uploadart file [name] |
Upload artwork to the asset library — saves to disk, posts to upload channel, returns permanent URL |
/clearassets |
Purge all assets from DB, disk, upload channel, and asset list panel |
Admin panels
All panels use edit-in-place messaging.
Dashboard (ADMIN_DASHBOARD)
- Status selector (Working / Away / Event / Ill / Project)
- Stream status with selector
- 🗑️ Clear Bot DMs button
- Asset list panel (inline)
Inbox (ADMIN_INBOX)
Two card types, both edit-in-place:
- Application cards —
pendingandrejectedcommissions with full timeline posted inline. Action buttons: Accept, Reject, Request Info, Cancel - Todo cards — commissions needing payment attention (unpaid invoices, payment sent awaiting confirmation, disputed refunds)
Admin View (ADMIN_VIEW)
Dropdown to select an active commission by queue. Renders the full commission card with timeline inline. Updates on status changes via refreshViewerCard.
Queue Panel (QUEUE_PANEL)
Create, edit, open/close, hide/show, and archive queues. Each queue card shows type key, max slots, status, and colors.
Commission Types Panel (COMMISSION_TYPES)
Create and manage commission types per queue. Each type has: name, price per unit, unit type, min/max units, expected hours, plus embed customisation. Extras (add-on line items with individual prices) can be attached to each type.
Embeds Panel (EMBEDS_PANEL)
Edit every bot-sent embed live. Changes persist to discord_embeds and are picked up by buildResolvedEmbed() at send time.
Admin Queue Channel (QUEUE_ADMIN)
Per-entry cards for every pending/accepted commission. Buttons: ▲ / ▼ reorder (confirm step), Send Invoice (modal), status dropdown override, Remove.
Asset List Panel (ASSET_LIST)
Browse all uploaded embed artwork. Each card: filename, thumbnail, permanent URL, Get URLs button, Delete button.
Scheduled tasks
| Task | Timing | Action |
|---|---|---|
| Daily cleanup | Every 24h from startup | Cancels rejected commissions older than 2 days; cancels draft commissions older than 24 hours |
Add new tasks in schedule/cleanup.js and call them inside scheduleDailyCleanup.
HTTP server (server.js)
Combined static + REST server on ASSET_SERVER_PORT (default 4040). Static routes require no auth; all /api/ routes require Authorization: Bearer <API_SECRET>.
Static files
GET /assets/...— serves files frommedia/assets/GET /media/...— serves all files undermedia/(refs, WIPs, finals)
API routes
| Method | Route | Description |
|---|---|---|
GET |
/health |
Unauthenticated uptime ping |
GET |
/api/queues |
All visible queues + slot counts |
GET |
/api/commissions |
All active commissions |
GET |
/api/commissions/user/:discordUserId |
Active commissions for a user |
GET |
/api/commission/:id |
Full commission + notes + approved media |
POST |
/api/commission/:id/action |
Trigger a lifecycle method (accept, reject, start, complete, cancel, etc.) |
POST |
/api/commission/:id/invoice |
Shorthand for send_invoice action |
POST |
/api/queue/:type/action |
open or close a queue |
POST |
/api/discord/verify/request |
Send a 6-digit verification code via DM to a Discord user by username |
POST |
/api/discord/verify/confirm |
Validate a verification code; returns discord_user_id + username |
POST |
/api/site/commission |
Create a site-native commission (no Discord required) |
POST |
/api/site/link-discord |
Link an existing site user row to a Discord account |
POST |
/api/art/post |
Post WIP or final art to the appropriate channel; finals trigger in_review transition |
POST |
/api/stream/announce |
Post a stream start announcement |
POST |
/api/stream/ping |
DM all opted-in users with a stream ping |
POST |
/api/raffle/ping |
DM all opted-in users with a raffle ping |
POST |
/api/refresh |
Force full panel rebuild |
Recommended nginx config:
location /assets/ { proxy_pass http://localhost:4040; }
location /media/ { proxy_pass http://localhost:4040; }
location /api/ { proxy_pass http://localhost:4040; }
Media folder structure
media/
commissions/
{commission_id}/
refs/ # Client reference images
wips/ # WIP art files
{commId}_final # Final file awaiting or post review
art/
{message_id}/ # Staging area for art posted in ART_OUTPUT (moved on categorisation)
personal/ # Personal work (not attached to commissions)
assets/ # Embed artwork uploaded via /uploadart
Database tables
| Table | Purpose |
|---|---|
users |
Discord users — username, DM pref, email (encrypted), Twitch, site_user_id link, locale, admin_notes |
queues |
Queue config — open/closed, slots, colors, embed state fields, sort_order, description |
commission_types |
Types per queue — price, units, embed data |
commission_extras |
Add-on line items per commission type (name, price, active flag) |
commission_extra_selections |
Which extras a commission has selected (with price snapshot) |
commissions |
Commission records — status, pitch, visibility, flags, timestamps |
commission_clients |
Additional client rows per commission (multi-client support) |
commission_notes |
Text notes added via the hub Add Note button — includes updated_at |
commission_media |
Image attachments — download path, SFW/NSFW state, review status, upload_group, updated_at |
commission_surfaces |
Registry of every Discord message representing a commission |
wips |
WIP art log |
personal_work |
Personal work log — includes description, is_private, category |
media |
Shared media asset registry |
assets |
Embed artwork — filename, path, Discord message ID, mimetype, file_size |
discord_embeds |
Live embed overrides (from embeds panel) |
discord_meta |
Key-value store — legacy hub tracking keys, pending state |
Schema is defined in db/schema.js and runs on every boot via CREATE TABLE IF NOT EXISTS. All schema extensions use the guarded ALTER TABLE ADD COLUMN migration pattern — probe a SELECT on the column, catch the error, run the ALTER. This means new columns are always additive and safe to deploy without dropping data.
Adding a column: Never add it only to the
CREATE TABLEblock — existing DBs won't see it. Add a guarded migration at the bottom ofschema.jsfollowing the established pattern.
Extending
Adding a lifecycle transition
- Add the event and target status to the relevant state in
commission/hub_machine.jsSTATES - Add a method to
CommissionManagerthat calls_writeStatus()or_transition(), sends any client DMs viathis.dm, and callsupdateEntries() - Call the manager method from the interaction handler — never write status directly
Adding a scheduled task
Add an async function to schedule/cleanup.js, call it inside scheduleDailyCleanup both immediately and inside the setInterval block.
Adding a slash command
- Add the definition to a file in
commands/ - Add it to the
definitionsarray incommands/index.js - Add a handler branch in
handleCommandincommands/index.js - Run
/clearmeta bot_initializedin Discord and restart to re-register
Adding an editable embed
Rule: All bot messages sent to users must go through
buildResolvedEmbed(). Never usenew EmbedBuilder()directly for client-facing content. Register a default, add it to the panel group, usebuildResolvedEmbed()at the call site.
- Add a default object with a unique
keyto the relevant file inembeds/- Add a
placeholders: ['{token}', ...]array if the description uses runtime substitutions
- Add a
- Include it in
getEmbedGroups()inpanels/embeds_panel.js - Use
buildResolvedEmbed(YOUR_DEFAULT)at the call site- For runtime substitutions:
buildResolvedEmbed(YOUR_DEFAULT.embed_name, {variable: 'replacement'}) - Never mutate the default object directly — always spread it
- For runtime substitutions:
Adding a surface
- Add a constant to
SURFACESincommission/surfaces.js - Use
CommissionSurfaces.upsertfor single-message surfaces,CommissionSurfaces.registerfor multi-message CommissionSurfaces.deleteAll(commissionId, client)is the terminal cleanup call — it covers all surfaces automatically
Known limitations / future work
media/personal/has no panel — personal work is logged to DB but only visible via/lookupor directly in the DB- The asset server serves HTTP only — put nginx or Caddy in front for HTTPS
- Morning brief / daily queue summary — scheduler infrastructure is ready, tasks just need writing
dm_listener.jsand itsbot.jsevent hooks (messageCreateDM branch,messageUpdate,messageDelete) can be removed — DM input is now entirely button/modal driven- Price breakdown is not surfaced in the client hub — base price and selected extras exist in the DB but are not displayed to clients during or after the application flow. This is the next UX gap to close.