Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
DISCORD_TOKEN=
DISCORD_APPLICATION_ID=
DISCORD_GUILD_ID=
# Legacy single-monitor config (defaults to Statuspage.io; use /monitor add for incident.io pages):
# Legacy single-monitor config (defaults to Statuspage.io; use /monitor add for incident.io or Instatus pages):
DISCORD_CHANNEL_ID=
STATUSPAGE_BASE_URL=https://status.atlassian.com
# Multi-monitor config overrides the two fields above when set. Accepts Statuspage.io and
# incident.io URLs. Optional per-entry "provider" field: "statuspage" (default) or "incidentio".
# 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".
# 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.
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 and incident.io 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 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.

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

Expand All @@ -24,6 +24,7 @@ src/providers/ # Per-provider API adapters (one file per provider)
index.ts # Provider registry + detectProvider()
statuspage.ts # Statuspage.io adapter
incidentio.ts # incident.io adapter (uses /proxy/<host> widget API)
instatus.ts # Instatus adapter (v3 JSON API + Atom history feed)
data/state.json # Runtime state (git-ignored, auto-created)
data/monitors.json # Runtime monitors (git-ignored, auto-created)
AGENTS.md # Agent instructions (cross-tool)
Expand All @@ -37,7 +38,7 @@ docs/wiki/ # GitHub-style wiki documentation
Contributing.md # How to contribute and code conventions
Incident-Lifecycle.md # How incidents are tracked and displayed
State-Management.md # Persistence format and behavior
API-Integration.md # Statuspage API usage
API-Integration.md # Status page provider APIs (Statuspage, incident.io, Instatus)
Deployment.md # Docker, CI/CD, production notes
Development.md # Local setup and contribution guide
```
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

A Bun-based Discord bot that:

- polls one or more public status pages (Statuspage.io and incident.io 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 Discord 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

Supported providers are auto-detected at `/monitor add` time — drop in any public Statuspage.io URL (e.g. `https://status.atlassian.com`) or any public incident.io URL (e.g. `https://status.openai.com`) and the bot picks the right adapter.
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">
<img width="483" height="424" alt="Squawk" src="https://github.com/user-attachments/assets/8359f28f-53e3-4c42-aa7e-002e0c3c4593" />
Expand Down Expand Up @@ -46,7 +46,7 @@ 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/...`) or incident.io's widget proxy (`<base-url>/proxy/<host>`) — so a public page URL is all you need.
- 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.
- 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.
Expand Down
27 changes: 25 additions & 2 deletions docs/wiki/API-Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The bot supports multiple status page providers. Each provider lives in its own
|----------|----|-----------| ---------|
| Statuspage.io (Atlassian) | `statuspage` | `https://status.atlassian.com` | Public v2 API, no key required |
| incident.io | `incidentio` | `https://status.openai.com` | Public widget proxy, no key required |
| Instatus | `instatus` | `https://status.perplexity.com` | Public v3 JSON API + Atom history feed, no key required |

No API key is required for any supported provider — all endpoints are public.

Expand All @@ -19,6 +20,7 @@ Current probe order:

1. **incident.io** — probed first because many incident.io pages also expose a Statuspage-compatible `/api/v2/` shim, but the shim returns empty update bodies and a truncated history. Probing incident.io first ensures we use the richer native widget API when available.
2. **Statuspage.io** — fallback for pages that are not on incident.io.
3. **Instatus** — probed last. Its `/v3/summary.json` path does not collide with the earlier providers, and its probe rejects Statuspage-shaped summaries (see below).

Monitors loaded from `data/monitors.json` or `MONITORS_JSON` that pre-date multi-provider support default to `statuspage` for backwards compatibility.

Expand Down Expand Up @@ -87,9 +89,30 @@ https://status.openai.com/proxy/status.openai.com/incidents # full history
- **Update message bodies** use a nested rich-doc structure (`{ type: "doc", content: [{ type: "paragraph", content: [...] }] }`). The adapter's `flattenMessage()` walks this recursively and returns plain text with paragraph breaks.
- **Shortlinks** come from `incident.url` when present, otherwise constructed as `<public_url>/incident/<id>`.

