refactor: unify outbound result envelopes

This commit is contained in:
Peter Steinberger
2026-01-07 02:36:05 +00:00
parent 4bf5f37a44
commit aa635af6d0
7 changed files with 173 additions and 25 deletions

View File

@@ -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,
),

View File

@@ -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,

View File

@@ -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,

View File

@@ -40,6 +40,18 @@ export type OutboundDeliveryResult =
type Chunker = (text: string, limit: number) => string[];
const providerCaps: Record<
Exclude<OutboundProvider, "none">,
{ 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<OutboundDeliveryResult>;
@@ -82,7 +94,7 @@ function createProviderHandler(params: {
const handlers: Record<Exclude<OutboundProvider, "none">, 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 })),

View File

@@ -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 });
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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" };
}