diff --git a/.env.example b/.env.example index ad57a8a..745ae85 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 2a79d67..8a32836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. @@ -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/ 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) @@ -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 ``` diff --git a/README.md b/README.md index e043e25..5f06606 100644 --- a/README.md +++ b/README.md @@ -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.

Squawk @@ -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 (`/api/v2/...`) or incident.io's widget proxy (`/proxy/`) — so a public page URL is all you need. +- The bot uses public APIs only — Statuspage.io's v2 API (`/api/v2/...`), incident.io's widget proxy (`/proxy/`), or Instatus's v3 JSON API + Atom history feed (`/v3/summary.json`, `/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. diff --git a/docs/wiki/API-Integration.md b/docs/wiki/API-Integration.md index 4c18a32..beee0b9 100644 --- a/docs/wiki/API-Integration.md +++ b/docs/wiki/API-Integration.md @@ -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. @@ -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. @@ -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 `/incident/`. -### 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 | +|----------|---------|---------| +| `/v3/summary.json` | `probe()`, `fetchSummary()` | Page status + `activeIncidents[]` + `activeMaintenances[]` (current state, impact enum) | +| `/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 `` HTML. Each `

` update block (`timestamp
Status - body`) is parsed into an `IncidentUpdate`; header `

` blocks (`Type: …`) are skipped. Update ids are `:` 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 ``, with a rollover guard: if the resulting date lands before `` (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/` 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 diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index 44e48db..7c4fe17 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -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"] @@ -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. diff --git a/docs/wiki/Commands.md b/docs/wiki/Commands.md index 0f56499..7a55d01 100644 --- a/docs/wiki/Commands.md +++ b/docs/wiki/Commands.md @@ -63,7 +63,7 @@ Delete recent bot-authored messages in the current channel. ## `/monitor add [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:** @@ -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:** diff --git a/docs/wiki/Configuration.md b/docs/wiki/Configuration.md index e290a81..2a1e57b 100644 --- a/docs/wiki/Configuration.md +++ b/docs/wiki/Configuration.md @@ -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 diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 30af996..4d1cb0c 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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 diff --git a/package.json b/package.json index 114ebc2..3883318 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index fc063c9..020b639 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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({ @@ -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(), }); @@ -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; } diff --git a/src/providers/index.test.ts b/src/providers/index.test.ts new file mode 100644 index 0000000..277958b --- /dev/null +++ b/src/providers/index.test.ts @@ -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"); + }); +}); diff --git a/src/providers/index.ts b/src/providers/index.ts index 41ebf91..ec9be2c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -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 = { statuspage, incidentio, + instatus, }; /** @@ -17,7 +19,7 @@ const PROVIDERS: Record = { * available. Statuspage URLs that aren't on incident.io will fail the * `/proxy/` 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"; diff --git a/src/providers/instatus.test.ts b/src/providers/instatus.test.ts new file mode 100644 index 0000000..8cb7965 --- /dev/null +++ b/src/providers/instatus.test.ts @@ -0,0 +1,286 @@ +import { test, expect, describe } from "bun:test"; +import { + canonicalIncidentStatus, + canonicalMaintenanceStatus, + canonicalImpact, +} from "./instatus"; + +describe("canonical mappers", () => { + test("incident status maps Instatus + feed words to canonical set", () => { + expect(canonicalIncidentStatus("INVESTIGATING")).toBe("investigating"); + expect(canonicalIncidentStatus("Investigating")).toBe("investigating"); + expect(canonicalIncidentStatus("IDENTIFIED")).toBe("identified"); + expect(canonicalIncidentStatus("MONITORING")).toBe("monitoring"); + expect(canonicalIncidentStatus("RESOLVED")).toBe("resolved"); + expect(canonicalIncidentStatus("something-odd")).toBe("investigating"); + }); + + test("maintenance status maps lifecycle; completed => resolved", () => { + expect(canonicalMaintenanceStatus("NOTSTARTEDYET")).toBe("scheduled"); + expect(canonicalMaintenanceStatus("Scheduled")).toBe("scheduled"); + expect(canonicalMaintenanceStatus("INPROGRESS")).toBe("in_progress"); + expect(canonicalMaintenanceStatus("In progress")).toBe("in_progress"); + expect(canonicalMaintenanceStatus("VERIFYING")).toBe("in_progress"); + expect(canonicalMaintenanceStatus("COMPLETED")).toBe("resolved"); + expect(canonicalMaintenanceStatus("Completed")).toBe("resolved"); + }); + + test("impact maps Instatus enums to canonical set", () => { + expect(canonicalImpact("OPERATIONAL")).toBe("none"); + expect(canonicalImpact("MINOROUTAGE")).toBe("minor"); + expect(canonicalImpact("DEGRADEDPERFORMANCE")).toBe("minor"); + expect(canonicalImpact("PARTIALOUTAGE")).toBe("major"); + expect(canonicalImpact("MAJOROUTAGE")).toBe("critical"); + expect(canonicalImpact(undefined)).toBe("none"); + expect(canonicalImpact("weird")).toBe("minor"); + }); +}); + +import { parseUpdateTimestamp } from "./instatus"; + +describe("parseUpdateTimestamp", () => { + const published = "2026-06-04T21:10:00.000+00:00"; + + test("parses 'Mon D, HH:MM:SS GMT+0' using the published year", () => { + const iso = parseUpdateTimestamp("Jun 5, 01:40:38 GMT+0", published); + expect(iso).toBe("2026-06-05T01:40:38.000Z"); + }); + + test("rolls the year forward when the month is far below the published month", () => { + const decPublished = "2025-12-31T23:50:00.000+00:00"; + const iso = parseUpdateTimestamp("Jan 1, 00:30:00 GMT+0", decPublished); + expect(iso).toBe("2026-01-01T00:30:00.000Z"); + }); + + test("rolls year forward for any update that lands before the published date", () => { + const decPublished = "2025-12-15T12:00:00.000+00:00"; + expect(parseUpdateTimestamp("Mar 2, 01:00:00 GMT+0", decPublished)).toBe("2026-03-02T01:00:00.000Z"); + }); + + test("returns null for unparseable input", () => { + expect(parseUpdateTimestamp("not a date", published)).toBeNull(); + }); +}); + +import { parseInstatusAtom } from "./instatus"; + +const ATOM_FIXTURE = ` + + Perplexity Status - Incident history + + tag:status.perplexity.com,2005:Incident/cmq08wkuw02waqmn6yt1kdgej + 2026-06-04T21:10:00.000+00:00 + 2026-06-05T01:40:38.000+00:00 + + Connector connectivity issues + Type: Incident

+

Duration: 4 hours and 31 minutes

+

Affected Components: Website

+

Jun 5, 01:40:38 GMT+0
Resolved - + This incident has been resolved..

+

Jun 5, 01:00:00 GMT+0
Monitoring - + Connectors are recovering and getting back to normal for all users..

+

Jun 4, 21:10:00 GMT+0
Investigating - + We have identified connector issues that are currently impacting users..

+ ]]> + + + tag:status.kagi.com,2005:Incident/cmaintenance123 + 2026-02-23T09:00:00.000+00:00 + 2026-02-23T09:18:00.000+00:00 + + Features update for feedback sites + Type: Maintenance

+

Duration: 18 minutes

+

Affected Components: feedback

+

Feb 23, 09:18:00 GMT+0
Completed - + The scheduled maintenance has been completed..

+

Feb 23, 09:00:00 GMT+0
Identified - + We will be deploying new features at this time..

+ ]]>
+
+`; + +describe("parseInstatusAtom", () => { + const incidents = parseInstatusAtom(ATOM_FIXTURE); + + test("returns one Incident per entry", () => { + expect(incidents).toHaveLength(2); + }); + + test("extracts id, name, and shortlink", () => { + const inc = incidents[0]; + expect(inc.id).toBe("cmq08wkuw02waqmn6yt1kdgej"); + expect(inc.name).toBe("Connector connectivity issues"); + expect(inc.shortlink).toBe("https://status.perplexity.com/incident/cmq08wkuw02waqmn6yt1kdgej"); + }); + + test("parses updates sorted chronologically with prose bodies", () => { + const inc = incidents[0]; + expect(inc.incident_updates).toHaveLength(3); + expect(inc.incident_updates.map((u) => u.status)).toEqual([ + "investigating", + "monitoring", + "resolved", + ]); + expect(inc.incident_updates[0].body).toBe( + "We have identified connector issues that are currently impacting users.", + ); + // Stable, dedupe-friendly update ids keyed on incident id + timestamp. + expect(inc.incident_updates[0].id).toBe("cmq08wkuw02waqmn6yt1kdgej:2026-06-04T21:10:00.000Z"); + }); + + test("top-level status/resolved_at reflect the latest update", () => { + const inc = incidents[0]; + expect(inc.status).toBe("resolved"); + expect(inc.resolved_at).toBe("2026-06-05T01:40:38.000Z"); + expect(inc.created_at).toBe("2026-06-04T21:10:00.000Z"); + }); + + test("maintenance entries get impact=maintenance and maintenance statuses", () => { + const maint = incidents[1]; + expect(maint.impact).toBe("maintenance"); + // 'Identified' on a maintenance => in_progress; 'Completed' => resolved. + expect(maint.incident_updates.map((u) => u.status)).toEqual(["in_progress", "resolved"]); + expect(maint.status).toBe("resolved"); + expect(maint.resolved_at).toBe("2026-02-23T09:18:00.000Z"); + }); + + test("incident impact defaults to minor when no summary join is available", () => { + // Atom carries no impact enum; resolved/historical incidents default to minor. + expect(incidents[0].impact).toBe("minor"); + }); +}); + +import { mapInstatusSummary, instatusPageStatus, type InstatusSummaryJson } from "./instatus"; + +const SUMMARY_ACTIVE: InstatusSummaryJson = { + page: { name: "Perplexity", url: "https://status.perplexity.com", status: "HASISSUES" }, + activeIncidents: [ + { + id: "cmq08wkuw02waqmn6yt1kdgej", + name: "Connector connectivity issues", + started: "2026-06-04T21:10:00.000Z", + status: "INVESTIGATING", + impact: "MAJOROUTAGE", + url: "https://status.perplexity.com/cmq08wkuw02waqmn6yt1kdgej", + updatedAt: "2026-06-04T21:10:00.000Z", + }, + ], + activeMaintenances: [ + { + id: "cm123", + name: "DB maintenance", + start: "2026-06-10T00:00:00.000Z", + status: "NOTSTARTEDYET", + duration: "60", + url: "https://status.perplexity.com/maintenance/cm123", + updatedAt: "2026-06-09T00:00:00.000Z", + }, + ], +}; + +describe("mapInstatusSummary", () => { + const summary = mapInstatusSummary(SUMMARY_ACTIVE, "https://status.perplexity.com"); + + test("synthesizes page.id from the host and keeps name/url", () => { + expect(summary.page.id).toBe("status.perplexity.com"); + expect(summary.page.name).toBe("Perplexity"); + expect(summary.page.url).toBe("https://status.perplexity.com"); + }); + + test("maps active incidents with impact from the summary enum", () => { + const inc = summary.incidents.find((i) => i.id === "cmq08wkuw02waqmn6yt1kdgej"); + expect(inc?.impact).toBe("critical"); + expect(inc?.status).toBe("investigating"); + }); + + test("maps active maintenances as impact=maintenance incidents", () => { + const maint = summary.incidents.find((i) => i.id === "cm123"); + expect(maint?.impact).toBe("maintenance"); + expect(maint?.status).toBe("scheduled"); + }); +}); + +describe("instatusPageStatus", () => { + test("UP => operational", () => { + expect(instatusPageStatus("UP", [])).toEqual({ + indicator: "none", + description: "All Systems Operational", + }); + }); + + test("UNDERMAINTENANCE => maintenance", () => { + expect(instatusPageStatus("UNDERMAINTENANCE", []).indicator).toBe("maintenance"); + }); + + test("HASISSUES => worst active impact", () => { + expect(instatusPageStatus("HASISSUES", ["minor", "critical"]).indicator).toBe("critical"); + }); +}); + +import { afterEach } from "bun:test"; +import { instatus } from "./instatus"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function stubFetch(routes: Record) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input.toString(); + const match = Object.keys(routes).find((path) => url.endsWith(path)); + if (!match) return new Response("not found", { status: 404 }); + const route = routes[match]; + const isJson = route.json ?? true; + const body = isJson ? JSON.stringify(route.body) : String(route.body); + return new Response(body, { + status: route.status ?? 200, + headers: { "content-type": isJson ? "application/json" : "application/atom+xml" }, + }); + }) as typeof fetch; +} + +describe("instatus provider", () => { + test("probe returns normalized page/status for an Instatus summary", async () => { + stubFetch({ + "/v3/summary.json": { body: { page: { name: "Perplexity", url: "https://status.perplexity.com", status: "UP" } } }, + }); + const probed = await instatus.probe("https://status.perplexity.com"); + expect(probed).not.toBeNull(); + expect(probed?.page.id).toBe("status.perplexity.com"); + expect(probed?.status.indicator).toBe("none"); + }); + + test("probe returns null for a Statuspage-shaped summary (no page.status)", async () => { + stubFetch({ + "/v3/summary.json": { body: { page: { id: "abc", name: "X", url: "https://x" }, status: { indicator: "none", description: "OK" } } }, + }); + expect(await instatus.probe("https://x")).toBeNull(); + }); + + test("probe returns null on 404", async () => { + stubFetch({}); + expect(await instatus.probe("https://not-instatus.example")).toBeNull(); + }); + + test("fetchIncidents joins live impact from summary onto feed incidents", async () => { + stubFetch({ + "/history.atom": { json: false, body: ATOM_FIXTURE }, + "/v3/summary.json": { + body: { + page: { name: "Perplexity", url: "https://status.perplexity.com", status: "HASISSUES" }, + activeIncidents: [{ id: "cmq08wkuw02waqmn6yt1kdgej", name: "Connector connectivity issues", status: "INVESTIGATING", impact: "MAJOROUTAGE" }], + }, + }, + }); + const incidents = await instatus.fetchIncidents({ baseUrl: "https://status.perplexity.com", provider: "instatus" }); + const active = incidents.find((i) => i.id === "cmq08wkuw02waqmn6yt1kdgej"); + // Still active in summary => impact upgraded from feed default "minor" to "critical". + expect(active?.impact).toBe("critical"); + expect(active?.incident_updates).toHaveLength(3); + }); +}); diff --git a/src/providers/instatus.ts b/src/providers/instatus.ts new file mode 100644 index 0000000..826e292 --- /dev/null +++ b/src/providers/instatus.ts @@ -0,0 +1,409 @@ +import type { + Incident, + IncidentUpdate, + PageStatus, + Provider, + ProviderMonitor, + Summary, +} from "./types"; + +/** Lowercase + strip non-alphanumerics so "In progress" and "INPROGRESS" match. */ +function normKey(raw: string | undefined): string { + return String(raw ?? "").toLowerCase().replace(/[^a-z]/g, ""); +} + +export function canonicalIncidentStatus(raw: string | undefined): string { + switch (normKey(raw)) { + case "investigating": + return "investigating"; + case "identified": + return "identified"; + case "monitoring": + return "monitoring"; + case "resolved": + return "resolved"; + default: + return "investigating"; + } +} + +export function canonicalMaintenanceStatus(raw: string | undefined): string { + switch (normKey(raw)) { + case "notstartedyet": + case "scheduled": + return "scheduled"; + case "inprogress": + case "verifying": + case "identified": + return "in_progress"; + case "completed": + case "resolved": + return "resolved"; + default: + return "in_progress"; + } +} + +export function canonicalImpact(raw: string | undefined): string { + switch (normKey(raw)) { + case "": + case "operational": + case "none": + case "up": + return "none"; + case "minoroutage": + case "degradedperformance": + case "minor": + return "minor"; + case "partialoutage": + case "major": + return "major"; + case "majoroutage": + case "critical": + return "critical"; + default: + return "minor"; + } +} + +const MONTHS: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; + +/** + * Parse an Instatus feed `` timestamp like "Jun 5, 01:40:38 GMT+0" + * (no year) into an ISO-8601 UTC string. The year is taken from `publishedIso`; + * if the resulting date lands before `publishedIso` (beyond a small grace + * window), it belongs to the following year (incident spanning a year boundary). + */ +export function parseUpdateTimestamp(small: string, publishedIso: string): string | null { + const m = small.match(/([A-Za-z]{3})\s+(\d{1,2})\s*,\s+(\d{1,2}):(\d{2}):(\d{2})/); + if (!m) return null; + const month = MONTHS[m[1].toLowerCase()]; + if (month === undefined) return null; + const day = Number(m[2]); + const hour = Number(m[3]); + const min = Number(m[4]); + const sec = Number(m[5]); + + const published = new Date(publishedIso); + const year = Number.isNaN(published.getTime()) ? new Date(0).getUTCFullYear() : published.getUTCFullYear(); + + let date = new Date(Date.UTC(year, month, day, hour, min, sec)); + // The feed omits the year, so an update that lands before `published` (with a + // ~24h grace window for rounding) must belong to the following year (Dec→Jan). + if (!Number.isNaN(published.getTime()) && date.getTime() < published.getTime() - 24 * 3600 * 1000) { + date = new Date(Date.UTC(year + 1, month, day, hour, min, sec)); + } + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +/** Decode the handful of XML/HTML entities that appear in Instatus feeds. */ +function decodeEntities(text: string): string { + return text + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/�?39;|'|'/g, "'") + .replace(/&/g, "&"); +} + +/** Strip all tags and collapse whitespace; trim a single duplicate trailing period. */ +function plainText(html: string): string { + const text = decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); + // Instatus appends a period to update bodies that already end in one, yielding "..". + return text.replace(/\.\.$/, "."); +} + +function firstMatch(source: string, re: RegExp): string | undefined { + const m = source.match(re); + return m ? m[1] : undefined; +} + +type ParsedUpdate = { status: string; body: string; created_at: string }; + +/** + * Parse one entry's `` HTML into ordered updates. Update blocks are the + * `

` blocks that contain a `` timestamp, a `
`, and a + * `STATUS -` marker. Header blocks (`Type: …`) + * are skipped because their `` text ends in a colon. + */ +function parseUpdateBlocks(content: string, publishedIso: string, isMaintenance: boolean): ParsedUpdate[] { + const updates: ParsedUpdate[] = []; + const blockRe = /

\s*([\s\S]*?)<\/small>\s*\s*([^<]+)<\/strong>\s*-\s*([\s\S]*?)<\/p>/g; + let m: RegExpExecArray | null; + while ((m = blockRe.exec(content)) !== null) { + const small = plainText(m[1]); + const statusWord = m[2].trim(); + if (statusWord.endsWith(":")) continue; // header block, not an update + const created = parseUpdateTimestamp(small, publishedIso) ?? publishedIso; + const status = isMaintenance + ? canonicalMaintenanceStatus(statusWord) + : canonicalIncidentStatus(statusWord); + updates.push({ status, body: plainText(m[3]) || "No message provided.", created_at: created }); + } + updates.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + return updates; +} + +/** Parse a full `/history.atom` document into canonical incidents. */ +export function parseInstatusAtom(xml: string): Incident[] { + const entries = [...xml.matchAll(/([\s\S]*?)<\/entry>/g)].map((m) => m[1]); + const incidents: Incident[] = []; + + for (const entry of entries) { + const rawId = firstMatch(entry, /([\s\S]*?)<\/id>/) ?? ""; + const id = (rawId.match(/\/([A-Za-z0-9]+)\s*$/)?.[1]) ?? rawId; + const name = decodeEntities(firstMatch(entry, /([\s\S]*?)<\/title>/)?.trim() ?? "Untitled incident"); + const published = firstMatch(entry, /<published>([\s\S]*?)<\/published>/)?.trim() + ?? new Date(0).toISOString(); + const shortlink = firstMatch(entry, /<link[^>]*rel=["']alternate["'][^>]*href=["']([^"']+)["']/); + + const contentRaw = firstMatch(entry, /<content[^>]*>([\s\S]*?)<\/content>/) ?? ""; + const content = contentRaw.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, ""); + const typeField = firstMatch(content, /<strong>\s*Type:\s*<\/strong>\s*([A-Za-z]+)/); + const isMaintenance = (typeField ?? "").toLowerCase() === "maintenance"; + + const updates = parseUpdateBlocks(content, published, isMaintenance); + const mappedUpdates: IncidentUpdate[] = updates.map((u) => ({ + id: `${id}:${u.created_at}`, + status: u.status, + body: u.body, + created_at: u.created_at, + updated_at: u.created_at, + })); + + const latest = mappedUpdates[mappedUpdates.length - 1]; + const status = latest?.status ?? (isMaintenance ? "scheduled" : "investigating"); + const createdAt = mappedUpdates[0]?.created_at ?? published; + const resolvedAt = status === "resolved" + ? (latest?.created_at ?? null) + : null; + + incidents.push({ + id, + name, + status, + impact: isMaintenance ? "maintenance" : "minor", + shortlink, + created_at: createdAt, + updated_at: latest?.created_at ?? createdAt, + resolved_at: resolvedAt, + incident_updates: mappedUpdates, + }); + } + + return incidents; +} + +export type InstatusActiveIncident = { + id: string; + name: string; + started?: string; + status?: string; + impact?: string; + url?: string; + updatedAt?: string; +}; + +export type InstatusActiveMaintenance = { + id: string; + name: string; + start?: string; + status?: string; + duration?: string; + url?: string; + updatedAt?: string; +}; + +export type InstatusSummaryJson = { + page?: { name?: string; url?: string; status?: string }; + activeIncidents?: InstatusActiveIncident[]; + activeMaintenances?: InstatusActiveMaintenance[]; +}; + +const IMPACT_RANK: Record<string, number> = { none: 0, minor: 1, major: 2, critical: 3 }; + +function worstImpact(impacts: string[]): string { + let best = "none"; + for (const impact of impacts) { + if ((IMPACT_RANK[impact] ?? 0) > (IMPACT_RANK[best] ?? 0)) best = impact; + } + return best; +} + +function hostOf(baseUrl: string): string { + try { + return new URL(baseUrl).host; + } catch { + return baseUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + } +} + +/** Derive the page-level PageStatus from `page.status` + the active impacts. */ +export function instatusPageStatus(pageStatus: string | undefined, activeImpacts: string[]): PageStatus { + switch (normKey(pageStatus)) { + case "up": + return { indicator: "none", description: "All Systems Operational" }; + case "undermaintenance": + return { indicator: "maintenance", description: "Under Maintenance" }; + default: { + const worst = worstImpact(activeImpacts.length ? activeImpacts : ["minor"]); + const description = + worst === "critical" ? "Critical Outage" + : worst === "major" ? "Major Outage" + : worst === "minor" ? "Minor Issues" + : "Issues Detected"; + return { indicator: worst === "none" ? "minor" : worst, description }; + } + } +} + +function activeIncidentToCanonical(raw: InstatusActiveIncident): Incident { + const impact = canonicalImpact(raw.impact); + const status = canonicalIncidentStatus(raw.status); + const createdAt = raw.started ?? raw.updatedAt ?? new Date(0).toISOString(); + return { + id: raw.id, + name: raw.name, + status, + impact, + shortlink: raw.url, + created_at: createdAt, + updated_at: raw.updatedAt ?? createdAt, + resolved_at: null, + incident_updates: [ + { + id: `${raw.id}:${raw.updatedAt ?? createdAt}`, + status, + body: raw.name, + created_at: raw.updatedAt ?? createdAt, + updated_at: raw.updatedAt ?? createdAt, + }, + ], + }; +} + +function activeMaintenanceToCanonical(raw: InstatusActiveMaintenance): Incident { + const status = canonicalMaintenanceStatus(raw.status); + const createdAt = raw.start ?? raw.updatedAt ?? new Date(0).toISOString(); + return { + id: raw.id, + name: raw.name, + status, + impact: "maintenance", + shortlink: raw.url, + created_at: createdAt, + updated_at: raw.updatedAt ?? createdAt, + resolved_at: status === "resolved" ? (raw.updatedAt ?? createdAt) : null, + incident_updates: [ + { + id: `${raw.id}:${raw.updatedAt ?? createdAt}`, + status, + body: raw.name, + created_at: raw.updatedAt ?? createdAt, + updated_at: raw.updatedAt ?? createdAt, + }, + ], + }; +} + +/** Map `/v3/summary.json` into a canonical Summary (active incidents + maintenances). */ +export function mapInstatusSummary(json: InstatusSummaryJson, baseUrl: string): Summary { + const incidents = (json.activeIncidents ?? []).map(activeIncidentToCanonical); + const maintenances = (json.activeMaintenances ?? []).map(activeMaintenanceToCanonical); + const all = [...incidents, ...maintenances]; + const activeImpacts = all.map((i) => i.impact).filter((i) => i !== "maintenance"); + return { + page: { + id: hostOf(baseUrl), + name: json.page?.name ?? "Instatus status page", + url: json.page?.url ?? baseUrl, + }, + status: instatusPageStatus(json.page?.status, activeImpacts), + incidents: all, + }; +} + +/** Map of active incident id -> canonical impact, for enriching feed incidents. */ +export function activeImpactById(json: InstatusSummaryJson): Map<string, string> { + const map = new Map<string, string>(); + for (const inc of json.activeIncidents ?? []) { + map.set(inc.id, canonicalImpact(inc.impact)); + } + return map; +} + +async function fetchText(url: string): Promise<string> { + const response = await fetch(url, { headers: { Accept: "application/atom+xml, application/json" } }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Instatus request failed (${response.status}): ${body}`); + } + return response.text(); +} + +async function fetchSummaryJson(baseUrl: string): Promise<InstatusSummaryJson> { + const trimmed = baseUrl.replace(/\/+$/, ""); + const response = await fetch(`${trimmed}/v3/summary.json`, { headers: { Accept: "application/json" } }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Instatus request failed (${response.status}): ${body}`); + } + return (await response.json()) as InstatusSummaryJson; +} + +/** True only for genuine Instatus summaries (page.status enum, no Statuspage shape). */ +function isInstatusSummary(json: unknown): json is InstatusSummaryJson { + if (!json || typeof json !== "object") return false; + const obj = json as Record<string, unknown>; + const page = obj.page as Record<string, unknown> | undefined; + if (!page || typeof page.url !== "string" || typeof page.name !== "string") return false; + if (typeof page.status !== "string") return false; // Instatus has page.status; Statuspage does not. + if (page.id !== undefined) return false; // Statuspage page has an id; Instatus does not. + if (obj.status && typeof obj.status === "object") return false; // Statuspage top-level status object. + return true; +} + +export const instatus: Provider = { + id: "instatus", + displayName: "Instatus", + + async probe(baseUrl) { + try { + const json = await fetchSummaryJson(baseUrl); + if (!isInstatusSummary(json)) return null; + const summary = mapInstatusSummary(json, baseUrl); + return { page: summary.page, status: summary.status }; + } catch { + return null; + } + }, + + async fetchSummary(monitor: ProviderMonitor): Promise<Summary> { + const json = await fetchSummaryJson(monitor.baseUrl); + return mapInstatusSummary(json, monitor.baseUrl); + }, + + async fetchIncidents(monitor: ProviderMonitor): Promise<Incident[]> { + const trimmed = monitor.baseUrl.replace(/\/+$/, ""); + const xml = await fetchText(`${trimmed}/history.atom`); + const incidents = parseInstatusAtom(xml); + + // Join live impact: the Atom feed has no impact enum, so for incidents still + // active in summary.json, stamp the real impact over the feed's default. + try { + const summaryJson = await fetchSummaryJson(monitor.baseUrl); + const impacts = activeImpactById(summaryJson); + for (const incident of incidents) { + const live = impacts.get(incident.id); + if (live && incident.impact !== "maintenance") incident.impact = live; + } + } catch { + // Non-fatal; feed-derived impact stands. + } + return incidents; + }, +}; diff --git a/src/providers/types.ts b/src/providers/types.ts index 410489e..c6af5bf 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -5,7 +5,7 @@ * rendering, state, and commands stay provider-agnostic. */ -export type ProviderId = "statuspage" | "incidentio"; +export type ProviderId = "statuspage" | "incidentio" | "instatus"; export type PageStatus = { indicator: string;