VoidToolsbot settings

Everything this bot can do

Every command, setting, button and automation of the VoidTools moderation bot in one reference. Generated straight from the bot's source, so it cannot drift from the deployed behaviour.

37slash commands
8subcommands
110options
49settings
10secrets
RuntimeCloudflare Workers
Application ID1530303374950531092
Ticket stateChannel topic JSON
Persistence2 KV + 1 Durable Object

Every slash command requires the Administrator permission. It is hidden in Discord's UI through default_member_permissions and re-checked server-side, so the UI alone is never the gate. Regular members interact through the ticket and verification panels, which need no permission at all.

Commands

Moderation 9

Actions against a member. Every one of these requires Administrator, is re-checked in code, and replies publicly on purpose so the channel keeps a visible record.

/ban

AdministratorPublic replyDMs the member

Ban a member from the server (DMs them the reason).

/ban <user> <reason> [clear_messages] [clear_amount]
OptionTypeDescription
user user required The member to ban.
reason string required Why is this member being banned? (required)max 1000 chars
clear_messages boolean optional Delete the user's recent messages? (yes / no)
clear_amount string optional How much message history to delete (only if clear_messages is yes).
  • Don't delete any0
  • Previous 1 hour3600
  • Previous 6 hours21600
  • Previous 12 hours43200
  • Previous 24 hours86400
  • Previous 3 days259200
  • Previous 7 days (max)604800

/softban

AdministratorPublic replyDMs the member

Ban and immediately unban a member to remove their recent messages.

/softban <user> <reason> [clear_amount]
OptionTypeDescription
user user required The member to softban.
reason string required Why is this member being softbanned?max 1000 chars
clear_amount string optional How much message history to delete (defaults to 24 hours).
  • Previous 1 hour3600
  • Previous 6 hours21600
  • Previous 12 hours43200
  • Previous 24 hours86400
  • Previous 3 days259200
  • Previous 7 days (max)604800

/kick

AdministratorPublic replyDMs the member

Kick a member from the server (DMs them the reason).

/kick <user> <reason>
OptionTypeDescription
user user required The member to kick.
reason string required Why is this member being kicked? (required)max 1000 chars

/mute

AdministratorPublic replyDMs the member

Timeout (mute) a member for a set duration (DMs them the reason).

/mute <user> <duration> <reason>
OptionTypeDescription
user user required The member to mute.
duration string required How long the mute lasts.
  • 60 seconds60
  • 5 minutes300
  • 10 minutes600
  • 1 hour3600
  • 6 hours21600
  • 1 day86400
  • 1 week604800
  • 28 days (max)2419200
reason string required Why is this member being muted? (required)max 1000 chars

/unmute

AdministratorPublic replyDMs the member

Remove a member's timeout (unmute).

/unmute <user> [reason]
OptionTypeDescription
user user required The member to unmute.
reason string optional Reason for the unmute.max 1000 chars

/unban

AdministratorPublic reply

Unban a user by their ID.

/unban <user_id> [reason]
OptionTypeDescription
user_id string required The ID of the user to unban.
reason string optional Reason for the unban.max 1000 chars

/warn

AdministratorPublic replyDMs the member

Warn a member (DMs them the warning).

/warn <user> <reason>
OptionTypeDescription
user user required The member to warn.
reason string required What is the warning for? (required)max 1000 chars

/role

AdministratorPublic reply

Add or remove a server role from a member.

/role <action> <user> <role> [reason]
OptionTypeDescription
action string required Whether to add or remove the role.
  • Add roleadd
  • Remove roleremove
user user required The member whose roles should be changed.
role role required The role to add or remove.
reason string optional Reason for the role change.max 1000 chars

/nickname

AdministratorPublic reply

Set or reset a member's server nickname.

/nickname <user> [nickname] [reason]
OptionTypeDescription
user user required The member whose nickname should be changed.
nickname string optional New nickname; leave empty to reset it.1–32 chars
reason string optional Reason for the nickname change.max 1000 chars

