Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Discord (discord)

Discord is a guild-based chat platform. Rustiz uses OAuth only to discover the guild and the application’s bot user; every publish, comment and mention call authenticates as the Rustiz bot with DISCORD_BOT_TOKEN_ID. A channel is a guild channel id, and comments are published in a thread created on the original message.

  • Rust source: crates/rustiz-providers/src/discord.rs
  • Upstream port: discord.provider.ts
  • Editor: Markdown
  • Connection mode: oauth

Overview

PropertyValue
Identifierdiscord
Display nameDiscord
Connection modeoauth (a human opens Discord’s consent/install page)
Editormarkdown
Channel kindsocial
Comments / threadsYes. supports_comments = true; the first comment opens a thread on the post, and later comments are intended to stay in that thread (chains of 3+ entries are broken; see Gotchas)
Pending / async publishingNo. supports_pending = false; posting is a single multipart request
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsYes. supports_mentions = true; roles, members, @here and @everyone
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5 — generous for webhook-style message posting
Character limit (max_length)1980 characters (Discord’s hard limit is 2000)
OAuth scopesidentify, guilds checked; the authorize URL also requests bot
Redirect URI{FRONTEND_URL}/integrations/social/discord
API hosthttps://discord.com (override DISCORD_BASE_URL)
CDN hosthttps://cdn.discordapp.com (override DISCORD_CDN_BASE_URL)

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
DISCORD_CLIENT_IDYes—Application id from the Developer Portal
DISCORD_CLIENT_SECRETYes—OAuth2 client secret
DISCORD_BOT_TOKEN_IDYes in practice—The bot token, despite the _ID suffix. Every message, comment, channel list, mention and nickname call uses Bot <token>. Not part of required_env().
DISCORD_BASE_URLNohttps://discord.comOrigin for OAuth and /api/... calls, and for release URLs. Trailing slash stripped.
DISCORD_CDN_BASE_URLNohttps://cdn.discordapp.comOrigin for avatar URLs (/avatars/{user}/{hash}.png). Trailing slash stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the OAuth redirect URI
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state row

rustiz providers --status reports Discord as configured when only DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET are set. It does not check DISCORD_BOT_TOKEN_ID. The OAuth login can succeed and the channel can appear in channels list, but every publish fails with 401: Unauthorized until the bot token is set. Set both credentials and the bot token.

DISCORD_BASE_URL is used for both API calls and browser-facing release URLs. If you point it at a mock server, the releaseURL values are mock URLs too.

Step-by-Step Developer App Setup

1. Create the application

  1. Sign in to Discord and open the Developer Portal.
  2. Click New Application, name it (e.g. Rustiz), accept the terms.
  3. Upload an App Icon (1024×1024 max) under General Information and save. Postiz’s own docs call this required: without an icon, the Discord CDN avatar URL that Rustiz builds can 404 when the channel is added.
  4. Open OAuth2 and copy the Client ID and Client Secret.

2. Add the redirect URI

In OAuth2 → Redirects, click Add Redirect and paste the exact value:

https://social.example.com/integrations/social/discord

Local development examples:

http://localhost:5000/integrations/social/discord   # Docker / rustiz serve default
http://localhost:4200/integrations/social/discord   # upstream Postiz dev UI

Discord requires HTTPS for production redirect URIs. It accepts http://localhost (and sometimes loopback IPs) for local development. The URI must match exactly, including scheme, host, port, path and trailing slash. Do not append a query string.

There is no redirectmeto.com wrapper for Discord.

3. Create the bot and copy the token

  1. Open Bot in the Developer Portal.
  2. Set the bot’s username/avatar as desired.
  3. Click Reset Token and copy the token. This is the value for DISCORD_BOT_TOKEN_ID — it is a token, not an application id.
  4. The bot does not need privileged intents for this integration: it sends messages and uses the member search endpoint, which Discord documents as usable without the Guild Members intent. Do not enable privileged intents unless you need the full member list or member events.

If you later want the full member list (GET /guilds/{id}/members), the GUILD_MEMBERS privileged intent is required. Apps under 10,000 guild-installed users can toggle privileged intents themselves; above that, Discord requires an application and review.

4. Set the environment and restart

