Merge pull request #587 from clawdbot/refactor/hook-agent-normalize
Hooks: normalize agent provider handling
This commit is contained in:
@@ -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: 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.
|
- 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: 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
|
- 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
|
- 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
|
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Payload:
|
|||||||
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
|
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. 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.
|
- `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.
|
- `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.
|
- `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.
|
- `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`).
|
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type HookMappingConfig,
|
type HookMappingConfig,
|
||||||
type HooksConfig,
|
type HooksConfig,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
|
import type { HookMessageProvider } from "./hooks.js";
|
||||||
|
|
||||||
export type HookMappingResolved = {
|
export type HookMappingResolved = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,15 +19,7 @@ export type HookMappingResolved = {
|
|||||||
messageTemplate?: string;
|
messageTemplate?: string;
|
||||||
textTemplate?: string;
|
textTemplate?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
provider?:
|
provider?: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -59,15 +52,7 @@ export type HookAction =
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
provider?:
|
provider?: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -105,14 +90,7 @@ type HookTransformResult = Partial<{
|
|||||||
name: string;
|
name: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
provider:
|
provider: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage";
|
|
||||||
to: string;
|
to: string;
|
||||||
model: string;
|
model: string;
|
||||||
thinking: string;
|
thinking: string;
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ describe("gateway hooks helpers", () => {
|
|||||||
expect(imsg.value.provider).toBe("imessage");
|
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" });
|
const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
|
||||||
expect(bad.ok).toBe(false);
|
expect(bad.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,20 +138,41 @@ export type HookAgentPayload = {
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
provider:
|
provider: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
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<string>(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(
|
export function normalizeAgentPayload(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
opts?: { idFactory?: () => string },
|
opts?: { idFactory?: () => string },
|
||||||
@@ -175,30 +196,8 @@ export function normalizeAgentPayload(
|
|||||||
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
|
||||||
? sessionKeyRaw.trim()
|
? sessionKeyRaw.trim()
|
||||||
: `hook:${idFactory()}`;
|
: `hook:${idFactory()}`;
|
||||||
const providerRaw = payload.provider;
|
const provider = resolveHookProvider(payload.provider);
|
||||||
const providerNormalized =
|
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR };
|
||||||
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 toRaw = payload.to;
|
const toRaw = payload.to;
|
||||||
const to =
|
const to =
|
||||||
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||||
@@ -210,7 +209,7 @@ export function normalizeAgentPayload(
|
|||||||
if (modelRaw !== undefined && !model) {
|
if (modelRaw !== undefined && !model) {
|
||||||
return { ok: false, error: "model required" };
|
return { ok: false, error: "model required" };
|
||||||
}
|
}
|
||||||
const deliver = payload.deliver !== false;
|
const deliver = resolveHookDeliver(payload.deliver);
|
||||||
const thinkingRaw = payload.thinking;
|
const thinkingRaw = payload.thinking;
|
||||||
const thinking =
|
const thinking =
|
||||||
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
typeof thinkingRaw === "string" && thinkingRaw.trim()
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ import type { createSubsystemLogger } from "../logging.js";
|
|||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
extractHookToken,
|
extractHookToken,
|
||||||
|
HOOK_PROVIDER_ERROR,
|
||||||
|
type HookMessageProvider,
|
||||||
type HooksConfigResolved,
|
type HooksConfigResolved,
|
||||||
normalizeAgentPayload,
|
normalizeAgentPayload,
|
||||||
normalizeHookHeaders,
|
normalizeHookHeaders,
|
||||||
normalizeWakePayload,
|
normalizeWakePayload,
|
||||||
readJsonBody,
|
readJsonBody,
|
||||||
|
resolveHookDeliver,
|
||||||
|
resolveHookProvider,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { applyHookMappings } from "./hooks-mapping.js";
|
import { applyHookMappings } from "./hooks-mapping.js";
|
||||||
|
|
||||||
@@ -32,15 +36,7 @@ type HookDispatchers = {
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
provider:
|
provider: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@@ -171,13 +167,18 @@ export function createHooksRequestHandler(
|
|||||||
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
|
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const provider = resolveHookProvider(mapped.action.provider);
|
||||||
|
if (!provider) {
|
||||||
|
sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const runId = dispatchAgentHook({
|
const runId = dispatchAgentHook({
|
||||||
message: mapped.action.message,
|
message: mapped.action.message,
|
||||||
name: mapped.action.name ?? "Hook",
|
name: mapped.action.name ?? "Hook",
|
||||||
wakeMode: mapped.action.wakeMode,
|
wakeMode: mapped.action.wakeMode,
|
||||||
sessionKey: mapped.action.sessionKey ?? "",
|
sessionKey: mapped.action.sessionKey ?? "",
|
||||||
deliver: mapped.action.deliver !== false,
|
deliver: resolveHookDeliver(mapped.action.deliver),
|
||||||
provider: mapped.action.provider ?? "last",
|
provider,
|
||||||
to: mapped.action.to,
|
to: mapped.action.to,
|
||||||
model: mapped.action.model,
|
model: mapped.action.model,
|
||||||
thinking: mapped.action.thinking,
|
thinking: mapped.action.thinking,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ import {
|
|||||||
startGatewayConfigReloader,
|
startGatewayConfigReloader,
|
||||||
} from "./config-reload.js";
|
} from "./config-reload.js";
|
||||||
import { normalizeControlUiBasePath } from "./control-ui.js";
|
import { normalizeControlUiBasePath } from "./control-ui.js";
|
||||||
import { resolveHooksConfig } from "./hooks.js";
|
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js";
|
||||||
import {
|
import {
|
||||||
isLoopbackAddress,
|
isLoopbackAddress,
|
||||||
isLoopbackHost,
|
isLoopbackHost,
|
||||||
@@ -496,15 +496,7 @@ export async function startGatewayServer(
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
provider:
|
provider: HookMessageProvider;
|
||||||
| "last"
|
|
||||||
| "whatsapp"
|
|
||||||
| "telegram"
|
|
||||||
| "discord"
|
|
||||||
| "slack"
|
|
||||||
| "signal"
|
|
||||||
| "imessage"
|
|
||||||
| "msteams";
|
|
||||||
to?: string;
|
to?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user