refactor: centralize message provider normalization

This commit is contained in:
Peter Steinberger
2026-01-07 23:52:20 +00:00
parent b03a1ad814
commit 8b4bcc6b7a
8 changed files with 61 additions and 52 deletions

View File

@@ -7,6 +7,7 @@ import {
} from "../config/sessions.js"; } from "../config/sessions.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
import { agentCommand } from "./agent.js"; import { agentCommand } from "./agent.js";
type AgentGatewayResult = { type AgentGatewayResult = {
@@ -85,12 +86,6 @@ function parseTimeoutSeconds(opts: {
return raw; return raw;
} }
function normalizeProvider(raw?: string): string | undefined {
const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined;
return normalized === "imsg" ? "imessage" : normalized;
}
function formatPayloadForLog(payload: { function formatPayloadForLog(payload: {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
@@ -127,7 +122,7 @@ export async function agentViaGatewayCommand(
sessionId: opts.sessionId, sessionId: opts.sessionId,
}); });
const provider = normalizeProvider(opts.provider) ?? "whatsapp"; const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey(); const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
const response = await callGateway<GatewayAgentResponse>({ const response = await callGateway<GatewayAgentResponse>({

View File

@@ -56,6 +56,10 @@ import {
import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { resolveSendPolicy } from "../sessions/send-policy.js"; import { resolveSendPolicy } from "../sessions/send-policy.js";
import {
normalizeMessageProvider,
resolveMessageProvider,
} from "../utils/message-provider.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
type AgentCommandOpts = { type AgentCommandOpts = {
@@ -395,13 +399,10 @@ export async function agentCommand(
let fallbackProvider = provider; let fallbackProvider = provider;
let fallbackModel = model; let fallbackModel = model;
try { try {
const messageProvider = const messageProvider = resolveMessageProvider(
opts.messageProvider?.trim().toLowerCase() || opts.messageProvider,
(() => { opts.provider,
const raw = opts.provider?.trim().toLowerCase(); );
if (!raw) return undefined;
return raw === "imsg" ? "imessage" : raw;
})();
const fallbackResult = await runWithModelFallback({ const fallbackResult = await runWithModelFallback({
cfg, cfg,
provider, provider,
@@ -514,9 +515,8 @@ export async function agentCommand(
const payloads = result.payloads ?? []; const payloads = result.payloads ?? [];
const deliver = opts.deliver === true; const deliver = opts.deliver === true;
const bestEffortDeliver = opts.bestEffortDeliver === true; const bestEffortDeliver = opts.bestEffortDeliver === true;
const deliveryProviderRaw = (opts.provider ?? "whatsapp").toLowerCase();
const deliveryProvider = const deliveryProvider =
deliveryProviderRaw === "imsg" ? "imessage" : deliveryProviderRaw; normalizeMessageProvider(opts.provider) ?? "whatsapp";
const logDeliveryError = (err: unknown) => { const logDeliveryError = (err: unknown) => {
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;

View File

@@ -11,6 +11,7 @@ import {
} from "../infra/outbound/format.js"; } from "../infra/outbound/format.js";
import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
export async function sendCommand( export async function sendCommand(
opts: { opts: {
@@ -26,8 +27,7 @@ export async function sendCommand(
deps: CliDeps, deps: CliDeps,
runtime: RuntimeEnv, runtime: RuntimeEnv,
) { ) {
const providerRaw = (opts.provider ?? "whatsapp").toLowerCase(); const provider = normalizeMessageProvider(opts.provider) ?? "whatsapp";
const provider = providerRaw === "imsg" ? "imessage" : providerRaw;
if (opts.dryRun) { if (opts.dryRun) {
runtime.log( runtime.log(

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { normalizeMessageProvider } from "../utils/message-provider.js";
import { import {
type HookMappingResolved, type HookMappingResolved,
resolveHookMappings, resolveHookMappings,
@@ -174,20 +175,22 @@ export function normalizeAgentPayload(
? sessionKeyRaw.trim() ? sessionKeyRaw.trim()
: `hook:${idFactory()}`; : `hook:${idFactory()}`;
const providerRaw = payload.provider; const providerRaw = payload.provider;
const providerNormalized =
typeof providerRaw === "string"
? normalizeMessageProvider(providerRaw)
: undefined;
const provider = const provider =
providerRaw === "whatsapp" || providerNormalized === "whatsapp" ||
providerRaw === "telegram" || providerNormalized === "telegram" ||
providerRaw === "discord" || providerNormalized === "discord" ||
providerRaw === "slack" || providerNormalized === "slack" ||
providerRaw === "signal" || providerNormalized === "signal" ||
providerRaw === "imessage" || providerNormalized === "imessage" ||
providerRaw === "last" providerNormalized === "last"
? providerRaw ? providerNormalized
: providerRaw === "imsg" : providerRaw === undefined
? "imessage" ? "last"
: providerRaw === undefined : null;
? "last"
: null;
if (provider === null) { if (provider === null) {
return { return {
ok: false, ok: false,

View File

@@ -11,6 +11,7 @@ import {
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { normalizeMessageProvider } from "../../utils/message-provider.js";
import { normalizeE164 } from "../../utils.js"; import { normalizeE164 } from "../../utils.js";
import { import {
type AgentWaitParams, type AgentWaitParams,
@@ -138,15 +139,8 @@ export const agentHandlers: GatewayRequestHandlers = {
const runId = idem; const runId = idem;
const requestedProviderRaw =
typeof request.provider === "string" ? request.provider.trim() : "";
const requestedProviderNormalized = requestedProviderRaw
? requestedProviderRaw.toLowerCase()
: "last";
const requestedProvider = const requestedProvider =
requestedProviderNormalized === "imsg" normalizeMessageProvider(request.provider) ?? "last";
? "imessage"
: requestedProviderNormalized;
const lastProvider = sessionEntry?.lastProvider; const lastProvider = sessionEntry?.lastProvider;
const lastTo = const lastTo =

View File

@@ -6,6 +6,7 @@ import { sendMessageSignal } from "../../signal/index.js";
import { sendMessageSlack } from "../../slack/send.js"; import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js"; import { sendMessageTelegram } from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js"; import { resolveTelegramToken } from "../../telegram/token.js";
import { normalizeMessageProvider } from "../../utils/message-provider.js";
import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js"; import { resolveDefaultWhatsAppAccountId } from "../../web/accounts.js";
import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "../../web/outbound.js";
import { import {
@@ -51,8 +52,7 @@ export const sendHandlers: GatewayRequestHandlers = {
} }
const to = request.to.trim(); const to = request.to.trim();
const message = request.message.trim(); const message = request.message.trim();
const providerRaw = (request.provider ?? "whatsapp").toLowerCase(); const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
const provider = providerRaw === "imsg" ? "imessage" : providerRaw;
try { try {
if (provider === "telegram") { if (provider === "telegram") {
const cfg = loadConfig(); const cfg = loadConfig();
@@ -220,8 +220,7 @@ export const sendHandlers: GatewayRequestHandlers = {
return; return;
} }
const to = request.to.trim(); const to = request.to.trim();
const providerRaw = (request.provider ?? "whatsapp").toLowerCase(); const provider = normalizeMessageProvider(request.provider) ?? "whatsapp";
const provider = providerRaw === "imsg" ? "imessage" : providerRaw;
if (provider !== "whatsapp" && provider !== "discord") { if (provider !== "whatsapp" && provider !== "discord") {
respond( respond(
false, false,

View File

@@ -21,6 +21,11 @@ import {
installGatewayTestHooks(); installGatewayTestHooks();
function expectProviders(call: Record<string, unknown>, provider: string) {
expect(call.provider).toBe(provider);
expect(call.messageProvider).toBe(provider);
}
describe("gateway server agent", () => { describe("gateway server agent", () => {
test("agent falls back to allowFrom when lastTo is stale", async () => { test("agent falls back to allowFrom when lastTo is stale", async () => {
testState.allowFrom = ["+436769770569"]; testState.allowFrom = ["+436769770569"];
@@ -57,8 +62,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("whatsapp"); expectProviders(call, "whatsapp");
expect(call.messageProvider).toBe("whatsapp");
expect(call.to).toBe("+436769770569"); expect(call.to).toBe("+436769770569");
expect(call.sessionId).toBe("sess-main-stale"); expect(call.sessionId).toBe("sess-main-stale");
@@ -138,7 +142,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("whatsapp"); expectProviders(call, "whatsapp");
expect(call.messageProvider).toBe("whatsapp"); expect(call.messageProvider).toBe("whatsapp");
expect(call.to).toBe("+1555"); expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
@@ -183,8 +187,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("telegram"); expectProviders(call, "telegram");
expect(call.messageProvider).toBe("telegram");
expect(call.to).toBe("123"); expect(call.to).toBe("123");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
@@ -228,8 +231,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("discord"); expectProviders(call, "discord");
expect(call.messageProvider).toBe("discord");
expect(call.to).toBe("channel:discord-123"); expect(call.to).toBe("channel:discord-123");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
@@ -273,7 +275,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("signal"); expectProviders(call, "signal");
expect(call.to).toBe("+15551234567"); expect(call.to).toBe("+15551234567");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);
@@ -318,7 +320,7 @@ describe("gateway server agent", () => {
const spy = vi.mocked(agentCommand); const spy = vi.mocked(agentCommand);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>; const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.provider).toBe("whatsapp"); expectProviders(call, "whatsapp");
expect(call.to).toBe("+1555"); expect(call.to).toBe("+1555");
expect(call.deliver).toBe(true); expect(call.deliver).toBe(true);
expect(call.bestEffortDeliver).toBe(true); expect(call.bestEffortDeliver).toBe(true);

View File

@@ -0,0 +1,16 @@
export function normalizeMessageProvider(
raw?: string | null,
): string | undefined {
const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined;
return normalized === "imsg" ? "imessage" : normalized;
}
export function resolveMessageProvider(
primary?: string | null,
fallback?: string | null,
): string | undefined {
return (
normalizeMessageProvider(primary) ?? normalizeMessageProvider(fallback)
);
}