DISCORD_CLIENT_ID="…"
DISCORD_CLIENT_SECRET="…"
DISCORD_BOT_TOKEN_ID="…"
FRONTEND_URL="https://social.example.com"

Restart rustiz serve, rustiz worker and rustiz mcp, then check:

rustiz providers --status

5. Redirect URI (exact)

Discord::redirect_uri() is:

#![allow(unused)]
fn main() {
format!(
    "{}/integrations/social/discord",
    self.ctx.config.frontend_url()
)
}

FRONTEND_URL is trimmed of trailing slashes, so the registered value has no trailing slash. The same string is sent to /oauth2/authorize and to /api/oauth2/token; a mismatch returns invalid_request / Invalid "redirect_uri" in request.

6. Scopes and permissions requested

The authorize URL is:

https://discord.com/oauth2/authorize
  ?client_id=<DISCORD_CLIENT_ID>
  &permissions=377957124096
  &response_type=code
  &redirect_uri=<urlencoded redirect>
  &integration_type=0
  &scope=bot+identify+guilds
  &state=<state>

377957124096 is the permission bit set:

PermissionBitIncluded
SEND_MESSAGES1 << 11yes
CREATE_PUBLIC_THREADS1 << 35yes
CREATE_PRIVATE_THREADS1 << 36yes
SEND_MESSAGES_IN_THREADS1 << 38yes
VIEW_CHANNEL1 << 10no (normally granted by @everyone)
ATTACH_FILES1 << 15no (needed for media; grant via role if @everyone lacks it)

integration_type=0 means GUILD_INSTALL. The app must allow guild installs in the Developer Portal (the default for a bot app). The bot scope adds the bot to the selected server with the requested permissions; identify and guilds give Rustiz the user token it uses to read the guild id and application/bot identity.

authenticate() calls check_scopes() with ["identify", "guilds"] against the token response’s scope. The bot scope is not checked, but the URL requests it. If Discord returns a scope string without identify or guilds, the connection fails with NotEnoughScopes.

The user performing the install needs Manage Server on the target guild.

7. Sandbox / testing vs production app review

Discord has no sandbox guild or test-mode OAuth client. Use a dedicated test server.

  • Under 100 guilds: the bot works immediately; no Discord review. Create a private test server, install the app there, and post to a test channel.
  • 100+ guilds: Discord App Verification is required. The owner must verify identity through Stripe and complete the App Verification checklist in the Developer Portal. This is for public scale, not for a private deployment.
  • App Directory listing is optional and separate from verification.
  • Privileged intents: not needed here; if enabled, the threshold is 10,000 guild-installed users (2026 policy), after which Discord requires an application and 90-day review.
  • Bot token vs OAuth token: production and test use the same application model. Rotating the bot token in the portal immediately breaks every existing Rustiz channel until DISCORD_BOT_TOKEN_ID is updated and the processes are restarted. Reconnecting the channel does not fix a bad bot token; Rustiz’s own error says so.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect discord --open
  • The login URL is printed to stderr; --open launches the browser.
  • With a local FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to --timeout seconds (default 300) for the new channel.
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll with rustiz channels connect-status <connectionId> or rustiz channels list.

The connect response’s id is the guild id, name is the application name, and username is the bot user’s username. The channel’s internal_id is the guild id.

MCP connectChannelTool

Start:

{ "provider": "discord" }

The tool returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://discord.com/oauth2/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Give the url to a human with Manage Server, then poll:

{ "connectionId": "…" }

Choosing the channel after connecting

is_between_steps = false; there is no channelSetupTool step. The guild id is already stored. Use triggerTool with the channels function to list the guild’s text, announcement and forum channels:

{
  "integrationId": "<discord channel id>",
  "methodName": "channels",
  "dataSchema": []
}

The result is [{ "id": "123456789012345678", "name": "general" }]. Only channel types 0 (text), 5 (announcement) and 15 (forum) are returned. Choose an id and put it in the post settings:

{ "channel": "123456789012345678" }

channelSetupTool is not used by Discord. It is only for providers whose connection creates a temporary parent channel that must be narrowed to a page, board, company or YouTube channel.

