Skip to content

Latest commit

 

History

History
171 lines (125 loc) · 7.67 KB

File metadata and controls

171 lines (125 loc) · 7.67 KB

Adding a New Alert Channel

Sorokeep ships five alert channels (webhook, Slack, Discord, Telegram, PagerDuty). Adding a sixth doesn't require touching the dispatcher, the CLI's --type validation, or the database schema — you implement one file and register it.

This is a worked example adding a hypothetical Matrix channel.

1. Implement the sender

Create src/alerts/matrix.ts. It only needs to satisfy the AlertChannel interface from alerts/types.ts:

export interface AlertChannel {
    send(target: string, event: AlertEvent, secret?: string | null): Promise<void>;
}
// src/alerts/matrix.ts
import type { AlertEvent } from "./types.js";
import { getLogger } from "../logging/index.js";

const logger = getLogger().child({ component: "MatrixHandler" });

export async function sendMatrixAlert(roomId: string, event: AlertEvent): Promise<void> {
    logger.debug(`Sending Matrix alert to ${roomId}`, { type: event.type, contractId: event.contractId });

    // Build whatever payload your channel needs from `event` (an AlertEvent —
    // see alerts/types.ts for the full discriminated union: TTL threshold
    // crossings, resource alerts, and state changes).

    const response = await fetch(`https://matrix.example.org/rooms/${roomId}/send`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: `Sorokeep alert: ${event.type}` }),
    });

    if (!response.ok) {
        throw new Error(`Matrix delivery failed: HTTP ${response.status}`);
    }
}

Throw on failure — the dispatcher's retry logic (alerts/dispatcher.ts) catches it, increments retry_count, and retries on the next daemon cycle. Don't swallow errors here.

Look at alerts/webhook.ts or alerts/pagerduty.ts for the timeout/AbortController pattern the built-ins use — it's not required, but it's the established convention for anything making an HTTP call.

2. Register a ChannelDefinition

Add your channel to src/alerts/builtins.ts if it's shipping in sorokeep itself, register it from your own application's startup code if you're embedding sorokeep as a library (import { registerAlertChannel } from "sorokeep" — see src/lib.ts), or publish it as an external npm package and load it with --channel-plugin.

import { registerAlertChannel } from "./registry.js";
import { sendMatrixAlert } from "./matrix.js";

registerAlertChannel({
    name: "matrix",
    channel: { send: (target, event) => sendMatrixAlert(target, event) },
    targetOption: "url",
    missingTargetError: "Error: --url is required when --type is matrix.",
    supportsSigning: false,
});

Field-by-field:

Field Meaning
name The value users pass to --type and what gets stored in channel_type. Must be unique — registerAlertChannel throws if the name is already taken.
channel Your AlertChannel implementation.
targetOption Which alerts add CLI flag supplies channel_target — one of "url", "channel", or "routingKey" (the three flags the CLI already exposes). If your channel needs a genuinely new kind of identifier, that's a CLI change — open an issue first.
missingTargetError Exact message printed when that flag is omitted. Write the whole sentence — it's shown as-is, not templated.
supportsSigning true only if this channel should get an auto-generated HMAC webhook_secret (like the built-in webhook channel does). Almost always false for anything that isn't a raw webhook URL.

If you added it to builtins.ts, that's it — registerBuiltinChannels() is called once (idempotently) from both dispatcher.ts and commands/alerts.ts, so your channel is live everywhere.

3. External plugin package convention

If you want a channel to be installable without changing sorokeep's source, publish a package whose default export is a registration function. The CLI loads it with --channel-plugin <package> and passes in sorokeep's public registerAlertChannel function.

// package entrypoint, e.g. src/index.ts in your npm package
export default function registerMatrixChannel(
    registerAlertChannel: typeof import("sorokeep").registerAlertChannel,
): void {
    registerAlertChannel({
        name: "matrix",
        channel: { send: (target, event) => sendMatrixAlert(target, event) },
        targetOption: "url",
        missingTargetError: "Error: --url is required when --type is matrix.",
        supportsSigning: false,
    });
}

Minimal package metadata:

{
  "name": "sorokeep-alert-channel-matrix",
  "type": "module",
  "exports": "./dist/index.js"
}

Example CLI usage after npm install or a local link:

sorokeep --channel-plugin sorokeep-alert-channel-matrix alerts add \
  --contract <contractId> \
  --type matrix \
  --url '!room:example.org' \
  --threshold 1000

--channel-plugin is repeatable and applies before any command runs, so it also works with long-running processes such as sorokeep daemon. An invalid or missing plugin package prints a clear error and exits non-zero rather than silently no-op'ing.

4. What you did not need to touch

  • alerts/dispatcher.ts — its default channel map is built from the registry (listAlertChannels()), not a hardcoded object.
  • commands/alerts.tsalerts add --type matrix ... resolves your ChannelDefinition from the registry; the target flag, error message, and signing behavior all come from what you registered.
  • db/schema.sqlchannel_type is a plain TEXT column with a non-empty check, not a fixed SQL enum. No migration needed.

5. Tests

Contract test suite (recommended first test)

Sorokeep ships a reusable contract test in tests/alerts/channel-contract.ts. It verifies that your channel satisfies the AlertChannel interface mechanically:

  • send() returns a Promise.
  • A network failure causes send() to reject (not resolve silently).
  • The channel does not throw for any of the four AlertEvent variants (threshold_crossed, alert_resolved, resource_alert, state_changed).

Call it once per channel, passing a factory for your AlertChannel implementation and a callback that stubs your network layer to fail:

import { runChannelContractTests } from "./channel-contract.js";

describe("Matrix (contract)", () => {
    const mockFetch = vi.fn();

    beforeEach(() => {
        const okResponse = new Response(JSON.stringify({ ok: true }), {
            status: 200,
            headers: { "content-type": "application/json" },
        });
        vi.stubGlobal("fetch", mockFetch.mockResolvedValue(okResponse));
    });

    afterEach(() => {
        vi.unstubAllGlobals();
    });

    runChannelContractTests(
        "matrix",
        () => ({ send: (target, event) => sendMatrixAlert(target, event) }),
        () => { mockFetch.mockRejectedValue(new Error("ECONNREFUSED")); },
    );
});

Running the contract suite against a broken channel (e.g. one that swallows network errors) will fail, giving you confidence your implementation is correct before writing any channel-specific tests.

Channel-specific tests

Follow the pattern in tests/alerts/builtins.test.ts: mock the underlying module (e.g. vi.mock("../../src/alerts/matrix.js", ...)), then assert the registered channel.send delegates to it with the right arguments. Add a contract-shaped test the way tests/db/repositories.test.ts's "accepts discord as a valid channel_type" tests do, if your channel needs any DB-level exercise beyond what's already generic.

Per CONTRIBUTING.md, write the test first.

6. Docs

Add your channel to the table in README.md's Alerting section, and to sorokeep alerts add's --type help text in the same file.