Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,42 @@
# ─── Chat platform ───────────────────────────────────────────────────────────
# Squawk drives one chat platform per deployment. Leave PLATFORM unset to infer
# it from whichever bot token you fill in below; set it explicitly if both are
# present. Values: discord | slack
# PLATFORM=discord

# ─── Discord (PLATFORM=discord) ──────────────────────────────────────────────
DISCORD_TOKEN=
DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID=
# Legacy single-monitor config (defaults to Statuspage.io; use /monitor add for incident.io or Instatus pages):
# Legacy single-monitor config (defaults to Statuspage.io; use /monitor add for
# incident.io or Instatus pages):
DISCORD_CHANNEL_ID=

# ─── Slack (PLATFORM=slack) ──────────────────────────────────────────────────
# See docs/wiki/Slack-Setup.md for the app manifest and required scopes.
SLACK_BOT_TOKEN= # xoxb-… bot token
SLACK_APP_TOKEN= # xapp-… app-level token with connections:write
SLACK_CHANNEL_ID= # Legacy single-monitor config, e.g. C0123ABCDEF
# Slash command name, without the leading slash. Slack command names are unique
# per workspace — change this if /squawk is already taken.
SLACK_COMMAND_NAME=squawk
# Slack has no per-command permission model. When set, only these user IDs may
# run testpost/replay/clean/cleanup/monitor. Comma-separated, e.g. U123,U456.
SLACK_ADMIN_USER_IDS=

# ─── Monitors ────────────────────────────────────────────────────────────────
STATUSPAGE_BASE_URL=https://status.atlassian.com
# Multi-monitor config overrides the two fields above when set. Accepts Statuspage.io,
# incident.io, and Instatus URLs. Optional per-entry "provider" field: "statuspage" (default),
# "incidentio", or "instatus".
# Multi-monitor config overrides the single-monitor fields above when set.
# Accepts Statuspage.io, incident.io, and Instatus URLs. `channelId` is a
# channel on whichever platform is active — a Discord snowflake, or a Slack
# channel ID like C0123ABCDEF. Optional per-entry "provider" field:
# "statuspage" (default), "incidentio", or "instatus".
# MONITORS_JSON=[{"id":"atlassian","channelId":"123456789012345678","baseUrl":"https://status.atlassian.com","label":"Atlassian"},{"id":"openai","channelId":"234567890123456789","baseUrl":"https://status.openai.com","label":"OpenAI","provider":"incidentio"}]
# Legacy alias `STATUSPAGE_MONITORS_JSON` is still honored for backwards
# compatibility but emits a deprecation warning at startup; prefer MONITORS_JSON.
MONITORS_JSON=

# ─── Behavior ────────────────────────────────────────────────────────────────
POLL_INTERVAL_MS=180000
POST_EXISTING_UPDATES_ON_START=false
ENABLE_STATUS_COMMAND=true
Expand Down
73 changes: 54 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,32 @@ Instructions for AI coding agents working on this project. **All agents MUST rea

## Project Overview

Squawk is a Bun-based Discord bot that polls public status pages (Statuspage.io, incident.io, and Instatus are supported) and posts incident updates as threaded conversations in Discord. It supports multiple monitors, runtime monitor management, and persistent state.
Squawk is a Bun-based bot that polls public status pages (Statuspage.io, incident.io, and Instatus are supported) and posts incident updates as threaded conversations in **Discord or Slack**. It supports multiple monitors, runtime monitor management, and persistent state.

One deployment drives **one** chat platform, selected by `PLATFORM` (or inferred from whichever bot token is set). State stores that platform's opaque message/thread handles, so a single instance cannot serve both.

The repo was previously named `statuspage-discord`. The legacy `STATUSPAGE_MONITORS_JSON` env var is still honored as a deprecated alias for `MONITORS_JSON`.

## Tech Stack

- **Runtime:** Bun
- **Language:** TypeScript (strict mode)
- **Dependencies:** discord.js, zod
- **Dependencies:** discord.js, @slack/web-api, @slack/socket-mode, zod
- **Deployment:** Docker (Alpine-based), Docker Compose, GHCR

## Project Structure