Warnings 2

Warnings are stored in KV and expire, so escalation reflects recent behaviour rather than a member's whole history.

/warnings

AdministratorEphemeral reply

Show a member's warning history and the escalation policy.

/warnings <user>
OptionTypeDescription
user user required Member to look up.

/clearwarns

AdministratorPublic reply

Remove every warning on record for a member.

/clearwarns <user>
OptionTypeDescription
user user required Member whose warnings should be wiped.

Channel control 4

Channel-level moderation. Locking preserves existing overwrite state instead of overwriting it, so unlocking restores what was there before.

/purge

AdministratorEphemeral reply

Bulk delete recent messages in this channel.

/purge <amount> [user]
OptionTypeDescription
amount integer required How many messages to delete (1-100).1–100
user user optional Only delete messages from this user.

/slowmode

AdministratorPublic reply

Set this channel's slowmode delay.

/slowmode <seconds>
OptionTypeDescription
seconds integer required Seconds between messages (0 to disable, max 21600).0–21600

/lock

AdministratorPublic reply

Lock the current channel so regular members cannot send messages.

/lock [reason]
OptionTypeDescription
reason string optional Why is the channel being locked?max 1000 chars

/unlock

AdministratorPublic reply

Unlock the current channel so regular members can send messages again.

/unlock [reason]
OptionTypeDescription
reason string optional Why is the channel being unlocked?max 1000 chars

Announcements & messaging 8

Everything the bot can post on your behalf. Pings are always scoped explicitly through allowed_mentions, and plain-text commands disable mentions entirely.

/announce

AdministratorEphemeral reply

Post an announcement, with an optional ping and images.

/announce <title> <message> [channel] [ping] [ping_role] [color] [image] [thumbnail] [footer] [pin]
OptionTypeDescription
title string required Announcement headline.max 200 chars
message string required Announcement text. Use \n for a new line.max 2000 chars
channel channel optional Where to publish it (defaults to this channel).text / announcement
ping string optional Who should be notified?
  • No pingnone
  • @everyoneeveryone
  • @herehere
  • A specific rolerole
ping_role role optional Role to ping (only used when ping = A specific role).
color string optional Accent colour of the embed.
  • Brand violetvoid
  • Blurpleblurple
  • Blueinfo
  • Greensuccess
  • Yellowwarning
  • Reddanger
  • Aquaaqua
  • Pinkpink
  • Goldgold
  • Greyneutral
image string optional Large banner image URL.
thumbnail string optional Small top-right image URL (defaults to the server icon).
footer string optional Custom footer text.max 200 chars
pin boolean optional Pin the announcement in the channel?

/update

AdministratorEphemeral reply

Post changelog-style release notes for an update.

/update <version> [title] [summary] [added] [changed] [fixed] [removed] [notes] [link] [image] [channel] [ping] [ping_role] [color] [pin]
OptionTypeDescription
version string required Version or build number, e.g. 2.4.0max 40 chars
title string optional Optional release name, e.g. 'Ticket update'.max 120 chars
summary string optional One or two sentences describing the release.max 1000 chars
added string optional New features. Separate entries with | (pipe).max 1000 chars
changed string optional Changes / improvements. Separate entries with |max 1000 chars
fixed string optional Bug fixes. Separate entries with |max 1000 chars
removed string optional Removed features. Separate entries with |max 1000 chars
notes string optional Extra notes shown at the bottom.max 1000 chars
link string optional Link to the full changelog / download.
image string optional Banner image URL for the update.
channel channel optional Where to publish it (defaults to this channel).text / announcement
ping string optional Who should be notified?
  • No pingnone
  • @everyoneeveryone
  • @herehere
  • A specific rolerole
