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

Slack (slack)

Slack is a workspace chat platform. Rustiz installs a Slack app with a bot token, lists public and private channels, posts messages with Block Kit, and threads comments under the original message. Media is sent as Block Kit image blocks, so files must be publicly reachable URLs; videos are rejected before publishing.

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

Overview

PropertyValue
Identifierslack
Display nameSlack
Connection modeoauth (a human installs the app into a workspace)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; extra posts are replies with thread_ts
Pending / async publishingNo. supports_pending = false; one chat.postMessage per post
AnalyticsNo. supports_analytics = false
Post analyticsNo. supports_post_analytics = false
MentionsNo. supports_mentions = false
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job10 — Slack limits are per workspace, but posting is 1 msg/sec/channel
Character limit (max_length)400000 characters (this is not Slack’s real limit; see below)
OAuth scopeschannels:read, chat:write, users:read, groups:read, channels:join, chat:write.customize
Redirect URI{FRONTEND_URL}/integrations/social/slack, wrapped in https://redirectmeto.com/ when FRONTEND_URL does not contain https
API hosthttps://slack.com (override SLACK_BASE_URL)

Server Environment Variables

required_env() returns exactly:

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

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
SLACK_IDYes—Slack Client ID (not SLACK_CLIENT_ID)
SLACK_SECRETYes—Slack Client Secret (not SLACK_CLIENT_SECRET)
SLACK_BASE_URLNohttps://slack.comOrigin for OAuth and /api/... calls. Trailing slash stripped.
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI; also decides whether the redirectmeto.com tunnel is used
JWT_SECRETYes in practiceemptyEncrypts the pending OAuth state row

The variable names are a common trap: the Slack provider uses SLACK_ID / SLACK_SECRET, while most other OAuth providers use *_CLIENT_ID / *_CLIENT_SECRET.

rustiz providers --status only checks the two credential variables. It cannot tell whether the app has the six scopes, whether the redirect URL is registered, or whether the app is allowed to install into the target workspace.

For GovSlack, set SLACK_BASE_URL=https://slack-gov.com; the provider appends /oauth/v2/authorize and /api/... itself.

Step-by-Step Developer App Setup

1. Create the Slack app

  1. Open https://api.slack.com/apps and click Create New App.
  2. Choose From scratch.
  3. Name it (e.g. Rustiz) and select the development workspace where you will test. A new app can only be installed in this workspace until public distribution is activated.
  4. Open Basic Information → App Credentials and copy the Client ID and Client Secret.

2. Add the redirect URL

Open Features → OAuth & Permissions → Redirect URLs and add the exact URL Rustiz will send.

Slack::redirect_uri() is:

#![allow(unused)]
fn main() {
fn redirect_uri(&self, refresh: Option<&str>) -> String {
    let frontend = self.ctx.config.frontend_url();
    let prefix = if frontend.contains("https") {
        ""
    } else {
        "https://redirectmeto.com/"
    };
    let refresh = refresh.map(|r| format!("?refresh={r}")).unwrap_or_default();
    format!("{prefix}{frontend}/integrations/social/slack{refresh}")
}
}

The check is a substring test, not a URL scheme parse. Any FRONTEND_URL containing the letters https is used directly; everything else is tunnelled through https://redirectmeto.com/.

Production example:

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