```
src/index.ts # All bot logic (~2100 lines, single file)
src/index.ts # Entry point: resolve platform, load monitors, poll loop
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 # Platform-neutral Embed builders + TextFormat interface
src/core.ts # Incident lifecycle, polling, all command handlers
src/platform/ # Chat platform adapters (one file per platform)
types.ts # ChatPlatform interface, PlatformMessage, capabilities
discord.ts # discord.js adapter
slack.ts # Slack adapter (Socket Mode + Block Kit)
src/providers/ # Per-provider API adapters (one file per provider)
types.ts # Canonical Incident/Summary/PageStatus + Provider interface
index.ts # Provider registry + detectProvider()
Expand All @@ -40,6 +51,7 @@ docs/wiki/ # Wiki source of truth (published by .github/workflows
Incident-Lifecycle.md # How incidents are tracked and displayed
State-Management.md # Persistence format and behavior
API-Integration.md # Status page provider APIs (Statuspage, incident.io, Instatus)
Slack-Setup.md # Slack app manifest, scopes, tokens, Discord/Slack differences
Deployment.md # Docker, CI/CD, production notes
Development.md # Local setup and contribution guide
```
Expand Down Expand Up @@ -67,6 +79,7 @@ docker compose up -d # Docker deployment

| Change | Update |
|--------|--------|
| New/changed chat platform behavior | `Slack-Setup.md`, `Commands.md`, `Incident-Lifecycle.md`, `Architecture.md` |
| New/changed env variable | `README.md`, `Configuration.md`, `.env.example`, `AGENTS.md` (if structural) |
| New/changed command | `README.md`, `Commands.md`, `Development.md` (adding a command guide) |
| Incident lifecycle change | `Incident-Lifecycle.md`, `Architecture.md` |
Expand All @@ -78,21 +91,37 @@ docker compose up -d # Docker deployment

## Key Patterns

### Single-File Architecture
All bot logic is in `src/index.ts`. Functions are ordered by dependency (callees above callers). Don't split into modules unless the file exceeds ~3000 lines. The single-file rule does **not** apply to `src/providers/` — each provider adapter lives in its own small file so adding new providers is trivial.
### Two Adapter Seams
Squawk has two interfaces, and everything else is written once against them:

- `src/providers/` — status page vendors, behind `Provider`
- `src/platform/` — chat platforms, behind `ChatPlatform`

`src/core.ts` holds the whole incident lifecycle and every command handler, and imports neither `discord.js` nor `@slack/*`. Functions are ordered by dependency (callees above callers). Keep new lifecycle logic in `core.ts`; only genuinely platform-specific mechanics belong in an adapter.

### Adding a New Chat Platform

1. Create `src/platform/<name>.ts` exporting a class implementing `ChatPlatform` (see `src/platform/types.ts`).
2. Supply a `TextFormat` for the platform's inline markup, and map the neutral `Embed` from `src/render.ts` onto its native rich-message format.
3. Set `capabilities` honestly — the core skips optional work (thread archiving, pin-notice pruning, presence, autocomplete) rather than branching on platform identity.
4. Translate "this resource is gone" errors into `null`/`false` returns so the core prunes state without knowing any platform error codes. Everything else should throw.
5. Add the ID to `PlatformId` in `src/config.ts`, wire it into `createPlatform()` in `src/index.ts`, and document setup in `docs/wiki/`.

### Platform-Neutral Rendering
`render.ts` builds a structural `Embed` and produces inline markup through the active platform's `TextFormat`. Text that comes from a status page must be wrapped in `fmt.escape()`; markup Squawk generates itself must not be. Never hardcode `**bold**` or `~~strike~~` in a render function — Slack uses `*bold*` and `~strike~`.

### Adding a New Provider

1. Create `src/providers/<name>.ts` exporting a `Provider` object (see `src/providers/types.ts` for the interface). Implement `probe`, `fetchSummary`, and `fetchIncidents` so they return the canonical `Incident`/`Summary` shapes.
2. Register it in `src/providers/index.ts`: add to the `PROVIDERS` record, insert into `PROBE_ORDER` (more specific providers first — a provider whose probe might false-positive belongs later in the order).
3. Add its ID to the `provider` enum on `monitorSchema` in `src/index.ts`.
3. Add its ID to the `provider` enum on `monitorSchema` in `src/config.ts`.
4. Update `docs/wiki/API-Integration.md` with the endpoints and any quirks.