ping_role role optional Role to ping (only used when ping = A specific role).
color string optional Accent colour of the embed.
  • Brand violetvoid
  • Blurpleblurple
  • Blueinfo
  • Greensuccess
  • Yellowwarning
  • Reddanger
  • Aquaaqua
  • Pinkpink
  • Goldgold
  • Greyneutral
pin boolean optional Pin the update in the channel?

/postupdate

AdministratorEphemeral reply

Announce the current release from the website's update feed.

/postupdate [channel] [ping] [mark_announced]
OptionTypeDescription
channel channel optional Where to post it (defaults to this channel).text / announcement
ping boolean optional Notify @here and the member role (default: yes).
mark_announced boolean optional Stop the automatic check from posting it again (default: yes).

/embed

AdministratorEphemeral reply

Build and post a custom embed.

/embed <description> [title] [color] [author] [footer] [image] [thumbnail] [url] [channel]
OptionTypeDescription
description string required Main text. Use \n for a new line.max 2000 chars
title string optional Embed title.max 200 chars
color string optional Accent colour of the embed.
  • Brand violetvoid
  • Blurpleblurple
  • Blueinfo
  • Greensuccess
  • Yellowwarning
  • Reddanger
  • Aquaaqua
  • Pinkpink
  • Goldgold
  • Greyneutral
author string optional Small text above the title.max 200 chars
footer string optional Footer text.max 200 chars
image string optional Large image URL.
thumbnail string optional Small image URL.
url string optional Make the title clickable.
channel channel optional Where to post it (defaults to this channel).text / announcement

/say

AdministratorEphemeral reply

Send a plain message as the bot (mentions disabled).

/say <message> [channel]
OptionTypeDescription
message string required What should I say? Use \n for a new line.max 2000 chars
channel channel optional Where to post it (defaults to this channel).text / announcement

/echo

AdministratorEphemeral reply

Post text as the bot. Only you see that you ran it.

/echo <message> [channel] [silent]
OptionTypeDescription
message string required The text to post. Use \n for a new line.max 2000 chars
channel channel optional Where to post it (defaults to this channel).text / announcement
silent boolean optional Post without pinging or notifying anyone.

/dm

AdministratorEphemeral reply

Message a member privately as the staff team.

/dm <user> <message> [anonymous] [color]
OptionTypeDescription
user user required Who should receive the message?
message string required Message body. Use \n for a new line.max 1800 chars
anonymous boolean optional Hide your name and sign it as 'Staff team'?
color string optional Accent colour of the embed.
  • Brand violetvoid
  • Blurpleblurple
  • Blueinfo
  • Greensuccess
  • Yellowwarning
  • Reddanger
  • Aquaaqua
  • Pinkpink
  • Goldgold
  • Greyneutral

/poll

AdministratorEphemeral reply

Create a poll members can vote in.

/poll <question> <option1> <option2> [option3] [option4] [option5] [option6] [duration] [multiple] [channel] [color]
OptionTypeDescription
question string required What are you asking?max 300 chars
option1 string required First answer.max 55 chars
option2 string required Second answer.max 55 chars
option3 string optional Third answer.max 55 chars
option4 string optional Fourth answer.max 55 chars
option5 string optional Fifth answer.max 55 chars
option6 string optional Sixth answer.max 55 chars
duration integer optional How many hours the poll runs (default 24, max 768).1–768
multiple boolean optional Allow voting for more than one option?
channel channel optional Where to post it (defaults to this channel).text / announcement
color string optional Accent colour of the embed.
  • Brand violetvoid
  • Blurpleblurple
  • Blueinfo
  • Greensuccess
  • Yellowwarning
  • Reddanger
  • Aquaaqua
  • Pinkpink
  • Goldgold
  • Greyneutral

Information 5

Read-only lookups. They change nothing and are safe to run anytime.

/userinfo

AdministratorPublic reply

Show information about a member.

/userinfo <user>
OptionTypeDescription
user user required The member to inspect.

/roleinfo

AdministratorPublic reply

Show information and permissions of a role.

