Discord Bot
  • JavaScript 100%
Find a file
probablysleeping bdc19368f9 pain8
2026-04-19 22:31:56 +02:00
commands pain2 2026-04-19 21:49:04 +02:00
commission pain3 2026-04-19 22:08:30 +02:00
db pain6 2026-04-19 22:20:40 +02:00
embeds pain 2026-04-19 21:46:40 +02:00
handlers pain3 2026-04-19 22:08:30 +02:00
interactions pain8 2026-04-19 22:31:56 +02:00
panels pain8 2026-04-19 22:31:56 +02:00
queue actually including proper files 2026-04-17 17:41:24 +02:00
schedule initial 2026-04-14 14:50:50 +02:00
utils never_final2 2026-04-19 20:51:05 +02:00
.gitignore initial 2026-04-14 14:50:50 +02:00
.gitignore.txt v1 2026-04-16 20:09:26 +02:00
bot.js pain7 2026-04-19 22:27:08 +02:00
config.js never_final 2026-04-19 20:22:00 +02:00
deploy.bat Split out dismiss file 2026-04-15 18:55:51 +02:00
DiscordBot.zip pain 2026-04-19 21:46:40 +02:00
log.js initial 2026-04-14 14:50:50 +02:00
package-lock.json more tests 2026-04-16 00:00:07 +02:00
package.json more tests 2026-04-16 00:00:07 +02:00
purge.js initial 2026-04-14 14:50:50 +02:00
README.md actually including proper files 2026-04-17 17:41:24 +02:00
server.js never_final 2026-04-19 20:22:00 +02:00

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:

  1. Register all slash commands with Discord
  2. Purge and repaint all managed channels
  3. Set bot_initialized in 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

  1. Client clicks Apply in the commission info or queue channel
  2. Bot checks DMs are open, reserves a slot (status: draft), sends a DM with Complete Application / Release Slot buttons
  3. Complete Application → visibility selector (Public / Anonymous / Stay Private)
  4. Visibility selection → pitch modal (description, character names, optional unit count)
  5. Pitch submit → PayPal email prompt (if crypto configured). Status stays draft — client can now add notes and attach refs via hub buttons
  6. Client hits Submit Application in their commission hub → status moves to pending, bot pings you
  7. 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

  • Startin_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

  1. Select Finished in the art prompt → pick commission from dropdown
  2. Invoice gate: if not yet invoiced, bot shows invoice link / Mark as Invoiced button
  3. On invoiced → status moves to in_review, client receives review DM with the art attached (served from local file, not an expiring CDN URL)
  4. 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
  5. 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_requested instead of cancelled

Refunds

  1. Client requests refund from hub (modal for reason; only shown after payment_sent is flagged)
  2. You get a DM with Approve / Deny buttons
  3. Approve → status refunded, tracked DMs cleaned up, client notified
  4. 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 appearance
  • postCommissionTimeline(commissionId, post, trackItem?) — iterates sorted timeline, calls post(payload) for each item
  • editTimelineItemInPlace(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 cardspending and rejected commissions 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 from media/assets/
  • GET /media/... — serves all files under media/ (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 TABLE block — existing DBs won't see it. Add a guarded migration at the bottom of schema.js following the established pattern.


Extending

Adding a lifecycle transition

  1. Add the event and target status to the relevant state in commission/hub_machine.js STATES
  2. Add a method to CommissionManager that calls _writeStatus() or _transition(), sends any client DMs via this.dm, and calls updateEntries()
  3. 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

  1. Add the definition to a file in commands/
  2. Add it to the definitions array in commands/index.js
  3. Add a handler branch in handleCommand in commands/index.js
  4. Run /clearmeta bot_initialized in Discord and restart to re-register

Adding an editable embed

Rule: All bot messages sent to users must go through buildResolvedEmbed(). Never use new EmbedBuilder() directly for client-facing content. Register a default, add it to the panel group, use buildResolvedEmbed() at the call site.

  1. Add a default object with a unique key to the relevant file in embeds/
    • Add a placeholders: ['{token}', ...] array if the description uses runtime substitutions
  2. Include it in getEmbedGroups() in panels/embeds_panel.js
  3. 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

Adding a surface

  1. Add a constant to SURFACES in commission/surfaces.js
  2. Use CommissionSurfaces.upsert for single-message surfaces, CommissionSurfaces.register for multi-message
  3. 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 /lookup or 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.js and its bot.js event hooks (messageCreate DM 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.