From 51725a71a3ec52fcb450c7302f32188b25745e8f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:40:36 +0100 Subject: [PATCH] refactor: normalize hook agent providers --- CHANGELOG.md | 1 + docs/automation/webhook.md | 2 +- src/gateway/hooks.test.ts | 9 ++++++ src/gateway/hooks.ts | 65 +++++++++++++++++++------------------- src/gateway/server-http.ts | 12 +++++-- 5 files changed, 53 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af8e12d2..75e069b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured. - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj +- Hooks: normalize hook agent providers (aliases + msteams support). - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 17e47432a..bf2e27d2a 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -71,7 +71,7 @@ Payload: - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:`. Using a consistent key allows for a multi-turn conversation within the hook context. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. -- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`. +- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`. - `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. - `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index c54606184..8849f1a33 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -87,6 +87,15 @@ describe("gateway hooks helpers", () => { expect(imsg.value.provider).toBe("imessage"); } + const teams = normalizeAgentPayload( + { message: "yo", provider: "teams" }, + { idFactory: () => "x" }, + ); + expect(teams.ok).toBe(true); + if (teams.ok) { + expect(teams.value.provider).toBe("msteams"); + } + const bad = normalizeAgentPayload({ message: "yo", provider: "sms" }); expect(bad.ok).toBe(false); }); diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 7f8d23045..b9852e7a9 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -138,20 +138,41 @@ export type HookAgentPayload = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - provider: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage"; + provider: HookMessageProvider; to?: string; model?: string; thinking?: string; timeoutSeconds?: number; }; +const HOOK_PROVIDER_VALUES = [ + "last", + "whatsapp", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "msteams", +] as const; + +export type HookMessageProvider = (typeof HOOK_PROVIDER_VALUES)[number]; + +const hookProviderSet = new Set(HOOK_PROVIDER_VALUES); +export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`; + +export function resolveHookProvider(raw: unknown): HookMessageProvider | null { + if (raw === undefined) return "last"; + if (typeof raw !== "string") return null; + const normalized = normalizeMessageProvider(raw); + if (!normalized || !hookProviderSet.has(normalized)) return null; + return normalized as HookMessageProvider; +} + +export function resolveHookDeliver(raw: unknown): boolean { + return raw !== false; +} + export function normalizeAgentPayload( payload: Record, opts?: { idFactory?: () => string }, @@ -175,30 +196,8 @@ export function normalizeAgentPayload( typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() ? sessionKeyRaw.trim() : `hook:${idFactory()}`; - const providerRaw = payload.provider; - const providerNormalized = - typeof providerRaw === "string" - ? normalizeMessageProvider(providerRaw) - : undefined; - const provider = - providerNormalized === "whatsapp" || - providerNormalized === "telegram" || - providerNormalized === "discord" || - providerNormalized === "slack" || - providerNormalized === "signal" || - providerNormalized === "imessage" || - providerNormalized === "last" - ? providerNormalized - : providerRaw === undefined - ? "last" - : null; - if (provider === null) { - return { - ok: false, - error: - "provider must be last|whatsapp|telegram|discord|slack|signal|imessage", - }; - } + const provider = resolveHookProvider(payload.provider); + if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR }; const toRaw = payload.to; const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; @@ -210,7 +209,7 @@ export function normalizeAgentPayload( if (modelRaw !== undefined && !model) { return { ok: false, error: "model required" }; } - const deliver = payload.deliver !== false; + const deliver = resolveHookDeliver(payload.deliver); const thinkingRaw = payload.thinking; const thinking = typeof thinkingRaw === "string" && thinkingRaw.trim() diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 7a7212368..607b3d992 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -11,11 +11,14 @@ import type { createSubsystemLogger } from "../logging.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, + HOOK_PROVIDER_ERROR, type HooksConfigResolved, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, + resolveHookDeliver, + resolveHookProvider, } from "./hooks.js"; import { applyHookMappings } from "./hooks-mapping.js"; @@ -171,13 +174,18 @@ export function createHooksRequestHandler( sendJson(res, 200, { ok: true, mode: mapped.action.mode }); return true; } + const provider = resolveHookProvider(mapped.action.provider); + if (!provider) { + sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR }); + return true; + } const runId = dispatchAgentHook({ message: mapped.action.message, name: mapped.action.name ?? "Hook", wakeMode: mapped.action.wakeMode, sessionKey: mapped.action.sessionKey ?? "", - deliver: mapped.action.deliver !== false, - provider: mapped.action.provider ?? "last", + deliver: resolveHookDeliver(mapped.action.deliver), + provider, to: mapped.action.to, model: mapped.action.model, thinking: mapped.action.thinking,