/roleinfo <role>
OptionTypeDescription
role role required The role to inspect.

/avatar

AdministratorPublic reply

Show a member's avatar in full size.

/avatar [user]
OptionTypeDescription
user user optional The member whose avatar you want to view.

/channelinfo

AdministratorPublic reply

Show useful information about a server channel.

/channelinfo [channel]
OptionTypeDescription
channel channel optional The channel to inspect (defaults to the current channel).

/serverinfo

AdministratorPublic reply

Show information about this server.

/serverinfo

Takes no options.

Tickets 2

Publish the help-desk panel and manage the ticket you are standing in. Members never need these — they use the panel buttons.

/ticketsetup

AdministratorEphemeral reply

Publish the panel members use to open support tickets.

/ticketsetup [channel]
OptionTypeDescription
channel channel optional Where to publish the panel (defaults to this channel).text / announcement

/ticket

AdministratorEphemeral reply8 subcommands

Ticket tools: details, access, claiming and transcripts.

/ticket <subcommand>
/ticket info Show details about this ticket.
/ticket claim Mark yourself as the staff member handling this ticket.
/ticket close Close this ticket (it can be reopened).
/ticket open Reopen this ticket.
/ticket transcript Export the conversation so far as an HTML file.
/ticket add Give another member access to this ticket.
OptionTypeDescription
user user required Member to add.
/ticket remove Remove a member's access to this ticket.
OptionTypeDescription
user user required Member to remove.
/ticket panel Re-publish the ticket panel.
OptionTypeDescription
channel channel optional Where to publish the panel.text / announcement

Verification & autoroles 5

The join funnel: publish the verification gate, and decide which roles are handed out automatically.

/verifypanel

AdministratorEphemeral reply

Publish the panel new members use to verify and unlock the server.

/verifypanel [channel]
OptionTypeDescription
channel channel optional Where to publish the panel (defaults to this channel).text / announcement

/autorole

AdministratorEphemeral reply

Give a role automatically to every new member who joins.

/autorole <role>
OptionTypeDescription
role role required Role to hand out to new members.

/removeautorole

AdministratorEphemeral reply

Stop giving a role automatically to new members.

/removeautorole <role>
OptionTypeDescription
role role required Role to stop handing out.

/autorolebots

AdministratorEphemeral reply

Give a role automatically to every bot that joins.

/autorolebots <role>
OptionTypeDescription
role role required Role to hand out to new bots.

/removeautorolebots

AdministratorEphemeral reply

Stop giving a role automatically to new bots.

/removeautorolebots <role>
OptionTypeDescription
role role required Role to stop handing out to bots.

Utility 2

Health and discovery.

/ping

AdministratorEphemeral reply

Check that VoidTools is online.

/ping

Takes no options.

/help

AdministratorEphemeral reply

List every VoidTools command.

/help

Takes no options.

Systems

Ticket topics & priorities

What a member picks before the form opens.

