From 51725a71a3ec52fcb450c7302f32188b25745e8f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:40:36 +0100 Subject: [PATCH 1/2] 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, From b1ddf1f0487b19016baea1a0bf14143fc9dca4da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 17:45:42 +0100 Subject: [PATCH 2/2] refactor: share hook provider types --- src/gateway/hooks-mapping.ts | 30 ++++-------------------------- src/gateway/server-http.ts | 11 ++--------- src/gateway/server.ts | 12 ++---------- 3 files changed, 8 insertions(+), 45 deletions(-) diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index f71fd465d..26d32b2d1 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -6,6 +6,7 @@ import { type HookMappingConfig, type HooksConfig, } from "../config/config.js"; +import type { HookMessageProvider } from "./hooks.js"; export type HookMappingResolved = { id: string; @@ -18,15 +19,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - provider?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider?: HookMessageProvider; to?: string; model?: string; thinking?: string; @@ -59,15 +52,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - provider?: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider?: HookMessageProvider; to?: string; model?: string; thinking?: string; @@ -105,14 +90,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - provider: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage"; + provider: HookMessageProvider; to: string; model: string; thinking: string; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 607b3d992..6dc106ac9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -12,6 +12,7 @@ import { handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, HOOK_PROVIDER_ERROR, + type HookMessageProvider, type HooksConfigResolved, normalizeAgentPayload, normalizeHookHeaders, @@ -35,15 +36,7 @@ type HookDispatchers = { wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - provider: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider: HookMessageProvider; to?: string; model?: string; thinking?: string; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index be4466722..9dd92c846 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -111,7 +111,7 @@ import { startGatewayConfigReloader, } from "./config-reload.js"; import { normalizeControlUiBasePath } from "./control-ui.js"; -import { resolveHooksConfig } from "./hooks.js"; +import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js"; import { isLoopbackAddress, isLoopbackHost, @@ -496,15 +496,7 @@ export async function startGatewayServer( wakeMode: "now" | "next-heartbeat"; sessionKey: string; deliver: boolean; - provider: - | "last" - | "whatsapp" - | "telegram" - | "discord" - | "slack" - | "signal" - | "imessage" - | "msteams"; + provider: HookMessageProvider; to?: string; model?: string; thinking?: string;