Docker / local example (FRONTEND_URL=http://localhost:5000):

https://redirectmeto.com/http://localhost:5000/integrations/social/slack

Register the tunnelled URL in Slack for local HTTP deployments, not the plain http://localhost:5000/... URL. Slack requires HTTPS redirect URLs and rejects plain http production URLs.

Slack matches a redirect URI if it is equal to or a subdirectory of a registered Redirect URL. It also requires the same redirect_uri value in the authorize and token-exchange steps.

Reconnect quirk: when a connection is started with a refresh query parameter, generate_auth_url() uses the redirect URI without ?refresh=<id> (it calls redirect_uri(None)), while authenticate() calls redirect_uri(params.refresh.as_deref()) and therefore sends .../slack?refresh=<id> at token exchange. Slack’s own rule says the two values must match. If Slack returns bad_redirect_uri during a reconnect, start a fresh connection without the refresh parameter instead of retrying the refresh flow.

3. Add the bot token scopes

In OAuth & Permissions → Scopes → Bot Token Scopes, add all six scopes:

ScopeWhy Rustiz needs it
channels:readconversations.list for public channels
groups:readconversations.list for private channels
chat:writechat.postMessage
users:readusers.info to resolve the bot user’s name/avatar during connect
channels:joinconversations.join before posting to a public channel
chat:write.customizeusername and icon_url overrides; Rustiz always sends username

The Postiz documentation lists only five scopes and omits chat:write.customize. That page is stale for this Rust port. check_scopes() requires all six at connect time, so a Slack app built from the five-scope list fails the connection with NotEnoughScopes before any post is made.

Slack scopes are additive for an existing installation: adding a scope and reinstalling grants the new scope without removing the old ones. Removing a scope requires revoking the token and reinstalling.

4. Set an app icon

In Settings → Basic Information → Display Information, upload an app icon. Postiz’s docs require a square image between 512×512 and 2000×2000. Rustiz uses the bot user’s profile.image_original as the integration picture and as icon_url on every message; a missing icon produces an empty/placeholder avatar and may make the UI reject the integration in the upstream Postiz frontend.

5. Install the app and set the environment

Install the app into the workspace from OAuth & Permissions → Install to Workspace. Rustiz performs the OAuth install itself, but installing once in the portal is a quick way to verify the scopes.

SLACK_ID="…"
SLACK_SECRET="…"
FRONTEND_URL="https://social.example.com"

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

rustiz providers --status

6. Scopes requested and checked

The authorize URL is:

https://slack.com/oauth/v2/authorize
  ?client_id=<SLACK_ID>
  &redirect_uri=<urlencoded redirect>
  &scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize
  &state=<state>

Slack accepts a comma-separated scope list. The token exchange posts client_id, client_secret, code and redirect_uri to https://slack.com/api/oauth.v2.access. Slack returns scope as a comma-separated string; check_scopes() accepts commas or spaces.

authenticate() then calls users.info?user=<bot_user_id> with the bot token to get real_name, name and profile.image_original. The integration id is the team id, the name is the bot’s real name, and the username is the bot’s Slack username.

7. Sandbox / testing vs production review

Slack has no separate sandbox app or test-mode client. A newly created app can be installed only in its development workspace; that is the testing environment.

  • Single workspace / internal use: install and use immediately. No Slack review.
  • Other workspaces: open Manage Distribution, complete the checklist, and click Activate Public Distribution. This is a configuration toggle, not a review; it takes effect immediately. Without it, installing in another workspace fails with invalid_team_for_non_distributed_app.
  • Slack Marketplace (App Directory) listing: optional and separately reviewed. Current Slack requirements include a fully tested app, at least 10 active workspaces, a demo video and scope justification; the preliminary review is about 10 business days and functional review can take up to 10 weeks for a new submission.
  • HTTPS is mandatory for distributed apps. All OAuth redirect URLs, interaction request URLs, Events API URLs and Block Kit option-load URLs must be HTTPS. The redirectmeto.com prefix exists to satisfy this during local development.
  • Token rotation: do not enable Slack token rotation for this integration. Rustiz stores the bot token as non-expiring (expires_in of 100 years) and its refresh_token() implementation is a no-op that returns an empty access token. If rotation is enabled, the token eventually expires and Rustiz cannot refresh it; the channel is flagged for reconnection instead.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect slack --open
  • The login URL is printed to stderr; --open opens 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 channel to appear.
  • 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.

For a local HTTP deployment, the browser goes to https://redirectmeto.com/http://localhost:5000/integrations/social/slack?..., which then redirects to the local callback. The CLI still serves the local callback on the frontend port.

MCP connectChannelTool

Start:

{ "provider": "slack" }

The tool returns:

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

Give the url to a workspace member who can install apps, then poll:

{ "connectionId": "…" }

Choosing the channel after connecting

is_between_steps = false; there is no channelSetupTool step. Use triggerTool with the channels function:

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

The result is [{ "id": "C012AB3CD", "name": "general" }, …]. Only the first page of conversations.list is fetched (no limit or cursor parameters), so a large workspace may not show every channel. Archived channels are included because the request does not set exclude_archived=true.

Store the chosen id in the post settings:

{ "channel": "C012AB3CD" }

channelSetupTool is not used by Slack.

What not to send

  • customFields — Slack is OAuth only; the generic path would base64-encode the object and pass it as an OAuth code.
  • externalUrl — Slack 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": "Id of the Slack channel to post to"
    }
  },
  "required": ["channel"],
  "additionalProperties": true
}