Topics offered in the panel select menu
TopicValueDescription
General supportsupportQuestions about the server or our products
Report a memberreportRule breaking, harassment, spam or scams
Punishment appealappealAppeal a warning, mute or ban
Bug reportbugSomething is broken or behaving oddly
Something elseotherAnything 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
ButtonMemberStaffEffect
Close ticketyesyesChannel becomes read-only for the member. Nothing is deleted; it can be reopened.
Open ticketpartlyyesRestores write access and re-pings the member. Members cannot reopen a fully closed ticket.
Fully closeyesyesMember loses access completely, staff keep it. Transcript is generated and distributed.
Claim ticketnoyesRecords which staff member is handling the case; shown in the panel and the channel topic.
Delete ticketnoyesGenerates the transcript, distributes it, then deletes the channel.
Save transcriptnoyesExports 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
ModeWhat happens
offRules do not run.
flagDetection is logged to the bot log channel; nothing is touched. Use this after editing any list.
enforceThe message is deleted and a warning is recorded, which can trigger escalation.
Link policy, in two layers
LayerRule
Always onRaw-IP URLs, credential-bearing URLs, punycode domains and domains imitating protected brands are treated as suspicious.
Strict allowlistWhen 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
EventAnnounced when
pushCommits land on a branch. Lists up to 8 commits and flags force pushes.
pull_requestOpened, reopened, merged, closed without merging, draft ↔ ready.
pull_request_reviewApproved, changes requested, or reviewed.
pull_request_review_commentA new inline review comment, with the file path.
issuesOpened, reopened, closed. Includes labels.
issue_commentA new comment on an issue or PR.
create / deleteA branch or tag is created or deleted.
releaseA release is published.
workflow_runA workflow finishes: succeeded, failed, timed out, cancelled.
forkSomeone forks the repository.
watchSomeone 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
FieldContents
Title/ban, Ticket deleted, Ticket claimed, …
ActorWho triggered it, as a mention plus their raw ID.
ChannelWhere it was triggered.
OutcomeCompleted, Refused or failed or Crashed.
InputOptions they passed, or the values they typed into a modal.
ReplyThe reply the bot produced, condensed to one line.
ErrorOnly 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.

Image & video moderation

Deliberately delegated to a vetted provider.

The project does not build its own classifier for CSAM, adult media or gore. A home-grown model is not reliable enough for that decision and creates serious handling and retention risk.

Instead an optional adapter calls a vetted HTTPS moderation provider with a no-retention policy. The Worker sends temporary Discord attachment URLs plus minimal metadata; it never downloads, decodes, hashes, previews or stores the media, and audit entries contain no attachment URL. Provider failures fail open and are logged — they never cause silent deletions.

Start in flag and only switch to enforce after reviewing real false-positive rates.

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
EventLogged to
Message deleted, with its cached contentLOG_CHANNEL_ID
Message edited, before and afterLOG_CHANNEL_ID
Member joined / leftLOG_CHANNEL_ID
Member banned / unbannedLOG_CHANNEL_ID
Automod deletion or warningBOT_LOG_CHANNEL_ID
Autorole appliedBOT_LOG_CHANNEL_ID
Requirements
RequirementWhy
Node 22 or newerGlobal WebSocket; the installer adds it if missing.
Message Content + Server Members intentsBoth are privileged. Without them Discord closes the socket with 4014.
Manage Messages in moderated channelsAutomod deletions.
Add ReactionsSuggestion voting.
Manage Nicknames + role hierarchyAutomatic name cleanup.
Moderate MembersThe optional warning-threshold timeout.
GATEWAY_SECRET identical on both sidesAuthenticates 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.

Configuration

Non-secret settings, read from wrangler.toml[vars]. These are the values in version control; change one and redeploy for it to take effect. Nothing here is sensitive — the sensitive lists live in secrets.

Discord app 2

VariableValueWhat it does
DISCORD_APPLICATION_ID 1530303374950531092 Discord ID This app's ID.
DISCORD_PUBLIC_KEY dc439589369ae54d4f39635c073a86554e93880cc8844c739105a91d440aac10 Verifies incoming interaction signatures.

Tickets 8

VariableValueWhat it does
TICKET_CATEGORY_ID 1490713248637653173 Discord ID Existing category that ticket channels are created in. Wins over the name.
TICKET_CATEGORY_NAME <\> ~ Tickets Fallback if the ID ever goes away. Auto-created when missing.
TICKET_TRANSCRIPT_CHANNEL_ID 1490713557648801854 Discord ID Channel that receives every archived ticket transcript.
TICKET_TRANSCRIPT_CHANNEL_NAME transcript Fallback name, auto-created as a staff-only channel.
TICKET_MEMBER_ROLE_ID empty If set, only this role (plus staff) may open tickets. Empty means everyone.
TICKET_STAFF_ROLE_ID 1490710064040771675,1490710288184377557 Staff roles added to every ticket and pinged when one opens. Comma separated. Administrator / Manage Server get access regardless, but only these are pinged.
TICKET_SUPPORT_HOURS Usually within a few hours Response-time text shown on the panel.
TICKET_MAX_OPEN_PER_USER 3 Simultaneous tickets per member.

