refactor: unify outbound result envelopes
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
|||||||
registerAgentRunContext,
|
registerAgentRunContext,
|
||||||
} from "../infra/agent-events.js";
|
} from "../infra/agent-events.js";
|
||||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||||
|
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
|
||||||
import {
|
import {
|
||||||
formatOutboundPayloadLog,
|
formatOutboundPayloadLog,
|
||||||
type NormalizedOutboundPayload,
|
type NormalizedOutboundPayload,
|
||||||
@@ -548,7 +549,10 @@ export async function agentCommand(
|
|||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{ payloads: normalizedPayloads, meta: result.meta },
|
buildOutboundResultEnvelope({
|
||||||
|
payloads: normalizedPayloads,
|
||||||
|
meta: result.meta,
|
||||||
|
}),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { success } from "../globals.js";
|
import { success } from "../globals.js";
|
||||||
|
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
|
||||||
import {
|
import {
|
||||||
buildOutboundDeliveryJson,
|
buildOutboundDeliveryJson,
|
||||||
formatGatewaySummary,
|
formatGatewaySummary,
|
||||||
@@ -89,12 +90,14 @@ export async function pollCommand(
|
|||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
...buildOutboundDeliveryJson({
|
...buildOutboundResultEnvelope({
|
||||||
provider,
|
delivery: buildOutboundDeliveryJson({
|
||||||
via: "gateway",
|
provider,
|
||||||
to: opts.to,
|
via: "gateway",
|
||||||
result,
|
to: opts.to,
|
||||||
mediaUrl: null,
|
result,
|
||||||
|
mediaUrl: null,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
question: normalized.question,
|
question: normalized.question,
|
||||||
options: normalized.options,
|
options: normalized.options,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { success } from "../globals.js";
|
import { success } from "../globals.js";
|
||||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||||
|
import { buildOutboundResultEnvelope } from "../infra/outbound/envelope.js";
|
||||||
import {
|
import {
|
||||||
buildOutboundDeliveryJson,
|
buildOutboundDeliveryJson,
|
||||||
formatGatewaySummary,
|
formatGatewaySummary,
|
||||||
@@ -114,12 +115,14 @@ export async function sendCommand(
|
|||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
buildOutboundDeliveryJson({
|
buildOutboundResultEnvelope({
|
||||||
provider,
|
delivery: buildOutboundDeliveryJson({
|
||||||
via: "gateway",
|
provider,
|
||||||
to: opts.to,
|
via: "gateway",
|
||||||
result,
|
to: opts.to,
|
||||||
mediaUrl: opts.media ?? null,
|
result,
|
||||||
|
mediaUrl: opts.media ?? null,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ export type OutboundDeliveryResult =
|
|||||||
|
|
||||||
type Chunker = (text: string, limit: number) => string[];
|
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 = {
|
type ProviderHandler = {
|
||||||
chunker: Chunker | null;
|
chunker: Chunker | null;
|
||||||
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
sendText: (text: string) => Promise<OutboundDeliveryResult>;
|
||||||
@@ -82,7 +94,7 @@ function createProviderHandler(params: {
|
|||||||
|
|
||||||
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
|
const handlers: Record<Exclude<OutboundProvider, "none">, ProviderHandler> = {
|
||||||
whatsapp: {
|
whatsapp: {
|
||||||
chunker: chunkText,
|
chunker: providerCaps.whatsapp.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
...(await deps.sendWhatsApp(to, text, { verbose: false })),
|
...(await deps.sendWhatsApp(to, text, { verbose: false })),
|
||||||
@@ -96,7 +108,7 @@ function createProviderHandler(params: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
chunker: chunkMarkdownText,
|
chunker: providerCaps.telegram.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "telegram",
|
provider: "telegram",
|
||||||
...(await deps.sendTelegram(to, text, {
|
...(await deps.sendTelegram(to, text, {
|
||||||
@@ -114,7 +126,7 @@ function createProviderHandler(params: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
chunker: null,
|
chunker: providerCaps.discord.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
...(await deps.sendDiscord(to, text, { verbose: false })),
|
...(await deps.sendDiscord(to, text, { verbose: false })),
|
||||||
@@ -128,7 +140,7 @@ function createProviderHandler(params: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
slack: {
|
slack: {
|
||||||
chunker: null,
|
chunker: providerCaps.slack.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
...(await deps.sendSlack(to, text)),
|
...(await deps.sendSlack(to, text)),
|
||||||
@@ -139,7 +151,7 @@ function createProviderHandler(params: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
signal: {
|
signal: {
|
||||||
chunker: chunkText,
|
chunker: providerCaps.signal.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })),
|
...(await deps.sendSignal(to, text, { maxBytes: signalMaxBytes })),
|
||||||
@@ -153,7 +165,7 @@ function createProviderHandler(params: {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
imessage: {
|
imessage: {
|
||||||
chunker: chunkText,
|
chunker: providerCaps.imessage.chunker,
|
||||||
sendText: async (text) => ({
|
sendText: async (text) => ({
|
||||||
provider: "imessage",
|
provider: "imessage",
|
||||||
...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })),
|
...(await deps.sendIMessage(to, text, { maxBytes: imessageMaxBytes })),
|
||||||
|
|||||||
65
src/infra/outbound/envelope.test.ts
Normal file
65
src/infra/outbound/envelope.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/infra/outbound/envelope.ts
Normal file
52
src/infra/outbound/envelope.ts
Normal 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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -174,17 +174,26 @@ export function resolveHeartbeatDeliveryTarget(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider !== "whatsapp") {
|
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 ?? [];
|
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
|
const allowFrom = rawAllow
|
||||||
.map((val) => normalizeE164(val))
|
.map((val) => normalizeE164(val))
|
||||||
.filter((val) => val.length > 1);
|
.filter((val) => val.length > 1);
|
||||||
if (allowFrom.length === 0) return { provider, to };
|
if (allowFrom.length === 0) return { provider, to: resolved.to };
|
||||||
|
if (allowFrom.includes(resolved.to)) return { provider, to: resolved.to };
|
||||||
const normalized = normalizeE164(to);
|
|
||||||
if (allowFrom.includes(normalized)) return { provider, to: normalized };
|
|
||||||
return { provider, to: allowFrom[0], reason: "allowFrom-fallback" };
|
return { provider, to: allowFrom[0], reason: "allowFrom-fallback" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user