No changes to polling, rendering, state, or thread lifecycle should be required — every provider normalizes into the canonical types.

### Error Handling
- Check for the specific `DiscordAPIError` codes before cleaning up state: 10003 (Unknown Channel), 10008 (Unknown Message), 50001 (Missing Access)
- Never catch-all delete state on generic errors — only on confirmed missing Discord resources
- Platform adapters translate "resource is gone" errors into `null`/`false` returns (Discord codes 10003, 10008, 50001, 50013, 50035; Slack `channel_not_found`, `message_not_found`, `thread_not_found`). The core prunes state on `null` and never inspects error codes itself.
- Never catch-all delete state on generic errors — only on confirmed missing platform resources
- Status page adapters throw on non-2xx with the status code and body. There is no retry helper: the poll loop catches per monitor and retries on the next cycle.
- The poll loop is wrapped in `singleFlight()` so cycles never overlap and duplicate threads
- Thread archive/unarchive failures are logged but non-fatal
Expand All @@ -104,10 +133,11 @@ No changes to polling, rendering, state, or thread lifecycle should be required
- Runtime monitors use a promise-chain lock for safe concurrent writes

### Embed Rendering
- All embeds are built by `render*()` functions
- All embeds are built by `render*()` functions in `src/render.ts`, returning the neutral `Embed` type
- Color is derived from impact/status using `impactColor()` and `statusColor()`
- Removed/ghosted incidents use `MISSING_INCIDENT_COLOR` (grey) with strikethrough text
- Favicons are cached at startup in the `monitorIcons` Map
- Favicons are cached at startup in the `monitorIcons` Map (`src/icons.ts`)
- Adapters convert `Embed` to a discord.js `EmbedBuilder` or a Slack Block Kit attachment

### Incident Lifecycle
- New incident → parent embed + thread + pin
Expand All @@ -116,19 +146,24 @@ No changes to polling, rendering, state, or thread lifecycle should be required
- Vanished from API → ghost (grey + strikethrough) + archive thread

### Command Pattern
Every command handler follows:
Handlers in `core.ts` take a neutral `CommandContext` and follow:
1. Check feature flag
2. `deferReply({ flags: MessageFlags.Ephemeral })`
3. Resolve monitor target
4. Assert channel access
5. Perform action
6. `editReply()` with result
2. Resolve monitor target
3. Assert channel access
4. Perform action
5. `context.reply()` with the result

The adapter owns the platform's response mechanics: Discord defers ephemerally before dispatch and replies via `editReply`; Slack acks within 3 seconds and replies through `response_url`.

Discord registers typed slash commands over the API. Slack command names are unique per workspace and are manifest-declared, so all commands are subcommands of a single command (`SLACK_COMMAND_NAME`, default `squawk`) parsed by `parseCommandText()`. A new command must be added to both adapters and to `buildHelpText()`.

## Environment Variables

See `.env.example` for the full list. Key ones:
- `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID` (required)
- `MONITORS_JSON` or `DISCORD_CHANNEL_ID` + `STATUSPAGE_BASE_URL` (legacy `STATUSPAGE_MONITORS_JSON` still honored with deprecation warning)
See `.env.example` for the full list. Blank values are treated as unset, so placeholder lines in `.env` are safe. Key ones:
- `PLATFORM` — `discord` or `slack`; inferred from the configured bot token when omitted
- `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID` (required for Discord)
- `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` (required for Slack), plus `SLACK_COMMAND_NAME` and `SLACK_ADMIN_USER_IDS`
- `MONITORS_JSON` or `DISCORD_CHANNEL_ID`/`SLACK_CHANNEL_ID` + `STATUSPAGE_BASE_URL` (legacy `STATUSPAGE_MONITORS_JSON` still honored with deprecation warning)
- `POLL_INTERVAL_MS` (default 60000)
- `ENABLE_*_COMMAND` feature flags (all default true, includes `ENABLE_CLEANUP_COMMAND`)
- `APP_VERSION` (optional, auto-set in Docker builds via build arg, falls back to `package.json` version)
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Squawk