Verification 3

VariableValueWhat it does
VERIFY_MEMBER_ROLE_ID 1490710613289078864 Discord ID Granted on successful verification. This is what actually unlocks the server.
VERIFY_UNVERIFIED_ROLE_ID 1531724245648805989 Discord ID Label only. Nothing depends on it; it exists for filtering and pruning.
VERIFY_CHANNEL_ID 1531724246483734669 Discord ID Must stay visible to @everyone, or the gate locks everyone out of itself.

Automod 14

VariableValueWhat it does
AUTOMOD_MODE enforce off | flag | enforce. Needs AUTOMOD_FORWARD_ALL on the relay to have anything to act on.
AUTOMOD_BANNED_WORDS
88 entries
malwaremalware builderddosddoserdosrat builderrat generatorrat clientrat payloadrat malwarerat trojanremote access trojanremote administration trojancrypterbooterstresserrat tooltoken grabberstealerbotnetnukerraid toolraid serviceraid botselfbottoken loggerip loggerip grabbernitro generatorfree nitronitro sniperdoxxdoxbinswattingwarezkeygennulledcracked installercrack downloadphishing kitscam pagecardingcredit card dumpcombolistzłośliwe oprogramowanienarzędzie ddosgrabber tokenówkradzież tokenówlogger tokenówlogger ipgenerator nitrodarmowe nitrozestaw phishingowystrona phishingowadoksingkradzież kartlista comboвредоносное поддосерстилер токеновграббер токеновлоггер токеновип логгергенератор нитробесплатный нитрофишинговый наборфишинговая страницадоксингкардингкомболистшкідливе програмне забезпеченнявикрадач токенівгенератор нітрофішинговий набірфішингова сторінкаsoftware maliciosorobador de tokensgenerador de nitrokit de phishingpágina de phishinglogiciel malveillantvoleur de jetongénérateur nitrokit de phishingschadsoftwaretoken diebnitro generatorphishing kit
Public blocked terms, comma separated. Unicode and evasion aware.
AUTOMOD_BLOCK_RAT true Block RAT malware references without matching ordinary prose about the animal.
AUTOMOD_BANNED_PREFIX_EXCEPTIONS niggle*,niggl*,niggard* Benign exact words, or whole families with a trailing *, that survive the prefix secret.
AUTOMOD_BLOCK_INVITES true Block Discord invite links.
AUTOMOD_BLOCK_SHORTENERS true Block link shorteners.
AUTOMOD_BLOCK_SUSPICIOUS_LINKS true Block raw-IP, credential-bearing, punycode and brand-imitating URLs.
AUTOMOD_ALLOWED_DOMAINS
25 entries
voidtools.softwaregithub.comgithubusercontent.comdiscord.comdiscordapp.comdiscord.ggyoutube.comyoutu.begoogle.comgoogleusercontent.commicrosoft.comlive.comoffice.comcloudflare.comwikipedia.orgwikimedia.orgreddit.comredd.itimgur.comsteamcommunity.comsteampowered.comtwitch.tvx.comtwitter.comtiktok.com
Strict allowlist. When non-empty, every other domain is blocked.
AUTOMOD_MAX_MENTIONS 5 Mentions in one message before it is flagged. 0 disables the check.
AUTOMOD_EXEMPT_ROLE_ID 1490710064040771675,1490710288184377557 Roles automod ignores entirely. Comma separated.
AUTOMOD_EXEMPT_CHANNEL_ID empty Channels automod ignores entirely. Comma separated.
AUTOMOD_RESERVED_NAMES admin,administrator,moderator,staff,voidtools Staff/project names protected from impersonation.
AUTOMOD_BANNED_NAMES official support,discord support,free nitro Public blocked terms for guild display names.
AUTOMOD_NAME_REPLACEMENT Member Base of the safe replacement nickname, e.g. Member-1234.