channel must be a channel id (C…) or private-group id (G…), not a #channel-name. DMs are not supported by the channel picker because conversations.list is called with only public_channel,private_channel.

validate_settings() rules

  • channel must exist and be a string.
  • It must not be empty.
  • There is no check that the channel exists, that the bot is a member, or that the id belongs to the connected workspace. A bad id fails at Slack with channel_not_found / not_in_channel.

check_validity() rules

One pre-flight check:

#![allow(unused)]
fn main() {
if posts.iter().any(|post| post.iter().any(|item| item.path.contains("mp4"))) {
    return Ok(Some("No video support for Slack, only images".into()));
}
}
  • Only lowercase mp4 is caught. A .MOV or .WebM file passes validation and is then sent as a Block Kit image block, where Slack cannot render it.
  • There is no check for the number of images/blocks, public URL reachability, file type (PNG/JPG/JPEG/GIF only), or message length.

Media requirements

publish() builds:

{
  "channel": "C012AB3CD",
  "username": "<integration.name>",
  "icon_url": "<integration.picture>",
  "blocks": [
    { "type": "section", "text": { "type": "mrkdwn", "text": "<message>" } },
    { "type": "image", "image_url": "<media path>", "alt_text": "" },
    ...
  ],
  "thread_ts": "<only for comments>"
}

and calls POST /api/chat.postMessage with a Bearer bot token.

RequirementDetail
Image hostimage_url must be publicly reachable by Slack’s servers. A local upload path or a URL behind authentication renders as a broken image.
Image formatsSlack supports PNG, JPG/JPEG and GIF. WebP, SVG, BMP and TIFF are not supported by image blocks.
URL lengthimage_url maximum is 3000 characters.
alt_textRequired by Slack; Rustiz sends an empty string. It is accepted but poor for accessibility.
Blocks per messageMaximum 50 blocks. Rustiz emits one section block plus one image block per media item, so more than 49 images exceeds the block limit.
VideoNot supported. The early check only catches lowercase .mp4; other video containers slip through.
TextThe entire message is put in a single section block, whose text maximum is 3000 characters.

The provider never sends a top-level text fallback alongside blocks, so screen readers and notification previews rely on the section block. Slack’s accessibility guidance recommends a top-level fallback; this port does not provide one.

Threads and comments

supports_comments = true.

  • comment() computes thread_ts = last_comment_id.unwrap_or(post_id).
  • For the first reply, last_comment_id is None, so post_id (the original message ts) becomes the thread parent.
  • For later replies, last_comment_id is the previous comment’s post_id, which is also a valid Slack message ts. Slack accepts any message in the thread as thread_ts, so a multi-entry post forms a normal Slack thread.
  • Each reply sends the same Block Kit structure and its own media.
  • releaseURL comes from chat.getPermalink for each message.

Plugs

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