### Instatus status (skipped)
## Instatus Adapter

Instatus (e.g. `https://status.kagi.com`) is **not** currently supported. Its only public endpoint is `/summary.json`, which returns flat active-incident metadata without message bodies or history. Adding it would require either synthesizing updates from polled state diffs (degraded fidelity) or scraping the HTML incident pages (fragile). The provider interface is designed to make dropping Instatus in later a one-file addition if a richer public API appears.
File: `src/providers/instatus.ts`

Instatus exposes a documented keyless JSON API plus a standard Atom history feed. The adapter joins them on the incident `id`: the JSON API gives live state and the impact enum, while the Atom feed gives the full update history including the operator's written prose.

| Endpoint | Used By | Purpose |
|----------|---------|---------|
| `<baseUrl>/v3/summary.json` | `probe()`, `fetchSummary()` | Page status + `activeIncidents[]` + `activeMaintenances[]` (current state, impact enum) |
| `<baseUrl>/history.atom` | `fetchIncidents()` | Atom feed of recent incidents and maintenances with full update prose (for polling, `/replay`) |

### Normalization details

- **Page status** comes from `page.status`: `UP` → operational, `UNDERMAINTENANCE` → maintenance, anything else (`HASISSUES`, other `HAS*`) → derived from the worst active impact.
- **Incident status** maps `INVESTIGATING`/`IDENTIFIED`/`MONITORING`/`RESOLVED` (and the title-case feed equivalents) onto the canonical set. Unknown words default to `investigating`.
- **Maintenance status** maps `NOTSTARTEDYET`/`Scheduled` → `scheduled`, `INPROGRESS`/`VERIFYING`/`Identified` → `in_progress`, `COMPLETED`/`Resolved` → `resolved`. Maintenances carry `impact: "maintenance"` (rendered grey).
- **Impact** maps `OPERATIONAL` → `none`, `MINOROUTAGE`/`DEGRADEDPERFORMANCE` → `minor`, `PARTIALOUTAGE` → `major`, `MAJOROUTAGE` → `critical`. The Atom feed carries no impact enum, so resolved/historical incidents default to `minor`; incidents still active in `summary.json` are stamped with their real impact by joining on `id`.
- **Update bodies** come from the Atom `<content>` HTML. Each `<p>` update block (`<small>timestamp</small><br><strong>Status</strong> - body`) is parsed into an `IncidentUpdate`; header `<p>` blocks (`<strong>Type:</strong> …`) are skipped. Update ids are `<incidentId>:<updateTimestampIso>` for dedup. Update blocks are sorted chronologically (the feed does not emit them in order).
- **Update timestamps** in the feed carry no year. The year is taken from the entry `<published>`, with a rollover guard: if the resulting date lands before `<published>` (beyond a ~24h grace window), it is rolled to the following year (incident spanning Dec→Jan).
- **`page.id`** is synthesized from the base URL host (Instatus summaries omit it).

### Probe order

Instatus is probed last (`PROBE_ORDER` is `[incidentio, statuspage, instatus]`). Its `/v3/summary.json` path does not collide with incident.io's `/proxy/<host>` or Statuspage's `/api/v2/summary.json`, and the probe additionally rejects Statuspage-shaped summaries (which carry `page.id` and a top-level `status` object).

