Skip to content

Slack support alongside Discord - #23

Draft
anthonybaldwin wants to merge 6 commits into
mainfrom
claude/slack-bot-integration-ft7csx
Draft

anthonybaldwin wants to merge 6 commits into
mainfrom
claude/slack-bot-integration-ft7csx

Conversation

@anthonybaldwin

@anthonybaldwin anthonybaldwin commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Squawk can now run on Slack instead of Discord, with the same features, commands, and incident lifecycle. One deployment drives one platform — PLATFORM=discord or PLATFORM=slack, inferred from whichever bot token is configured.

Why the refactor came first

All bot logic lived in src/index.ts bound directly to discord.js, so nothing could be reused. Rather than write a second copy that would drift, the lifecycle was made platform-neutral and Discord moved behind an adapter:

File Purpose
src/config.ts Env + monitor schemas, platform resolution, monitors.json I/O
src/state.ts data/state.json read/write + legacy migration
src/icons.ts Favicon discovery and caching
src/render.ts Neutral Embed builders + the TextFormat markup interface
src/core.ts Incident lifecycle, polling, all command handlers
src/platform/types.ts The ChatPlatform seam
src/platform/discord.ts discord.js adapter
src/platform/slack.ts Slack adapter (Socket Mode + Block Kit)

src/core.ts imports neither discord.js nor @slack/*. Two conventions keep platform quirks out of it:

  • null means "gone". Adapters translate Discord's 10003/10008/50001/50013/50035 and Slack's channel_not_found/message_not_found into null returns; the core prunes state without knowing any error codes. Everything else throws.
  • Capabilities gate optional work. threadArchive, pinNotices, deletableThreads, presence, autocomplete, maxMessageDeleteAgeMs. Slack turns the first five off and the core skips that work rather than branching on platform identity.

Rendering goes through one set of render*() functions that emit a structural Embed plus inline markup via the platform's TextFormat — Discord markdown and its t: timestamp tags, or Slack mrkdwn and its !date^ tags. Status-page text is passed through fmt.escape() so Slack's reserved &, <, > characters render literally; markup Squawk generates itself is not escaped.

The Slack side

Socket Mode, so Slack needs no public HTTP endpoint and deploys exactly like the Discord bot. Embeds become Block Kit attachments (the attachment color supplies the same accent bar), threads are thread_ts replies, and update IDs travel as Slack message metadata instead of being scraped back out of a rendered embed.

Two places where Slack has no equivalent and a decision was needed:

  • Command names are unique per workspace and can't be registered over an API. All six commands are subcommands of one manifest-declared command — /squawk status, /squawk monitor add … — renameable via SLACK_COMMAND_NAME. Options are positional or key=value. Since Slack has no autocomplete, /squawk help lists whatever is enabled.
  • Slack has no per-command permission model. Discord gates the destructive commands behind Manage Server; Slack has nothing comparable, so SLACK_ADMIN_USER_IDS restricts testpost/replay/clean/cleanup/monitor to named users. Left unset, any workspace member can run them — worth a look, since it's the one place the Slack default is more permissive than Discord's.

What Slack simply doesn't have: thread archiving, pin system notices, bot presence, and command autocomplete. Those are capability-flagged off, documented, and skipped.

Also fixed

  • An empty DISCORD_GUILD_ID= — exactly what .env.example ships and what docker compose env_file produces — crashed startup, because a .env exports every key it lists and "" failed its optional non-empty string. Blank env values are now treated as unset. Pre-existing, but the new template adds more optional-and-usually-empty vars, so it would have gotten worse.
  • A single-message bulkDelete on Discord only reported success when the message happened to be cached, so /clean could report zero deletions and leave state pointing at a message it had just removed.

Verification

  • bun run typecheck and bun test pass — 76 tests, up from 29.
  • src/core.test.ts is the regression net for the refactor: it runs the full lifecycle (parent + thread + pin → follow-up update → resolve → ghost → self-heal after manual deletion) against an in-memory ChatPlatform, so Discord's behavior is pinned down by tests rather than by inspection.
  • New unit tests cover the Slack command parser, Block Kit conversion, per-platform escaping/markup, and platform resolution.
  • bun install --frozen-lockfile --production (what the Dockerfile runs) verified with the new dependencies.
  • Not verified: any live connection. The sandbox this was built in blocks egress to discord.com and slack.com, so neither adapter has talked to a real API. The Discord path was confirmed to reach the REST call before the proxy refused it. The Slack adapter wants a real workspace smoke test before this leaves draft — particularly pin/unpin, the single-message conversations.replies fetches, and ghosting an incident.

Note that this repo has no CI workflow running bun test or bun run typecheck — the only workflows are the Docker build, lockfile sync, and wiki sync. Nothing gates this PR on the test suite. Adding one felt outside the scope of this change, but it's worth doing.

Docs

docs/wiki/Slack-Setup.md is new (app manifest, scopes, both tokens, admin gating, and a table of Discord/Slack differences). Architecture, Configuration, Commands, Incident-Lifecycle, State-Management, Deployment, Development, Home, README, AGENTS.md, and .env.example were updated per the documentation rules in AGENTS.md.

Compatibility

No migration needed for existing Discord deployments — same env vars, same state format, same commands. data/state.json stores one platform's opaque handles, so switching platforms (or running both) means a separate instance and data volume; that's called out in Deployment and State-Management.

claude added 6 commits August 31, 2026 15:34
Squawk drives one chat platform per deployment. PLATFORM=discord or
PLATFORM=slack picks it (inferred from whichever bot token is set), and
every feature — polling, threaded incidents, pinning, ghosting, and all
six commands — works the same on either.

All bot logic previously lived in src/index.ts bound directly to
discord.js, so the lifecycle first had to stop knowing which chat service
it talks to:

- config/state/icons/render: env, persistence, favicon caching and
  rendering, none of them Discord-aware. render emits a structural Embed
  plus inline markup through a per-platform TextFormat, so Discord
  markdown and Slack mrkdwn come out of one set of render functions.
- core: incident lifecycle and command handlers, driven only through
  platform/types.ts. Platform errors meaning "this is gone" arrive as
  null returns, so the core prunes state without knowing any error codes.
- platform/discord.ts: the existing behavior, unchanged, behind the seam.
- platform/slack.ts: Socket Mode, so Slack needs no public HTTP endpoint
  and deploys exactly like the Discord bot. Embeds become Block Kit
  attachments; threads are thread_ts replies; update IDs travel as
  message metadata. Thread archiving, pin notices and bot presence do not
  exist on Slack, and the core skips them via capability flags.

Slack command names are unique per workspace and cannot be registered
over an API, so the six slash commands become subcommands of a single
manifest-declared command (/squawk by default, SLACK_COMMAND_NAME).
Slack also has no per-command permission model, so SLACK_ADMIN_USER_IDS
gates the destructive subcommands the way Manage Server does on Discord.

core.test.ts exercises the full lifecycle against an in-memory platform
to hold the Discord behavior unchanged through the split.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
Adds docs/wiki/Slack-Setup.md (app manifest, scopes, both tokens, admin
gating, and a table of what Slack has no equivalent for) and reworks the
existing pages that assumed Discord: Architecture's module map, the
Configuration variable tables, per-command Slack syntax, the
platform-scoped state file, and the two-instance deployment note.

Also fixes a startup crash the new template would have made worse: a
`.env` exports every key it lists, so a placeholder `DISCORD_GUILD_ID=`
arrived as "" and failed its optional non-empty string. Blank values are
now treated as unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
discord.js routes a one-ID bulkDelete to a plain delete but returns the
message only when it is in cache, so /clean could report zero deletions
and leave state pointing at a message it had just removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
Slack fails a whole chat.postMessage with invalid_blocks when an image
element's URL is malformed, so a status page with an odd favicon (a
data: URI, a protocol-relative href) would take down every incident post
for that monitor. Discord just omits an icon it cannot render; match
that by keeping only plain http(s) URLs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
Slack reports a private channel the app was never invited to as
channel_not_found, indistinguishable from a wrong ID, so the old message
sent people to re-check a channel ID that was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
Resolves the wiki conflicts with #24, which corrected stale content
across the same pages this branch rewrites. Kept #24's fixes:

- The mermaid data-flow diagram (<br/> rather than \n in quoted labels,
  and a quoted "normalized Incident[]" edge label so the brackets are
  not parsed as a node) — this branch had reintroduced both faults.
- Presence rotation lists three activities, not four.
- AGENTS.md no longer claims a retryWithBackoff helper that does not
  exist; status page adapters throw and the poll loop retries next cycle.
- The generic Dockerfile snippet with its APP_VERSION build arg.

Carried the branch's own layout forward where #24 still described the
single file, and repointed the references #24 added that the split
invalidated: the monitorSchema provider enum now lives in config.ts and
the color maps in render.ts, and Contributing's single-file section is
now the two adapter seams.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLa4zi5Jmyb6HrGCTme2m4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants