From aa635af6d0c4399482095ada518b7ed0f2469755 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 02:36:05 +0000 Subject: [PATCH] refactor: unify outbound result envelopes --- src/commands/agent.ts | 6 ++- src/commands/poll.ts | 15 ++++--- src/commands/send.ts | 15 ++++--- src/infra/outbound/deliver.ts | 24 ++++++++--- src/infra/outbound/envelope.test.ts | 65 +++++++++++++++++++++++++++++ src/infra/outbound/envelope.ts | 52 +++++++++++++++++++++++ src/infra/outbound/targets.ts | 21 +++++++--- 7 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 src/infra/outbound/envelope.test.ts create mode 100644 src/infra/outbound/envelope.ts diff --git a/src/commands/agent.ts b/src/commands/agent.ts index d0ee57004..86af31262 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -45,6 +45,7 @@ import { registerAgentRunContext, } from "../infra/agent-events.js"; import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; +import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; import { formatOutboundPayloadLog, type NormalizedOutboundPayload, @@ -548,7 +549,10 @@ export async function agentCommand( if (opts.json) { runtime.log( JSON.stringify( - { payloads: normalizedPayloads, meta: result.meta }, + buildOutboundResultEnvelope({ + payloads: normalizedPayloads, + meta: result.meta, + }), null, 2, ), diff --git a/src/commands/poll.ts b/src/commands/poll.ts index 5927e4052..14802bb88 100644 --- a/src/commands/poll.ts +++ b/src/commands/poll.ts @@ -1,6 +1,7 @@ import type { CliDeps } from "../cli/deps.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { success } from "../globals.js"; +import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; import { buildOutboundDeliveryJson, formatGatewaySummary, @@ -89,12 +90,14 @@ export async function pollCommand( runtime.log( JSON.stringify( { - ...buildOutboundDeliveryJson({ - provider, - via: "gateway", - to: opts.to, - result, - mediaUrl: null, + ...buildOutboundResultEnvelope({ + delivery: buildOutboundDeliveryJson({ + provider, + via: "gateway", + to: opts.to, + result, + mediaUrl: null, + }), }), question: normalized.question, options: normalized.options, diff --git a/src/commands/send.ts b/src/commands/send.ts index bc7a6ceb0..8896d5357 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -3,6 +3,7 @@ import { loadConfig } from "../config/config.js"; import { callGateway, randomIdempotencyKey } from "../gateway/call.js"; import { success } from "../globals.js"; import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; +import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js"; import { buildOutboundDeliveryJson, formatGatewaySummary, @@ -114,12 +115,14 @@ export async function sendCommand( if (opts.json) { runtime.log( JSON.stringify( - buildOutboundDeliveryJson({ - provider, - via: "gateway", - to: opts.to, - result, - mediaUrl: opts.media ?? null, + buildOutboundResultEnvelope({ + delivery: buildOutboundDeliveryJson({ + provider, + via: "gateway", + to: opts.to, + result, + mediaUrl: opts.media ?? null, + }), }), null, 2, diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 57690f471..d437c9312 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -40,6 +40,18 @@ export type OutboundDeliveryResult = type Chunker = (text: string, limit: number) => string[]; +const providerCaps: Record< + Exclude, + { chunker: Chunker | null } +> = { + whatsapp: { chunker: chunkText }, + telegram: { chunker: chunkMarkdownText }, + discord: { chunker: null }, + slack: { chunker: null }, + signal: { chunker: chunkText }, + imessage: { chunker: chunkText }, +}; + type ProviderHandler = { chunker: Chunker | null; sendText: (text: string) => Promise; @@ -82,7 +94,7 @@ function createProviderHandler(params: { const handlers: Record, ProviderHandler> = { whatsapp: { - chunker: chunkText, + chunker: providerCaps.whatsapp.chunker, sendText: async (text) => ({ provider: "whatsapp", ...(await deps.sendWhatsApp(to, text, { verbose: false })), @@ -96,7 +108,7 @@ function createProviderHandler(params: { }), }, telegram: { - chunker: chunkMarkdownText, + chunker: providerCaps.telegram.chunker, sendText: async (text) => ({ provider: "telegram", ...(await deps.sendTelegram(to, text, { @@ -114,7 +126,7 @@ function createProviderHandler(params: { }), }, discord: { - chunker: null, + chunker: providerCaps.discord.chunker, sendText: async (text) => ({ provider: "discord", ...(await deps.sendDiscord(to, text, { verbose: false })), @@ -128,7 +140,7 @@ function createProviderHandler(params: { }), }, slack: { - chunker: null, + chunker: providerCaps.slack.chunker, sendText: async (text) => ({ provider: "slack", ...(await deps.sendSlack(to, text)), @@ -139,7 +151,7 @@ function createProviderHandler(params: { }), }, signal: { - chunker: chunkText, + chunker: providerCaps.signal.chunker, sendText: async (text) => ({ provider: "signal", ...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })), @@ -153,7 +165,7 @@ function createProviderHandler(params: { }), }, imessage: { - chunker: chunkText, + chunker: providerCaps.imessage.chunker, sendText: async (text) => ({ provider: "imessage", ...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })), diff --git a/src/infra/outbound/envelope.test.ts b/src/infra/outbound/envelope.test.ts new file mode 100644 index 000000000..e0e6a928f --- /dev/null +++ b/src/infra/outbound/envelope.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { buildOutboundResultEnvelope } from "./envelope.js"; +import type { OutboundDeliveryJson } from "./format.js"; + +describe("buildOutboundResultEnvelope", () => { + it("flattens delivery-only payloads by default", () => { + const delivery: OutboundDeliveryJson = { + provider: "whatsapp", + via: "gateway", + to: "+1", + messageId: "m1", + mediaUrl: null, + }; + expect(buildOutboundResultEnvelope({ delivery })).toEqual(delivery); + }); + + it("keeps payloads and meta in the envelope", () => { + const envelope = buildOutboundResultEnvelope({ + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }); + expect(envelope).toEqual({ + payloads: [{ text: "hi", mediaUrl: null, mediaUrls: undefined }], + meta: { foo: "bar" }, + }); + }); + + it("includes delivery when payloads are present", () => { + const delivery: OutboundDeliveryJson = { + provider: "telegram", + via: "direct", + to: "123", + messageId: "m2", + mediaUrl: null, + chatId: "c1", + }; + const envelope = buildOutboundResultEnvelope({ + payloads: [], + delivery, + meta: { ok: true }, + }); + expect(envelope).toEqual({ + payloads: [], + meta: { ok: true }, + delivery, + }); + }); + + it("can keep delivery wrapped when requested", () => { + const delivery: OutboundDeliveryJson = { + provider: "discord", + via: "gateway", + to: "channel:C1", + messageId: "m3", + mediaUrl: null, + channelId: "C1", + }; + const envelope = buildOutboundResultEnvelope({ + delivery, + flattenDelivery: false, + }); + expect(envelope).toEqual({ delivery }); + }); +}); diff --git a/src/infra/outbound/envelope.ts b/src/infra/outbound/envelope.ts new file mode 100644 index 000000000..aea6dba3a --- /dev/null +++ b/src/infra/outbound/envelope.ts @@ -0,0 +1,52 @@ +import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { OutboundDeliveryJson } from "./format.js"; +import { + normalizeOutboundPayloadsForJson, + type OutboundPayloadJson, +} from "./payloads.js"; + +export type OutboundResultEnvelope = { + payloads?: OutboundPayloadJson[]; + meta?: unknown; + delivery?: OutboundDeliveryJson; +}; + +type BuildEnvelopeParams = { + payloads?: ReplyPayload[] | OutboundPayloadJson[]; + meta?: unknown; + delivery?: OutboundDeliveryJson; + flattenDelivery?: boolean; +}; + +const isOutboundPayloadJson = ( + payload: ReplyPayload | OutboundPayloadJson, +): payload is OutboundPayloadJson => "mediaUrl" in payload; + +export function buildOutboundResultEnvelope( + params: BuildEnvelopeParams, +): OutboundResultEnvelope | OutboundDeliveryJson { + const hasPayloads = params.payloads !== undefined; + const payloads = + params.payloads === undefined + ? undefined + : params.payloads.length === 0 + ? [] + : isOutboundPayloadJson(params.payloads[0]) + ? (params.payloads as OutboundPayloadJson[]) + : normalizeOutboundPayloadsForJson(params.payloads as ReplyPayload[]); + + if ( + params.flattenDelivery !== false && + params.delivery && + !params.meta && + !hasPayloads + ) { + return params.delivery; + } + + return { + ...(hasPayloads ? { payloads } : {}), + ...(params.meta ? { meta: params.meta } : {}), + ...(params.delivery ? { delivery: params.delivery } : {}), + }; +} diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 122854ea8..59328a4d0 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -174,17 +174,26 @@ export function resolveHeartbeatDeliveryTarget(params: { } if (provider !== "whatsapp") { - return { provider, to }; + const resolved = resolveOutboundTarget({ provider, to }); + return resolved.ok + ? { provider, to: resolved.to } + : { provider: "none", reason: "no-target" }; } const rawAllow = cfg.whatsapp?.allowFrom ?? []; - if (rawAllow.includes("*")) return { provider, to }; + const resolved = resolveOutboundTarget({ + provider: "whatsapp", + to, + allowFrom: rawAllow, + }); + if (!resolved.ok) { + return { provider: "none", reason: "no-target" }; + } + if (rawAllow.includes("*")) return { provider, to: resolved.to }; const allowFrom = rawAllow .map((val) => normalizeE164(val)) .filter((val) => val.length > 1); - if (allowFrom.length === 0) return { provider, to }; - - const normalized = normalizeE164(to); - if (allowFrom.includes(normalized)) return { provider, to: normalized }; + if (allowFrom.length === 0) return { provider, to: resolved.to }; + if (allowFrom.includes(resolved.to)) return { provider, to: resolved.to }; return { provider, to: allowFrom[0], reason: "allowFrom-fallback" }; }