Implementation Notes & Gotchas

  • max_length is misleading. It returns 400000, but the message is always sent inside a Block Kit section block, whose text limit is 3000 characters. A 3500-character post passes Rustiz validation and is rejected by Slack with invalid_blocks / msg_blocks_too_long. If you need longer content, use a snippet/file upload, which this provider does not implement.
  • chat:write.customize is required, despite the docs. The publish payload always includes username (integration.name) and, when present, icon_url. Slack only permits those overrides with chat:write.customize. Rustiz checks the scope at connect, so an app built from Postiz’s five-scope list cannot connect at all.
  • Redirect tunnel is a substring check. frontend.contains("https") is not a scheme check. A URL like http://my-https-proxy.local is treated as HTTPS and sent directly to Slack, which then rejects it because the scheme is http. Keep FRONTEND_URL either a real https:// URL or a plain http://localhost:… URL.
  • Local redirect URL. For FRONTEND_URL=http://localhost:5000, register https://redirectmeto.com/http://localhost:5000/integrations/social/slack in Slack. Registering the plain http://localhost:5000/... URL does not match the URI Rustiz sends.
  • Reconnect ?refresh= mismatch. As described above, the authorize step omits ?refresh, the token exchange adds it. Slack requires the same value in both steps. If reconnecting an existing Slack channel fails with bad_redirect_uri, start a new connection instead of using the refresh flow.
  • conversations.join failure is ignored. post() calls POST /api/conversations.join and discards the response, then posts. For a public channel where the bot is not a member, channels:join usually fixes it. For a private channel, the bot must be manually invited; channels:join cannot join private channels. The subsequent chat.postMessage then fails with not_in_channel.
  • Permalink failure after a successful post can duplicate. publish() sends the message, then calls chat.getPermalink. If that second call fails (for example a transient 5xx after retries), the engine sees a failed post and may retry the whole publish, creating a duplicate message. There is no idempotency key. Check the channel before retrying a failed Slack post.
  • Channel list is one page. channels() calls conversations.list?types=public_channel,private_channel with no limit and no cursor. Slack’s default limit is 100; larger workspaces only see the first page. Archived channels are included (exclude_archived defaults to false), and posting to an archived channel fails.
  • Only text and images. chat.postMessage accepts files, but this provider only emits image blocks. PDFs, videos, audio and arbitrary documents are not supported.
  • Image URLs must be public. Rustiz does not upload media to Slack. If your media store requires signed URLs, they must remain valid long enough for Slack’s image proxy to fetch them; a short-lived URL can render as a broken image even though the message posts successfully.
  • alt_text is empty. Slack requires the field but Rustiz sets "". The message posts, but accessibility and some clients’ fallback rendering suffer.
  • Token model. Slack bot tokens do not expire by default. authenticate() stores refresh_token: Some("null") (the literal string) and expires_in: Some(100 * 365 * 24 * 3600). refresh_token() returns an empty access token and expires_in: 1_000_000; it never calls Slack. A revoked token cannot be repaired automatically: the channel is flagged for reconnection.
  • Token rotation is incompatible. Enabling Slack token rotation makes the bot token expire. Rustiz’s refresh is a no-op, so the channel breaks. Leave rotation off, or implement a real refresh before enabling it.
  • Error handling is ok:false aware. Slack returns HTTP 200 with {"ok":false,"error":"…"} for most failures. check_api_error() maps invalid_auth, token_revoked, token_expired and account_inactive to a refresh error; ratelimited becomes a plain error; everything else becomes BadBody. A 200 response is not retried by the shared HTTP layer, so a ratelimited body on a 200 is not retried.
  • Rate limits. Slack’s chat.postMessage allows about 1 message per second per channel, with a workspace-wide limit of several hundred messages per minute. max_concurrent_job = 10 can put multiple posts into the same channel at once; the 1/sec channel bucket still applies. Slack returns HTTP 429 with Retry-After, which the shared HTTP layer retries.
  • Enterprise Grid org-wide installs are not handled. The code reads the workspace id from token["team"]["id"] only. Slack returns team: null for an org-level installation (is_enterprise_install: true) and puts the id in the enterprise object. Rustiz does not read enterprise.id, so the integration’s internal_id can be empty. Install the app per workspace unless you have tested the org-wide flow.
  • change_nickname() and change_profile_picture() are no-ops. They return the input string without calling Slack. A Slack bot’s display name and icon are managed in the Slack app settings, not per integration.
  • No analytics, mentions or plugs. Slack has no analytics methods, no post analytics, supports_mentions = false, and no plug hooks in this port.
  • SLACK_BASE_URL affects release URLs. chat.getPermalink returns a Slack-hosted URL, so release URLs stay correct even with a mock base; message IDs and channel ids are real Slack values.

References