Warnings 5

VariableValueWhat it does
WARN_EXPIRY_DAYS 30 Warnings stop counting toward escalation after this many days. 0 = never.
WARN_MUTE_AT 3 Warning count that triggers a timeout. 0 disables the step.
WARN_MUTE_MINUTES 60 Length of that timeout.
WARN_KICK_AT 0 Warning count that triggers a kick. 0 disables the step.
WARN_BAN_AT 0 Warning count that triggers a ban. 0 disables the step.

Media moderation 4

VariableValueWhat it does
MEDIA_MODERATION_MODE off off | flag | enforce. External provider policy.
MEDIA_MODERATION_ENDPOINT empty Vetted provider HTTPS endpoint.
MEDIA_MODERATION_TIMEOUT_MS 8000 Provider request timeout. Failures fail open.
MEDIA_MODERATION_THRESHOLD 0.90 Score at or above which a category counts as a hit.

Releases 9

VariableValueWhat it does
RELEASE_FEED_URL https://raw.githubusercontent.com/LordSniper007/LordSniper007.github.io/main/updates/stable.json The desktop app's signed auto-update feed, reused as the changelog source.
RELEASE_CHANGELOG_URL https://voidtools.software/#releases Linked from the embed when a section has to be shortened.
RELEASE_PROJECT_NAME VoidTools Shown in the embed footer.
DISCORD_RELEASE_CHANNEL_ID empty Default target for the admin endpoint, which has no channel of its own. /postupdate uses the channel it was run in.
ANNOUNCE_INITIAL_RELEASE false Announce the very first release instead of just storing it as the baseline.
RELEASE_FETCH_TIMEOUT_MS 10000 How long to wait for the feed before giving up.
RELEASE_PING true Set to false to announce silently.
RELEASE_PING_HERE true Include @here in the spoiler-wrapped notification line.
RELEASE_PING_ROLE_ID 1490710613289078864 Discord ID Roles mentioned alongside @here. Comma separated.

Channels & logging 4

VariableValueWhat it does
GITHUB_CHANNEL_ID 1490719196516974723 Discord ID Channel that receives GitHub activity notifications.
BOT_LOG_CHANNEL_ID 1531658534830346250 Discord ID What the bot decided: automod, autoroles. Empty disables the audit log entirely.
LOG_CHANNEL_ID 1490719234165313737 Discord ID What people did: commands, deletions, edits, joins, leaves, bans. Empty merges into the bot log.
SUGGESTION_CHANNEL_ID 1532073576893517894 Discord ID Human posts here become app-authored voting embeds.

Storage bindings

BindingKindHolds
RELEASE_STATEKVlast_announced_release, so a release is never announced twice.
MODERATION_STATEKVWarning history under warns:<guildId>:<userId>. Without it warnings are not stored and escalation cannot work.
TICKET_LOCKSDurable ObjectSerialises ticket state changes per channel so simultaneous clicks cannot race state writes, transcripts or deletion.

Secrets

Set with npx wrangler secret put <NAME> and never committed. Only names and purposes are listed here — no value is readable from this page or from the Worker's configuration API.

SecretRequiredPurpose
DISCORD_TOKENyesBot token used to call the Discord REST API.
GATEWAY_SECREToptionalShared secret authenticating the gateway relay's POSTs.
GITHUB_WEBHOOK_SECREToptionalShared secret that authenticates GitHub webhook payloads.
GITHUB_DISCORD_WEBHOOK_URLoptionalOptional. Channel webhook used to post as the GitHub user.
ADMIN_API_SECREToptionalBearer secret for the protected release-test endpoint.
AUTOMOD_BANNED_WORDS_SECREToptionalSlurs, kept out of git history. Merged with the public list.
AUTOMOD_BANNED_STEMS_SECREToptionalSensitive 4+ character stems matched inside inflected words.
AUTOMOD_BANNED_PREFIXES_SECREToptionalSensitive roots matched only from word boundaries.
AUTOMOD_BANNED_NAMES_SECREToptionalSensitive display-name terms, merged with the public list.
MEDIA_MODERATION_TOKENoptionalBearer credential for the external media-safety provider.