What not to send

  • customFields — Discord is OAuth only; the generic path would treat the object as an authorization code.
  • externalUrl — Discord is not self-hosted. The generic path calls the provider’s external_url(), which returns Unsupported("externalUrl").

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "minLength": 1,
      "description": "Channel must be an id"
    }
  },
  "required": ["channel"],
  "additionalProperties": true
}

channel is a snowflake id, not a #name. Usernames and channel names are not resolved.

validate_settings() rules

  • channel must exist and be a string.
  • It must not be empty.
  • No snowflake-format check, no permission check, no “is this channel in the connected guild?” check. A wrong or foreign channel id passes validation and fails at Discord with 50001 / 10003.

check_validity()

Not overridden. There are no pre-flight media or length rules; Discord’s API enforces them. In particular:

  • The 2000-character message limit is reduced to 1980 by max_length(), but a post with media still has the same 2000-character cap.
  • Discord’s attachment size limit depends on the server’s boost tier. Rustiz does not pre-check; Discord returns 40005 (Request entity too large) and the provider maps it to “Attachment exceeds Discord’s size limit”.

Media requirements

send_message_with_media() builds a multipart request:

POST {DISCORD_BASE_URL}/api/channels/{channel}/messages
Authorization: Bot <DISCORD_BOT_TOKEN_ID>
Content-Type: multipart/form-data

payload_json = {
  "content": "<converted mentions>",
  "attachments": [
    {"id": 0, "description": "Picture 0", "filename": "image.png"},
    ...
  ]
}
files[0] = <streamed file>
files[1] = ...
  • Every media item is attached in one message. Discord allows up to 10 attachments per message; Rustiz does not split a larger list.
  • The file part has no explicit MIME type. Discord usually sniffs it, but a mislabeled file can be rejected.
  • The whole form is rebuilt on each retry because a consumed media stream cannot be replayed.
  • Discord’s per-file size limit applies (10 MB on unboosted servers; higher with boosts). There is no pre-flight size check.
  • alt text is not sent; the attachment description is hard-coded as Picture <index>.

Threads and comments

supports_comments = true.

  • The first comment calls POST /api/channels/{channel}/messages/{post_id}/threads with {"name":"Thread","auto_archive_duration":1440} and then sends the comment into the new thread channel.
  • Later comments are supposed to reuse the same thread channel.
  • Known limitation: comment() receives last_comment_id as the previous comment’s message id, and then uses it as a channel id for the next POST /channels/{id}/messages. For a thread with three or more entries, the third entry targets a message id as if it were a channel and fails. Only the first reply is reliable. See “Implementation Notes” below.
  • Comment media follows the same multipart path as a normal post.
  • releaseURL for a comment is the thread message URL.

Plugs

No @Plug or @PostPlug methods. run_plug() and run_post_plug() use the trait defaults and return ProviderError::Unsupported.

