slack
What it is
Imitates the two surfaces a Slack SDK, or any HTTP client, uses to post a
message: incoming webhooks
and the Web API's chat.postMessage.
Both convert into tommy's canonical chat.Message; Block Kit blocks and
legacy attachments are stored verbatim so a card renderer never has to
re-derive them.
What it's for
Pointing whatever your code already uses to talk to Slack — the official SDK,
a bare http.Post to a webhook URL, an alerting library — at tommy instead of
a real workspace. The webhook path is the one most CI jobs are already wired
for (an app posts a JSON body to a fixed URL, no auth), so swapping the
webhook host is usually the entire change. Useful for the same three cases
the plugin README describes: watching a deploy notification render without
spamming a channel, asserting in CI that a job posted the right message, and
iterating on a Block Kit layout with no workspace at all.
How to test it for real
TOMMY_NO_UPDATE_CHECK=1 go run . chat --ui-port 18931 --in-port 18932 --enabled-providers slack
An incoming webhook, plain text:
curl -s http://localhost:18932/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX \
-H 'Content-Type: application/json' \
-d '{"text":"It works.","channel":"#general","username":"deploy-bot"}'
returned ok (text/plain, exactly what a real webhook returns). The same
webhook with Block Kit blocks:
curl -s http://localhost:18932/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX \
-H 'Content-Type: application/json' \
-d '{"channel":"#alerts","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*It works.*"}}]}'
also returned ok. The Web API method instead of a webhook:
curl -s http://localhost:18932/api/chat.postMessage \
-H 'Authorization: Bearer xoxb-fake-token' \
-H 'Content-Type: application/json' \
-d '{"channel":"C0123ABCD","text":"It works."}'
returned {"channel":"C0123ABCD","message":{"type":"message","text":"It works.","bot_id":"B…","ts":"…"},"ok":true,"ts":"…"}.
Read every message back:
curl -s http://localhost:18931/api/v1/chat/channels | jq
curl -s http://localhost:18931/api/v1/chat/messages | jq '.[0].message'
or open the tab at http://localhost:18931/ui/chat/.
Routes
| Route | Notes |
|---|---|
POST /services/{team}/{bot}/{token} |
Incoming webhook. Body is JSON: text, blocks, attachments, channel, username, icon_url, icon_emoji, thread_ts, unfurl_links, unfurl_media, mrkdwn. Responds with the literal text ok as text/plain — not JSON. On error, a plain-text error code (invalid_payload, no_text) with a real HTTP status, matching Slack's own "webhooks give more expressive errors than the Web API" behavior. |
POST /api/chat.postMessage |
Web API. Accepts either application/json or application/x-www-form-urlencoded (Slack documents both). channel is required; text, blocks, attachments, thread_ts, username, icon_url, icon_emoji, mrkdwn, unfurl_links, unfurl_media, as_user, reply_broadcast are optional. Bearer token via the Authorization header (preferred) or a token field in the body. Always responds HTTP 200, {"ok":true,"channel":"C…","ts":"…","message":{…}} on success or {"ok":false,"error":"…"} on failure — Slack's Web API does not use HTTP status to signal an application-level error. |
/api/v1/ is reserved for tommy's own API, but /api/chat.postMessage does
not fall under that prefix, so it is legal for this provider to mount.
Not implemented: chat.update / chat.delete
core/store events are immutable once appended and the chat plugin has no
edit/delete resource type or relation for "this message was later changed" —
by design, since threads are already derived rather than stored (see
plugins/chat/thread.go). Faking either endpoint would mean returning a
{"ok":true} envelope that does not actually change what the store or the
chat tab show: exactly the "shaky" result the provider checklist warns
against. Two correct routes beat four half-real ones.
Wire shapes, verified against the live docs
- Incoming webhook success — HTTP 200,
Content-Type: text/plain, body is exactlyok. This is the single easiest thing to get wrong about this surface. - Incoming webhook payload —
text,blocks(Block Kit, verbatim),attachments(legacy, verbatim),thread_ts. Slack's current-generation apps rejectchannel/username/icon_url/icon_emojioverrides on this surface, but the classic "custom integration" webhooks Slack ran for years accepted them, and plenty of real-world payloads still send them — this provider accepts and records all of them rather than silently dropping fields a test fixture might depend on. chat.postMessagesuccess —{"ok":true,"channel":"C123ABC456","ts":"1503435956.000247","message":{"type":"message","text":"…","ts":"…","bot_id":"B…", ...}}.blocks/attachmentson the form-encoded path arrive as a JSON-encoded string (Slack's own documented shape for a structured argument over a form); this provider decodes the percent-encoding and stores the resulting bytes verbatim, exactly as it would a JSON body's ownblocksarray.chat.postMessageerrors —{"ok":false,"error":"<code>"}, HTTP 200 even fornot_authed,invalid_authandchannel_not_found. Slack's own method reference never documents a different status forok:false;429only shows up for actual rate limiting, which this fake does not implement.
Converting into the canonical model
Channel.IDis theC…id when the caller gave one, the#nameoverride when it gave that instead, and — only for the webhook, which never carries a channel id at all since Slack binds the destination channel to the webhook URL at install time —webhook:{team}/{bot}, so every post through the same webhook lands in the same derived channel.Author.Botis alwaystrue: both an incoming webhook and achat.postMessagecall post as a bot, never as a human.TSis left empty for a webhook post (Slack never hands one back to the poster, so message identity falls back to the event id, exactly asmessage.godocuments) and minted forchat.postMessagein Slack's ownseconds.ffffffshape, deterministically from the event id and the clock so the same fakeDepsalways mints the same ts.ThreadTSisthread_tspassed through untouched on both surfaces.- Everything Slack-specific —
team/bot/webhook_tokenfrom the path, the presented bearer token,icon_emoji,mrkdwn,unfurl_links/unfurl_media,as_user,reply_broadcast, the minted ts — lives inEvent.Meta, never in the canonicalMessage.
Auth
chat.postMessage accepts any bearer token (or none — see below) by default
and records exactly what was presented in Event.Meta, matching this repo's
"accept anything, record what was presented" convention (see the twilio
provider). Pin a bot_token in this provider's config section to start
rejecting a mismatch with Slack's own invalid_auth error; a request with no
token at all always gets not_authed:
[plugins.chat.providers.slack]
bot_token = "xoxb-fake-token"
Package tests
go test ./plugins/chat/providers/slack/...
go test -race ./plugins/chat/providers/slack/...
The package tests are table-driven against golden request fixtures in
testdata/ and assert both the exact HTTP response (status, Content-Type,
body) and the canonical chat.Message the provider actually stored: the
literal ok text/plain webhook response, JSON and form bodies on
chat.postMessage, blocks and attachments stored verbatim on both surfaces,
a threaded reply's thread_ts passthrough, the webhook's channel-id
fallback, auth capture (default-accept and pinned rejection), and every error
shape. plugintest.Conformance and a full testutil.Start end-to-end pass
(post through the ingress, read back through /api/v1/chat/) are included.