Nothing matches that search.
Systems
Ticket topics & priorities
What a member picks before the form opens.
Topics offered in the panel select menu
| Topic | Value | Description |
| General support | support | Questions about the server or our products |
| Report a member | report | Rule breaking, harassment, spam or scams |
| Punishment appeal | appeal | Appeal a warning, mute or ban |
| Bug report | bug | Something is broken or behaving oddly |
| Something else | other | Anything the other options do not cover |
- Priorities:
low, normal, high, urgent. Optional on the form; defaults to normal.
- Retired topics stay in the code so tickets already opened under them keep showing the right label — currently
billing.
- Defaults when a variable is unset: category
tickets, transcript channel transcripts, 3 open tickets per member, transcripts capped at 1000 messages.
Support tickets
A full help desk with no database behind it.
Staff runs /ticketsetup once. It publishes a panel with a topic select menu and a Create Ticket button. A member picks a topic, fills a short form (summary, description, optional priority) and gets a private channel in the ticket category, visible only to them, the bot and staff roles.
The first ticket is named ticket-username; simultaneous ones get -2, -3 and so on. The opening message pings the member, lists every detail and carries the control panel, pinned automatically.
Ticket state — owner, number, status, claimer, timestamps, topic, priority, subject, panel message ID — is stored as compact JSON inside the channel topic. That is what keeps the bot serverless: no KV, D1 or R2 is needed for tickets, and the state survives Worker restarts and redeploys. A Durable Object (TICKET_LOCKS) serialises changes per channel so two simultaneous clicks cannot race the state write, the transcript or the deletion.
Panel buttons
| Button | Member | Staff | Effect |
| Close ticket | yes | yes | Channel becomes read-only for the member. Nothing is deleted; it can be reopened. |
| Open ticket | partly | yes | Restores write access and re-pings the member. Members cannot reopen a fully closed ticket. |
| Fully close | yes | yes | Member loses access completely, staff keep it. Transcript is generated and distributed. |
| Claim ticket | no | yes | Records which staff member is handling the case; shown in the panel and the channel topic. |
| Delete ticket | no | yes | Generates the transcript, distributes it, then deletes the channel. |
| Save transcript | no | yes | Exports the conversation so far as HTML without closing anything. |
- Both destructive actions (Fully close and Delete) ask for confirmation first, in a message only the clicker can see. The prompt's buttons are removed the moment it is answered — a deferred update once left them live for several seconds, which let one ticket send its transcript repeatedly.
- The control panel re-renders after every action, so the disabled buttons and the status line always match reality.
Transcripts
Self-contained HTML, delivered to both sides.
When a ticket is fully closed or deleted, the bot fetches the whole conversation (up to 1000 messages) and renders a single self-contained, Discord-styled HTML file: dark theme, avatars, bot badges, timestamps, mentions, code blocks, quotes, attachments, images and embeds, with an info card and participant chips at the top.
The ticket owner is DMed the file plus a quick-facts embed: ticket number, status, topic, priority, who handled it, who closed it, exact opening and closing time (both Discord-relative and UTC), total duration and message count. The same file and summary go to the staff transcript channel, along with whether the member's DM was actually delivered, the member ID and the channel ID.
Automod
Unicode-aware text, link and display-name rules.
Matching is evasion-aware and anchored to word boundaries. Case, leetspeak, invisible and combining characters, separators and mixed Cyrillic/Latin homoglyphs all fold together, so a single entry covers DDoS, dd0s, d d o s and d.d.o.s anywhere in a sentence — while malwarebytes and addossier stay clean. Terms shorter than four characters get boundary matching only, because separator tolerance on something that short pulls in ordinary prose.
The public blocklist holds targeted attack and scam phrases in several languages. Abusive language and slurs live in secrets instead, never in git history: AUTOMOD_BANNED_WORDS_SECRET for whole terms, AUTOMOD_BANNED_STEMS_SECRET for stems matched inside inflected words, and AUTOMOD_BANNED_PREFIXES_SECRET for roots matched only from the start of a word.
RAT is handled contextually so ordinary sentences about an animal survive: uppercase RAT, a message consisting only of rat, and explicit malware phrases such as rat builder or remote access trojan are blocked, while lab rat and rat race are not.
Modes
| Mode | What happens |
| off | Rules do not run. |
| flag | Detection is logged to the bot log channel; nothing is touched. Use this after editing any list. |
| enforce | The message is deleted and a warning is recorded, which can trigger escalation. |
Link policy, in two layers
| Layer | Rule |
| Always on | Raw-IP URLs, credential-bearing URLs, punycode domains and domains imitating protected brands are treated as suspicious. |
| Strict allowlist | When AUTOMOD_ALLOWED_DOMAINS is non-empty, every other domain is blocked. |
- The checked host is the parsed destination, so text like
discord.com@evil.example cannot pretend the first name is the domain. - Display names are checked on join and on every member update. Links, mass-mention names, blocked terms and staff/project impersonation become a neutral
Member-1234 nickname in enforce mode. Exempt staff roles are never renamed, and only the server nickname changes — a bot cannot rename a Discord account. - Automod needs the relay.
AUTOMOD_MODE alone is not enough: the rules run in the Worker, but the Worker only sees messages when the relay runs with AUTOMOD_FORWARD_ALL=true. Without it automod reads as enabled and does nothing, so the relay logs a warning at startup.
Warning escalation
Thresholds you opted into, not defaults you inherited.
Warnings are stored in KV under warns:<guildId>:<userId>. Without the MODERATION_STATE binding warnings are not stored at all and escalation cannot work.
Each threshold fires when the count of non-expired warnings reaches it; 0 disables that step. Kick and ban are deliberately left at 0: an automatic ban belongs behind a decision somebody actually made.
Verification gate
Fail-closed by design.
Access is denied to @everyone at the category level and granted by the member role. That direction matters: an “unverified role that denies everything” is fail-open, because a joiner the bot never managed to tag walks in with full access. Here nothing has to succeed for the server to stay shut.
The panel button opens a modal with a generated challenge; a correct answer grants the member role. The verify channel must stay visible to @everyone, or the gate locks everyone out of itself. VERIFY_UNVERIFIED_ROLE_ID is a label only — nothing depends on it, it exists for filtering and pruning.
Suggestions board
Human posts become app-authored voting embeds.
Every human post in the suggestion channel that carries text or attachments is replaced by a VoidTools application embed showing the member's current display name and avatar, then the bot adds ✅, ❌ and ➖.
This is not a webhook impersonating the member: the message stays visibly authored by the app, and the member appears in the embed author profile. The conversion is transactional — the original is deleted only after the replacement and all reactions exist, and a failure rolls the app message back so the source survives. Discord's enforced nonce prevents duplicate posts on a retried delivery.
- Needs
AUTOMOD_FORWARD_ALL=true on the relay, because that is what forwards ordinary MESSAGE_CREATE events to the Worker.
Release announcements
One feed, shared with the desktop auto-updater.
Release data comes from the website's existing update feed — the same signed feed the desktop app uses. There is no second changelog to keep in sync.
/postupdate announces the current release into the channel it was run in. A scheduled() handler is ready for a cron trigger, but no cron is configured: announcements are manual on purpose. On the very first run the current release is stored as a baseline and nothing is posted, unless ANNOUNCE_INITIAL_RELEASE is true.
The last announced release is remembered in KV under last_announced_release. Announcements open with a notification line wrapped in spoiler bars, so the mention is delivered without a visible wall of pings.
GitHub notifications
Webhooks in, embeds out. Nothing stays connected.
GitHub webhooks are ordinary HTTP POSTs, so this needs no WebSocket and no always-on host: GitHub calls the Worker, the Worker calls Discord.
Every delivery must carry a valid X-Hub-Signature-256 HMAC-SHA256 over the raw body, verified with crypto.subtle.verify (constant-time). Unsigned or mis-signed requests get 401 and never reach Discord. All notifications are sent with allowed_mentions: { parse: [] }, so a branch name containing @everyone renders as harmless text.
Because Discord only allows a per-message username and avatar through channel webhooks, notifications are sent through one. Each notification then appears as the GitHub account that triggered it, with that account's real profile picture. Without GITHUB_DISCORD_WEBHOOK_URL the bot still posts, just under its own name with the GitHub identity on the embed author line.
Announced events
| Event | Announced when |
| push | Commits land on a branch. Lists up to 8 commits and flags force pushes. |
| pull_request | Opened, reopened, merged, closed without merging, draft ↔ ready. |
| pull_request_review | Approved, changes requested, or reviewed. |
| pull_request_review_comment | A new inline review comment, with the file path. |
| issues | Opened, reopened, closed. Includes labels. |
| issue_comment | A new comment on an issue or PR. |
| create / delete | A branch or tag is created or deleted. |
| release | A release is published. |
| workflow_run | A workflow finishes: succeeded, failed, timed out, cancelled. |
| fork | Someone forks the repository. |
| watch | Someone stars the repository. |
- Anything else — and noisy sub-actions such as
labeled, assigned or synchronize — is acknowledged with 200 and deliberately not posted.
Logging
Split by who caused the thing, not by what kind of thing it is.
BOT_LOG_CHANNEL_ID receives the bot's own decisions: automod deleting a message, automod issuing a warning, autoroles being applied. LOG_CHANNEL_ID receives what people did: commands they ran, messages they deleted or edited, joins, leaves, manual bans. A human running /warn is a person's action carried out through the bot, so it lands in the second one. Leave LOG_CHANNEL_ID empty to keep everything in one channel rather than silently dropping half of it.
Why the audit log exists: plenty of actions previously left no trace anyone could read back. Ticket buttons answer with an ephemeral message only the clicker sees, and when a ticket is deleted the bot's confirmation is edited into a channel that no longer exists, so it dies as a 404. The audit entry survives both.
Audit entry fields
| Field | Contents |
| Title | /ban, Ticket deleted, Ticket claimed, … |
| Actor | Who triggered it, as a mention plus their raw ID. |
| Channel | Where it was triggered. |
| Outcome | Completed, Refused or failed or Crashed. |
| Input | Options they passed, or the values they typed into a modal. |
| Reply | The reply the bot produced, condensed to one line. |
| Error | Only on a crash: the HTTP status and reason Discord gave. |
- Wired into the two central funnels (
processCommand and runInBackground), not into the ~30 individual handlers — so a new command is covered the moment it is added and nobody can forget the log call. - A failed log post never breaks a command: it is swallowed and reported to the Worker console. Logging is fully off when
BOT_LOG_CHANNEL_ID is empty, with no auto-created fallback channel, because a log that invents its own destination makes an empty log ambiguous. - Entries use Discord's suppress notifications flag, so the channel never generates badges or pings.
- Not covered: GitHub relays and release announcements, which already announce themselves in their own channels.
Gateway relay
A dumb transport for the one thing a Worker cannot hold.
Cloudflare remains the brain. The relay holds a persistent WebSocket to the Discord Gateway and forwards interesting events to POST /gateway. It makes no moderation decisions and stores no business state; its only Discord REST call warms the recent-message cache at startup.
If the relay dies you lose real-time server events. Slash commands, tickets, transcripts, the GitHub route and the audit log keep working, because those are all the Worker. That asymmetry is the entire point of running it this way instead of migrating the bot to a VPS.
Filtering happens on the relay, not in the Worker, because every forwarded event costs one Worker request. MESSAGE_CREATE is only forwarded when it starts with COMMAND_PREFIX — empty by default, so ordinary conversation generates no Worker traffic at all. Automod is the exception, and the reason AUTOMOD_FORWARD_ALL exists: text rules cannot filter what they never see.
The relay caches recent message content in RAM, because Discord's MESSAGE_DELETE payload carries only IDs. On startup it pulls the last CACHE_WARM messages per readable channel (50 by default, 0 disables) so a restart does not blind it. A deletion it has no record of is still logged, saying plainly that the text is unavailable — staying silent was the original behaviour and it was wrong, because an audit log that says nothing when a message disappears cannot be told apart from a broken one.
Zero dependencies: Node 22 ships a global WebSocket, so there is no ws and no discord.js to keep patched on a box you rarely log into.
What the relay adds
| Event | Logged to |
| Message deleted, with its cached content | LOG_CHANNEL_ID |
| Message edited, before and after | LOG_CHANNEL_ID |
| Member joined / left | LOG_CHANNEL_ID |
| Member banned / unbanned | LOG_CHANNEL_ID |
| Automod deletion or warning | BOT_LOG_CHANNEL_ID |
| Autorole applied | BOT_LOG_CHANNEL_ID |
Requirements
| Requirement | Why |
| Node 22 or newer | Global WebSocket; the installer adds it if missing. |
| Message Content + Server Members intents | Both are privileged. Without them Discord closes the socket with 4014. |
| Manage Messages in moderated channels | Automod deletions. |
| Add Reactions | Suggestion voting. |
| Manage Nicknames + role hierarchy | Automatic name cleanup. |
| Moderate Members | The optional warning-threshold timeout. |
GATEWAY_SECRET identical on both sides | Authenticates the relay's POSTs to the Worker. |
- Gateway
IDENTIFY requires the bot token, so the token has to live on the relay host. A compromised host means a compromised bot; that is unavoidable with any gateway connection. The systemd unit limits the damage as far as it can: dedicated user with no login shell, ProtectSystem=strict, read-only install directory, no capabilities and no listening sockets — the relay only dials out. - Prefix commands (
?ban style) are forwarded and logged but not executed. Running them needs an adapter that presents a plain chat message to the interaction-shaped handlers, which is a larger change. Set COMMAND_PREFIX and you will see entries recording that somebody tried.