A Bun-based Discord bot that:
A Bun-based bot for **Discord or Slack** that:

- polls one or more public status pages (Statuspage.io, incident.io, and Instatus are supported) and groups each incident into its own Discord thread
- polls one or more public status pages (Statuspage.io, incident.io, and Instatus are supported) and groups each incident into its own thread
- answers slash-command status questions with the current page health
- supports replay and preview flows so you can test notifications without waiting for a live incident

One deployment drives one platform. Set `PLATFORM=discord` or `PLATFORM=slack` — or just fill in one platform's tokens and Squawk infers it. Every feature, command, and lifecycle behavior is the same on both; see [Slack Setup](https://github.com/anthonybaldwin/squawk/wiki/Slack-Setup) for the handful of things Slack has no equivalent for.

Supported providers are auto-detected at `/monitor add` time — drop in any public Statuspage.io URL (e.g. `https://status.atlassian.com`), incident.io URL (e.g. `https://status.openai.com`), or Instatus URL (e.g. `https://status.perplexity.com`) and the bot picks the right adapter.

<p align="center">
Expand All @@ -15,11 +17,14 @@ Supported providers are auto-detected at `/monitor add` time — drop in any pub
## Quick Start

```bash
cp .env.example .env # Fill in DISCORD_TOKEN, DISCORD_APPLICATION_ID, etc.
cp .env.example .env # Discord: DISCORD_TOKEN + DISCORD_APPLICATION_ID
# Slack: SLACK_BOT_TOKEN + SLACK_APP_TOKEN
bun install
bun dev # Watch mode (or `bun start` for production)
```

Slack apps are created from a manifest — copy the one in [Slack Setup](https://github.com/anthonybaldwin/squawk/wiki/Slack-Setup).

## Docker

```bash
Expand All @@ -37,6 +42,7 @@ Full docs live in the [wiki](https://github.com/anthonybaldwin/squawk/wiki):
| [Architecture](https://github.com/anthonybaldwin/squawk/wiki/Architecture) | System design, data flow, and module structure |
| [Configuration](https://github.com/anthonybaldwin/squawk/wiki/Configuration) | Environment variables, multi-monitor setup, feature flags |
| [Commands](https://github.com/anthonybaldwin/squawk/wiki/Commands) | All slash commands with usage and permissions |
| [Slack Setup](https://github.com/anthonybaldwin/squawk/wiki/Slack-Setup) | Slack app manifest, scopes, tokens, and Discord/Slack differences |
| [Incident Lifecycle](https://github.com/anthonybaldwin/squawk/wiki/Incident-Lifecycle) | How incidents are tracked from creation to resolution or removal |
| [State Management](https://github.com/anthonybaldwin/squawk/wiki/State-Management) | Persistence format, migration, and locking |
| [API Integration](https://github.com/anthonybaldwin/squawk/wiki/API-Integration) | Supported providers, endpoints, and how to add a new provider |
Expand All @@ -47,9 +53,10 @@ Full docs live in the [wiki](https://github.com/anthonybaldwin/squawk/wiki):
## Notes

- The bot uses public APIs only — Statuspage.io's v2 API (`<base-url>/api/v2/...`), incident.io's widget proxy (`<base-url>/proxy/<host>`), or Instatus's v3 JSON API + Atom history feed (`<base-url>/v3/summary.json`, `<base-url>/history.atom`) — so a public page URL is all you need.
- For development, setting `DISCORD_GUILD_ID` makes slash-command registration update faster than global commands.
- For development on Discord, setting `DISCORD_GUILD_ID` makes slash-command registration update faster than global commands.
- On first startup, the bot seeds current incident-update IDs without posting them unless `POST_EXISTING_UPDATES_ON_START=true`.
- The bot needs Send Messages, Embed Links, Create Public Threads, and Manage Messages permissions.
- On Discord the bot needs Send Messages, Embed Links, Create Public Threads, and Manage Messages. On Slack it connects over Socket Mode, so no public endpoint is needed — just the bot and app-level tokens.
- Slack command names are unique per workspace, so the six commands are subcommands of a single `/squawk` (rename it with `SLACK_COMMAND_NAME`).

## Previously known as

Expand Down
Loading