HTTP routes

Three authentication schemes, separated by path on purpose: Ed25519 for Discord, HMAC-SHA256 for GitHub and the relay, and a bearer secret for the admin endpoint. Keeping them apart means one scheme can never be used to reach another's handler.

PathMethodPurpose
/POSTDiscord interactions. Ed25519-verified against the public key.
/GETHealth check.
/githubPOSTGitHub webhooks. HMAC-SHA256-verified against the shared secret.
/githubGETSetup hint.
/gatewayPOSTGateway events from the relay. HMAC-SHA256 over <timestamp>.<body>.
/gatewayGETSetup hint.
/admin/test-release-announcementPOSTProtected release-announcement test. Bearer ADMIN_API_SECRET.

Permissions

What the invite asks for, and why. Discord rejects an action with 403 if the VoidTools role sits below the member or role it is acting on — the bot detects that case and says exactly what to fix.

PermissionNeeded for
Ban Members/ban, /softban, /unban
Kick Members/kick
Moderate Members/mute, /unmute, warning-threshold timeouts
Manage Messages/purge, automod deletions, pinning panels
Manage ChannelsTicket channels, /lock, /unlock, /slowmode
Manage Roles/role, autoroles, verification, ticket access
Manage Nicknames/nickname, automod name cleanup
View ChannelsReading the channels it moderates
Send MessagesEvery reply and announcement
Embed LinksAll embeds
Attach FilesHTML transcripts
Read Message HistoryTranscripts and /purge
Add ReactionsSuggestion voting
Mention EveryoneScoped announcement pings
Relay environment (/etc/voidtools-relay.env)
KeyDefaultPurpose
DISCORD_TOKENsecretGateway IDENTIFY. Must be the same bot token.
WORKER_GATEWAY_URLFull URL of the Worker's /gateway route.
GATEWAY_SECRETsecretMust match the Worker's secret exactly.
AUTOMOD_FORWARD_ALLtrueForwards every new and edited message. Required for automod and suggestions.
COMMAND_PREFIX(empty)When set, MESSAGE_CREATE starting with it is forwarded and logged.
CACHE_WARM50Messages pulled per readable channel at startup. 0 disables.

Setup

  1. Deploy the Worker

    Push to main, or run the Deploy VoidTools Bot workflow. It deploys the Worker and registers the slash commands. Repo secrets needed: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, DISCORD_TOKEN, optionally GH_WEBHOOK_SECRET and a GUILD_ID variable for instant command registration instead of the ~1 hour global rollout.

  2. Point Discord at it

    Developer Portal → Interactions Endpoint URL → the Worker's *.workers.dev URL. Discord immediately sends a PING; the Worker answers it.

  3. Invite the bot, fix role position

    Move the VoidTools role above the members and roles it should manage, otherwise Discord rejects actions with 403. The bot detects this and says exactly what to fix.

  4. Publish the panels

    Run /ticketsetup in the support channel and /verifypanel in the verification channel.

  5. Optional: the relay

    sudo bash gateway/install.sh on an always-on host, fill in /etc/voidtools-relay.env, then sudo systemctl start voidtools-relay. A healthy start logs {"event":"ready", …}. Required for automod, suggestions and server event logging.

Hard limits the bot respects

embed title256embed description4096embed field name256embed field value1024embed footer2048embed author256embed fields25message content2000channel topic1024channel name100nickname32audit reason512modal title45text input label45select placeholder150button label80channels per category50

Discord returns 400 when any of these is exceeded, so every builder truncates to them.