Implementation Notes & Gotchas

  • Posting is bot-only. post() and comment() ignore the OAuth access_token entirely and send Authorization: Bot <DISCORD_BOT_TOKEN_ID>. The OAuth token is used only during connect/refresh. A missing or rotated bot token makes every publish fail even though the channel looks connected.
  • The bot must be in the guild. The bot scope in the authorize URL adds it during install. If someone removes the bot from the server, channels returns 50001 and posts fail. Reconnecting the channel does not re-add the bot; use the install URL again.
  • Channel lookup uses the guild id. channels() calls /api/guilds/{integration.internal_id}/channels. If the token response did not include guild.id (for example the bot was already installed and Discord omitted the guild object), internal_id is empty and the lookup hits /api/guilds//channels. This is why the integration id is the guild id, not the bot id.
  • Avatar URL can 404. authenticate() builds {CDN}/avatars/{bot.id}/{bot.avatar}.png. If the bot has no custom avatar, bot.avatar is empty and the URL ends in /.png; Postiz’s docs tell you to upload an app icon for this reason. A 404 avatar does not stop publishing.
  • Refresh response loses identity fields. refresh_token() returns only the new token and application name; id, picture and username are empty. The engine only writes the token columns, so this is normally harmless. Do not call refresh_token() and then treat the result as a full account snapshot.
  • OAuth token lifetime. Discord access tokens expire after 7 days (expires_in: 604800) and the refresh token can rotate. The engine does not refresh Discord proactively (refresh_cron = false); it refreshes on a 401 from the OAuth token. Because posting uses the bot token, a stale OAuth token has no effect on publishing.
  • Thread bug for 3+ comments. Engine chaining (post_flow.rs) passes last_comment_id = previous comment's post_id. For Discord that is a message id. comment() treats it as a channel id on every reply after the first. With exactly two entries (post + one comment) the code uses the root post id and works. With three or more, the third and later comments fail. There is no stored thread_id in pending_data/PostResponse, so the provider cannot recover the thread channel id later.
  • Forum channels are listed but not really supported. channels() includes type 15 (forum), but posting to a forum channel requires creating a thread with a thread_name/forum payload, not a plain /channels/{id}/messages call. Expect a forum channel to fail or create an unexpected message. Prefer text (0) or announcement (5) channels.
  • Mentions: role labels are often empty. mention() returns roles with id = "&<role id>" and label = name.split('@').nth(1). A normal role name like Moderator has no @, so the label is an empty string. The id is still correct and mention_format() converts [[[@&123]]] to <@&123>, but a UI that displays label will show blank role options.
  • Mentions: member search needs the bot in the guild. members/search is called with the bot token and a query; it returns members whose username or nickname starts with the query. No privileged intent is required by current Discord docs for this endpoint (the full member-list endpoint is different). @here and @everyone are special-cased and not cached.
  • Mentions: @here/@everyone are plain text. mention_format() returns the name unchanged for those two, unlike normal users/roles which are wrapped as [[[@…]]] and converted to <@…>/<@&…>. A ping only happens if the bot’s role has MENTION_EVERYONE; the fixed permission integer 377957124096 does not request it. Grant it separately if you need mass mentions.
  • Mention conversion is regex-based. convert_mentions() replaces [[[@...]]] with <@...> for both users and roles. A literal [[[@...]]] in the message will be converted; there is no escaping mechanism.
  • Error mapping is substring-based. handle_errors() looks for Discord error codes anywhere in the body:
    • 50001 → Bot doesn't have access to this channel
    • 50013 → Bot lacks permission to send messages in this channel
    • 10003 → Channel no longer exists
    • 40005 → Attachment exceeds Discord's size limit
    • 20028 → Rate limited by Discord (retry)
    • 401: Unauthorized → Discord rejected the Rustiz bot token, reconnecting will not help A 401 with a different body is treated as a refresh error by the shared HTTP layer, which then cannot help because posting uses the bot token.
  • Only 200/201 count as success in send(). send_message_with_media bypasses this and accepts 200..=202. channels, create_thread and change_nickname use send_json, so a 204 would be treated as failure.
  • Rate limits. Discord allows up to 50 requests/second per bot globally, plus per-route buckets (message creation is roughly 5 requests per 5 seconds per channel). Rustiz sets max_concurrent_job = 5, which is safe for global throughput but can still hit a per-channel bucket if several queued posts target the same channel. 429 responses are retried by the shared HTTP layer.
  • Invalid-request Cloudflare ban. Discord temporarily blocks IPs that make too many 401/403/429 requests (10,000 per 10 minutes). A bad bot token or a removed channel in a retry loop can contribute. Fix the token/channel instead of letting the queue retry indefinitely.
  • change_nickname needs the guild id and permission. It calls PATCH /api/guilds/{id}/members/@me with {"nick": name}. The requested permission bit set does not include CHANGE_NICKNAME; a bot can normally change its own nickname, but a restrictive role setup may return 403.
  • max_length is 1980, not 2000. The 20-character margin is intentional, but the provider does not count UTF-16 code units or grapheme clusters the way Discord does. Emoji-heavy posts can still exceed Discord’s real limit.
  • Markdown editor. Discord renders Markdown, so the engine passes the content through the Markdown editor path. Discord’s flavor differs from CommonMark in tables, headings and mentions; test formatting in a real channel.
  • Release URL uses the configurable base. It is {DISCORD_BASE_URL}/channels/{guild}/{channel}/{message}. With the default base this is the normal discord.com/channels/... link; with a mock base it is not a usable Discord link.
  • No analytics or plugs. Discord has no analytics methods, no post analytics and no plug hooks in this port.

References