## Favicon Fetching

Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ squawk is a Bun/TypeScript application with bot logic in `src/index.ts` (~1700 l

```mermaid
graph LR
A["Status page API\n(Statuspage.io or incident.io)"] -->|poll every 60s| P["Provider adapter"]
A["Status page API\n(Statuspage.io, incident.io, or Instatus)"] -->|poll every 60s| P["Provider adapter"]
P -->|normalized Incident[]| B["Bot"]
B -->|compare update IDs| C["State"]
B -->|new updates?| D["Discord API"]
Expand Down Expand Up @@ -63,7 +63,7 @@ The rotation reads state from disk each tick to get current incident counts, and
Bot logic lives in one file (`src/index.ts`) for simplicity — the project is small enough that splitting core logic into modules would add overhead without meaningful benefit. Provider-specific API code is the one exception: each provider lives in its own small file under `src/providers/` so adding a new provider is a drop-in change with no edits to `src/index.ts` beyond registering the provider.

### Polling Over Webhooks
Both Statuspage.io and incident.io support webhooks, but polling is simpler to deploy (no public endpoint needed) and works behind NATs/firewalls. The trade-off is a ~60s update delay.
The supported providers offer webhooks, but polling is simpler to deploy (no public endpoint needed) and works behind NATs/firewalls. The trade-off is a ~60s update delay.

### Thread-Per-Incident
Each incident gets its own Discord thread hanging off a "parent" embed in the channel. This keeps the main channel clean while preserving full timelines.
Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/Commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ Delete recent bot-authored messages in the current channel.

## `/monitor add <url> [channel] [label] [id] [icon_url]`

Add a new status page monitor at runtime. Both Statuspage.io and incident.io URLs are supported — the provider is auto-detected from the URL.
Add a new status page monitor at runtime. Statuspage.io, incident.io, and Instatus URLs are supported — the provider is auto-detected from the URL.

- **Permission:** Manage Server
- **Options:**
Expand All @@ -73,7 +73,7 @@ Add a new status page monitor at runtime. Both Statuspage.io and incident.io URL
- `id` (optional): Unique monitor ID; auto-derived from the page name if omitted
- `icon_url` (optional): Custom icon URL for embeds; overrides auto-detected favicon
- **Validation:**
- Probes each supported provider (incident.io first, then Statuspage.io) and picks the first match. The detected provider is saved on the monitor entry so future polls skip detection.
- Probes each supported provider (incident.io first, then Statuspage.io, then Instatus) and picks the first match. The detected provider is saved on the monitor entry so future polls skip detection.
- Checks bot permissions in the target channel
- Rejects duplicate IDs or duplicate URLs (same status page can only be tracked once per server; different status pages in the same channel are allowed)
- **Side effects:**
Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ Each monitor object requires:
|-------|----------|-------------|
| `id` | Yes | Unique identifier used in commands and state |
| `channelId` | Yes | Discord text channel ID for posting updates |
| `baseUrl` | Yes | Public status page URL (Statuspage.io e.g. `https://status.atlassian.com` or incident.io e.g. `https://status.openai.com`) |
| `baseUrl` | Yes | Public status page URL (Statuspage.io e.g. `https://status.atlassian.com`, incident.io e.g. `https://status.openai.com`, or Instatus e.g. `https://status.perplexity.com`) |
| `label` | No | Display name shown in embeds and command output |
| `iconUrl` | No | Custom icon URL for embeds. Overrides auto-detected favicon. Useful when a page's favicon doesn't work in Discord (e.g. extensionless URLs). |
| `provider` | No | Provider ID: `statuspage` (default) or `incidentio`. If omitted, `statuspage` is assumed — set to `incidentio` explicitly for incident.io pages. Runtime monitors added via `/monitor add` have this set automatically based on probe results. |
| `provider` | No | Provider ID: `statuspage` (default), `incidentio`, or `instatus`. If omitted, `statuspage` is assumed — set to `incidentio` explicitly for incident.io pages or `instatus` for Instatus pages. Runtime monitors added via `/monitor add` have this set automatically based on probe results. |

### Option B: Legacy Single-Monitor

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Home.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# squawk

A Bun-based Discord bot that monitors public status pages — [Statuspage.io](https://www.atlassian.com/software/statuspage) and [incident.io](https://incident.io/) are supported — and posts incident updates to Discord as threaded conversations.
A Bun-based Discord bot that monitors public status pages — [Statuspage.io](https://www.atlassian.com/software/statuspage), [incident.io](https://incident.io/), and [Instatus](https://instatus.com/) are supported — and posts incident updates to Discord as threaded conversations.

## What It Does

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "bun --watch src/index.ts",
"start": "bun src/index.ts"
"start": "bun src/index.ts",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"discord.js": "^14.19.3",
Expand Down
19 changes: 11 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const monitorSchema = z.object({
baseUrl: z.string().url(),
label: z.string().min(1).optional(),
iconUrl: z.string().url().optional(),
provider: z.enum(["statuspage", "incidentio"]).optional(),
provider: z.enum(["statuspage", "incidentio", "instatus"]).optional(),
});

const envSchema = z.object({
Expand All @@ -67,13 +67,13 @@ const envSchema = z.object({
MONITORS_JSON: z.string().optional(),
STATUSPAGE_MONITORS_JSON: z.string().optional(),
POLL_INTERVAL_MS: z.coerce.number().int().positive().default(60_000),
POST_EXISTING_UPDATES_ON_START: booleanFromEnv.default("false"),
ENABLE_REPLAY_COMMAND: booleanFromEnv.default("true"),
ENABLE_CLEAN_COMMAND: booleanFromEnv.default("true"),
ENABLE_STATUS_COMMAND: booleanFromEnv.default("true"),
ENABLE_TEST_COMMAND: booleanFromEnv.default("true"),
ENABLE_MONITOR_COMMAND: booleanFromEnv.default("true"),
ENABLE_CLEANUP_COMMAND: booleanFromEnv.default("true"),
POST_EXISTING_UPDATES_ON_START: booleanFromEnv.default(false),
ENABLE_REPLAY_COMMAND: booleanFromEnv.default(true),
ENABLE_CLEAN_COMMAND: booleanFromEnv.default(true),
ENABLE_STATUS_COMMAND: booleanFromEnv.default(true),
ENABLE_TEST_COMMAND: booleanFromEnv.default(true),
ENABLE_MONITOR_COMMAND: booleanFromEnv.default(true),
ENABLE_CLEANUP_COMMAND: booleanFromEnv.default(true),
APP_VERSION: z.string().min(1).optional(),
});

Expand Down Expand Up @@ -561,6 +561,9 @@ function impactColor(impact: string, status?: string) {
return 0xf2994a;
case "critical":
return 0xeb5757;
case "maintenance":
case "under_maintenance":
return 0x7f8c8d;
default:
return 0x5865f2;
}
Expand Down
26 changes: 26 additions & 0 deletions src/providers/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test, expect, describe, afterEach } from "bun:test";
import { detectProvider } from "./index";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

describe("detectProvider with Instatus", () => {
test("auto-detects an Instatus page", async () => {
globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
if (url.endsWith("/v3/summary.json")) {
return new Response(
JSON.stringify({ page: { name: "Kagi", url: "https://status.kagi.com", status: "UP" } }),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response("not found", { status: 404 });
}) as typeof fetch;

const detected = await detectProvider("https://status.kagi.com");
expect(detected?.provider.id).toBe("instatus");
expect(detected?.summary.page.name).toBe("Kagi");
});
});
4 changes: 3 additions & 1 deletion src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { incidentio } from "./incidentio";
import { instatus } from "./instatus";
import { statuspage } from "./statuspage";
import type { Provider, ProviderId, ProviderMonitor, Summary } from "./types";

const PROVIDERS: Record<ProviderId, Provider> = {
statuspage,
incidentio,
instatus,
};

/**
Expand All @@ -17,7 +19,7 @@ const PROVIDERS: Record<ProviderId, Provider> = {
* available. Statuspage URLs that aren't on incident.io will fail the
* `/proxy/<host>` probe cleanly (404) and fall through to statuspage.
*/
const PROBE_ORDER: Provider[] = [incidentio, statuspage];
const PROBE_ORDER: Provider[] = [incidentio, statuspage, instatus];

export function getProvider(monitor: ProviderMonitor): Provider {
const id = monitor.provider ?? "statuspage";
Expand Down
Loading