refactor(whatsapp): keep inbound boundary transport-private

This commit is contained in:
joshavant
2026-07-29 13:33:35 -05:00
committed by Josh Avant
parent 5c296b5a54
commit 21db50efc7
18 changed files with 220 additions and 314 deletions

View File

@@ -29,7 +29,7 @@ c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel
9a5aaf650f9242523bb57bdc2556c323ab64e55aa11e25e2685b73b23ee12534 module/channel-dm-policy
fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract
982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback
6cf2da836db49a267aed470ce6f5f55e68d8d86e1efe980cdc71349cfce30bc2 module/channel-inbound
d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel-inbound
76bb7f531f3702c801e8fe7479e9e499f601fb361a4303afdcb45fc0da440e4b module/channel-inbound-debounce
4a7ada095f0f483525dcbd848fbccab26473749eba87e6a6c6e5074fd04ee1d1 module/channel-ingress-runtime
c97dd36cdf8f83c2893c33e9430a93cd131a03d855725783ca5b545de0cf84f8 module/channel-lifecycle

View File

@@ -38,78 +38,6 @@ import {
- `dispatchChannelInboundReply(...)`: records and dispatches an already
assembled inbound reply with a delivery adapter.
## Prepared inbound envelope
Channels that finish transport admission and normalization before entering the
shared runner can use `PreparedChannelInbound` as the boundary:
```ts
import {
projectPreparedChannelInbound,
resolveChannelInboundReplyPolicy,
runChannelInboundEvent,
type PreparedChannelInbound,
} from "openclaw/plugin-sdk/channel-inbound";
const inbound = {
channel: "demo",
event: { id: nativeEvent.id, timestamp: nativeEvent.timestamp },
from: conversation.id,
sender: { id: sender.id, name: sender.name },
conversation: { kind: "group", id: conversation.id },
route: { agentId, accountId, routeSessionKey },
reply: { to: conversation.id, replyToId: quote?.id },
message: {
rawBody,
body: formattedBody,
bodyForAgent,
commandBody,
},
command: {
kind: "text-slash",
body: commandBody,
authorization: { kind: "authorized" },
},
media,
} satisfies PreparedChannelInbound;
const prepared = projectPreparedChannelInbound({
inbound,
control: { messageReceivedHooks: "core" },
});
const replyPolicy = resolveChannelInboundReplyPolicy({
cfg,
ctx: prepared.context,
blockStreamingEnabled,
});
await runChannelInboundEvent({
channel: inbound.channel,
accountId,
raw: inbound,
adapter: {
ingest: () => prepared.input,
resolveTurn: () => buildTurnPlan(prepared.context, replyPolicy),
},
});
```
The envelope contains prepared product facts only. Keep native callbacks,
socket objects, provider message records, and transport lifecycle symbols in a
channel-private context beside it. Represent command authorization explicitly
as `not_checked`, `authorized`, or `denied`; do not make core infer it from a
transport payload.
Use `resolveChannelInboundReplyPolicy(...)` after building the portable inbound
context. Core resolves product policy for source reply delivery, block
streaming, and typing suppression. The channel supplies prepared context facts,
including mention state, plus native capabilities such as whether block
streaming is enabled.
Keep quote encoding, reply callbacks, native target lookup, and other
transport mechanics in the channel. Do not duplicate visible-reply policy in a
channel adapter.
For media-only inbound events, keep the message body and command text empty and
pass one `ChannelInboundMediaInput` fact per native attachment. When an ambient
history line or another text-only carrier must describe those facts, use

View File

@@ -27,13 +27,13 @@ import {
type ManagedWhatsAppListener,
} from "../connection-controller.js";
import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
import { normalizeAdmittedWebInboundMessage } from "../inbound/message-aliases.js";
import {
readWhatsAppBaileysCacheEntry,
type WhatsAppBaileysGroupMetadataCache,
type WhatsAppBaileysMessageCache,
} from "../inbound/baileys-cache.js";
import type { WhatsAppGroupMetadataCache } from "../inbound/group-metadata-cache.js";
import { normalizeAdmittedWebInboundMessage } from "../inbound/message-aliases.js";
import { attachWebInboxToSocket } from "../inbound/monitor.js";
import type { WebInboundMessageInput } from "../inbound/types.js";
import {

View File

@@ -17,7 +17,10 @@ import {
import { clearInternalHooks, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import { createWhatsAppReplyPlan } from "./inbound-dispatch.js";
import {
buildWhatsAppInboundTransportContext,
createWhatsAppReplyPlan,
} from "./inbound-dispatch.js";
const sessionKey = "agent:main:whatsapp:direct:+1000";
type InternalHookEvent = Parameters<Parameters<typeof registerInternalHook>[1]>[0];
@@ -68,7 +71,25 @@ function createPlan(deliverReply: Parameters<typeof createWhatsAppReplyPlan>[0][
groupHistories: new Map(),
groupHistoryKey: sessionKey,
maxMediaBytes: 1024,
msg,
inbound: {
channel: "whatsapp",
event: { id: "wa-inbound-1" },
from: "+1000",
sender: { id: "1000@s.whatsapp.net" },
conversation: { kind: "direct", id: "+1000" },
route: {
agentId: "main",
accountId: "default",
routeSessionKey: sessionKey,
},
reply: { to: "+2000", originatingTo: "+1000" },
message: {
body: "show me the result",
bodyForAgent: "show me the result",
rawBody: "show me the result",
commandBody: "show me the result",
},
},
rememberSentText: vi.fn(),
replyLogger: {
info: vi.fn(),
@@ -88,6 +109,7 @@ function createPlan(deliverReply: Parameters<typeof createWhatsAppReplyPlan>[0][
matchedBy: "default",
},
shouldClearGroupHistory: false,
transport: buildWhatsAppInboundTransportContext(msg),
});
return { context, plan };
}

View File

@@ -51,14 +51,7 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
return {
...actual,
deliverInboundReplyWithMessageSendContext: deliverInboundReplyWithMessageSendContextMock,
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
resolveChannelInboundReplyPolicy: (params: {
resolveChannelMessageSourceReplyDeliveryMode: (params: {
cfg: {
messages?: {
visibleReplies?: "automatic" | "message_tool";
@@ -69,34 +62,16 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
ChatType?: string;
CommandSource?: "native" | "text";
CommandAuthorized?: boolean;
WasMentioned?: boolean;
};
blockStreamingEnabled?: boolean;
}) => {
sourceReplyDeliveryModeContexts.push(params.ctx);
const isRoom = params.ctx.ChatType === "group" || params.ctx.ChatType === "channel";
const sourceReplyDeliveryMode = !isRoom
? undefined
: params.ctx.CommandSource === "native" ||
(params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true)
return params.ctx.CommandSource === "native" ||
(params.ctx.CommandSource === "text" && params.ctx.CommandAuthorized === true)
? "automatic"
: (params.cfg.messages?.groupChat?.visibleReplies ??
params.cfg.messages?.visibleReplies) === "automatic"
? "automatic"
: (params.cfg.messages?.groupChat?.visibleReplies ??
params.cfg.messages?.visibleReplies) === "automatic"
? "automatic"
: "message_tool_only";
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
return {
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: typeof params.blockStreamingEnabled === "boolean"
? !params.blockStreamingEnabled
: undefined,
suppressTyping:
sourceRepliesAreToolOnly &&
params.ctx.ChatType === "group" &&
params.ctx.WasMentioned !== true,
};
: "message_tool_only";
},
};
});

View File

@@ -1,18 +1,13 @@
// Whatsapp plugin module implements inbound dispatch behavior.
import type { StatusReactionController } from "openclaw/plugin-sdk/channel-feedback";
import {
buildChannelInboundEventContext,
createChannelPartialDeliveryError,
isChannelPartialDeliveryError,
projectPreparedChannelInbound,
resolveChannelInboundReplyPolicy,
type ChannelInboundTurnPlan,
type PreparedChannelInbound,
toInboundMediaFactsWithMetadata,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import {
bindIngressLifecycleToReplyOptions,
listMessageReceiptPlatformIds,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -54,6 +49,11 @@ import {
type ReplyPayload,
type resolveAgentRoute,
} from "./inbound-dispatch.runtime.js";
import {
projectPreparedChannelInbound,
resolveWhatsAppInboundReplyPolicy,
type PreparedChannelInbound,
} from "./prepared-inbound.js";
type ReplyLifecycleKind = "tool" | "block" | "final";
type ChannelReplyOnModelSelected = NonNullable<
@@ -641,7 +641,7 @@ export function createWhatsAppReplyPlan(params: {
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
const replyPolicy = resolveChannelInboundReplyPolicy({
const replyPolicy = resolveWhatsAppInboundReplyPolicy({
cfg: params.cfg,
ctx: params.context,
blockStreamingEnabled: resolveChannelStreamingBlockEnabled(params.cfg.channels?.whatsapp),

View File

@@ -95,8 +95,7 @@ vi.mock("openclaw/plugin-sdk/routing", () => ({
}),
}));
import { requireAdmittedWhatsAppInboundMessage } from "../../inbound/admission.js";
import { normalizeWebInboundMessage } from "../../inbound/message-aliases.js";
import { normalizeAdmittedWebInboundMessage } from "../../inbound/message-aliases.js";
import {
createTestLegacyFlatWebInboundMessage,
createTestWebAudioInboundMessage,
@@ -105,7 +104,7 @@ import type { AdmittedWebInboundMessage, WebInboundMessageInput } from "../../in
import { createWebOnMessageHandler } from "./on-message.js";
function admitTestMessage(msg: WebInboundMessageInput): AdmittedWebInboundMessage {
return requireAdmittedWhatsAppInboundMessage(normalizeWebInboundMessage(msg));
return normalizeAdmittedWebInboundMessage(msg);
}
function makeAudioMsg(): AdmittedWebInboundMessage {

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { projectPreparedChannelInbound, type PreparedChannelInbound } from "./prepared-inbound.js";
describe("WhatsApp prepared inbound", () => {
it("projects portable facts without WhatsApp transport state", () => {
const inbound = {
channel: "whatsapp",
accountId: "work",
event: {
id: "event-1",
fullId: "whatsapp:event-1",
timestamp: 1_710_000_000,
},
from: "whatsapp:user:u1",
sender: {
id: "u1",
name: "Alice",
},
conversation: {
kind: "group",
id: "room-1",
label: "Example Room",
},
route: {
agentId: "main",
accountId: "work",
routeSessionKey: "agent:main:whatsapp:group:room-1",
},
reply: {
to: "whatsapp:room:room-1",
replyToId: "quoted-1",
},
message: {
body: "agent body",
bodyForAgent: "agent body",
rawBody: "raw body",
commandBody: "/status",
},
command: {
kind: "text-slash",
body: "/status",
authorization: {
kind: "denied",
reason: "sender_not_allowed",
},
},
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
context: {
senderE164: "+15550001111",
},
} satisfies PreparedChannelInbound;
const projected = projectPreparedChannelInbound({
inbound,
control: { messageReceivedHooks: "core" },
});
expect(projected.input).toEqual({
id: "event-1",
timestamp: 1_710_000_000,
rawText: "raw body",
textForAgent: "agent body",
textForCommands: "/status",
raw: inbound,
});
expect(projected.context).toMatchObject({
MessageSid: "event-1",
MessageSidFull: "whatsapp:event-1",
BodyForAgent: "agent body",
RawBody: "raw body",
CommandBody: "/status",
ReplyToId: "quoted-1",
CommandAuthorized: false,
ConversationLabel: "Example Room",
GroupSubject: "Example Room",
SenderE164: "+15550001111",
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
});
});
});

View File

@@ -1,12 +1,14 @@
import type { ReplyThreadingPolicy } from "../../auto-reply/types.js";
import type { NormalizedLocation } from "../location.js";
import { toLocationContext } from "../location.js";
import type { CommandFacts, NormalizedTurnInput, SupplementalContextFacts } from "../turn/types.js";
import {
buildChannelInboundEventContext,
toLocationContext,
type BuildChannelInboundEventContextParams,
type BuiltChannelInboundEventContext,
} from "./context.js";
type CommandFacts,
type NormalizedLocation,
type SupplementalContextFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-outbound";
import type { ReplyThreadingPolicy } from "openclaw/plugin-sdk/reply-reference";
type PreparedChannelInboundCommandAuthorization =
| { kind: "not_checked" }
@@ -77,7 +79,14 @@ export function projectPreparedChannelInbound(params: {
inbound: PreparedChannelInbound;
control: PreparedChannelInboundControl;
}): {
input: NormalizedTurnInput;
input: {
id: string;
timestamp?: number;
rawText: string;
textForAgent?: string;
textForCommands?: string;
raw: PreparedChannelInbound;
};
context: BuiltChannelInboundEventContext;
} {
const { inbound, control } = params;
@@ -123,3 +132,40 @@ export function projectPreparedChannelInbound(params: {
}),
};
}
type ChannelInboundReplyPolicy = {
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
disableBlockStreaming?: boolean;
suppressTyping: boolean;
};
/** Keeps WhatsApp reply-policy mapping beside the transport that applies it. */
export function resolveWhatsAppInboundReplyPolicy(params: {
cfg: Parameters<typeof resolveChannelMessageSourceReplyDeliveryMode>[0]["cfg"];
ctx: Parameters<typeof resolveChannelMessageSourceReplyDeliveryMode>[0]["ctx"] & {
ChatType?: string;
WasMentioned?: boolean;
};
blockStreamingEnabled?: boolean;
}): ChannelInboundReplyPolicy {
const isRoom = params.ctx.ChatType === "group" || params.ctx.ChatType === "channel";
const sourceReplyDeliveryMode = isRoom
? resolveChannelMessageSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
})
: undefined;
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
return {
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: typeof params.blockStreamingEnabled === "boolean"
? !params.blockStreamingEnabled
: undefined,
suppressTyping:
sourceRepliesAreToolOnly &&
params.ctx.ChatType === "group" &&
params.ctx.WasMentioned !== true,
};
}

View File

@@ -2,7 +2,6 @@
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import {
normalizeAdmittedWebInboundMessage,
normalizeWebInboundMessage,
withDeprecatedWebInboundMessageFlatAliases,
} from "./message-aliases.js";
import type { monitorWebInbox } from "./monitor.js";
@@ -281,42 +280,33 @@ describe("WhatsApp inbound flat aliases", () => {
expect(msg.accessControlPassed).toBe(true);
});
it("normalizes canonical messages without admission without rebuilding admission facts", () => {
const normalized = normalizeWebInboundMessage({
event: {
id: "group-no-admission",
timestamp: 1_700_000_456,
},
payload: {
body: "hello group",
},
platform: {
chatJid: "123@g.us",
recipientJid: "+15550000001",
senderJid: "15550000002@s.whatsapp.net",
senderE164: "+15550000002",
senderName: "Alice",
sendComposing: vi.fn(async () => undefined),
reply: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-group")),
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-group")),
},
from: "123@g.us",
conversationId: "123@g.us",
accountId: "work",
accessControlPassed: true,
chatType: "group",
});
expect(normalized.admission).toBeUndefined();
expect(normalized.from).toBe("123@g.us");
expect(normalized.conversationId).toBe("123@g.us");
expect(normalized.accountId).toBe("work");
expect(normalized.chatType).toBe("group");
normalized.conversationId = "456@g.us";
expect(normalized.admission).toBeUndefined();
expect(normalized.from).toBe("456@g.us");
expect(normalized.conversationId).toBe("456@g.us");
it("rejects canonical messages without admission at the listener boundary", () => {
expect(() =>
normalizeAdmittedWebInboundMessage({
event: {
id: "group-no-admission",
timestamp: 1_700_000_456,
},
payload: {
body: "hello group",
},
platform: {
chatJid: "123@g.us",
recipientJid: "+15550000001",
senderJid: "15550000002@s.whatsapp.net",
senderE164: "+15550000002",
senderName: "Alice",
sendComposing: vi.fn(async () => undefined),
reply: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-group")),
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-group")),
},
from: "123@g.us",
conversationId: "123@g.us",
accountId: "work",
accessControlPassed: true,
chatType: "group",
}),
).toThrow("WhatsApp inbound message is missing admission facts");
});
it("normalizes legacy flat messages into canonical contexts with live aliases", () => {
@@ -351,7 +341,7 @@ describe("WhatsApp inbound flat aliases", () => {
isBatched: true,
};
const normalized = normalizeWebInboundMessage(legacy);
const normalized = normalizeAdmittedWebInboundMessage(legacy);
expect(normalized.event).toMatchObject({
id: "legacy-1",
@@ -405,7 +395,7 @@ describe("WhatsApp inbound flat aliases", () => {
});
it("normalizes blocked legacy flat admission fields through the compatibility seam", () => {
const normalized = normalizeWebInboundMessage({
const normalized = normalizeAdmittedWebInboundMessage({
id: "legacy-blocked",
from: "+15550000002",
conversationId: "+15550000002",
@@ -437,7 +427,7 @@ describe("WhatsApp inbound flat aliases", () => {
});
it("preserves explicit legacy access proof through normalization", () => {
const normalized = normalizeWebInboundMessage({
const normalized = normalizeAdmittedWebInboundMessage({
...createTestLegacyFlatWebInboundMessage(),
accessControlPassed: true,
});

View File

@@ -445,7 +445,7 @@ function normalizeLegacyFlatWebInboundMessage(msg: LegacyFlatWebInboundMessage):
});
}
export function normalizeWebInboundMessage(msg: WebInboundMessageInput): WebInboundMessage {
function normalizeWebInboundMessage(msg: WebInboundMessageInput): WebInboundMessage {
if (msg.event && msg.payload && msg.platform) {
return withDeprecatedWebInboundMessageFlatAliases(msg);
}

View File

@@ -6,10 +6,7 @@ import { requireWhatsAppInboundAdmission } from "./admission.js";
import type { WhatsAppIngressLifecycle, WhatsAppReadReceiptTarget } from "./durable-receive.js";
import { attachWhatsAppIngressLifecycle } from "./ingress-lifecycle.js";
import { withDeprecatedWebInboundMessageFlatAliases } from "./message-aliases.js";
import type {
AdmittedWebInboundCallbackMessage,
WebInboundMessageInput,
} from "./types.js";
import type { AdmittedWebInboundCallbackMessage, WebInboundMessageInput } from "./types.js";
export type WhatsAppQueuedInboundMessage = AdmittedWebInboundCallbackMessage & {
debounceKey?: string;

View File

@@ -26,10 +26,7 @@ import {
} from "./message-delivery.js";
import { createWebSendApi } from "./send-api.js";
import { createWhatsAppAttachedSocketSession } from "./socket-session.js";
import type {
AdmittedWebInboundCallbackMessage,
WebInboundMessageInput,
} from "./types.js";
import type { AdmittedWebInboundCallbackMessage, WebInboundMessageInput } from "./types.js";
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
if (enabled) {

View File

@@ -204,8 +204,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +2: shared ingress error factory through channel-outbound and channel-message.
// +2: shared ingress retention defaults through channel-outbound and channel-message.
// +1: collision-safe MCP server-name assignment for native harness catalogs.
// +3: prepared channel inbound envelope, projection, and reply-policy contracts.
4777,
4774,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -243,8 +242,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: inbound media-fact metadata projection for plugin-owned channel ingestion.
// +2: shared ingress error factory through channel-outbound and channel-message.
// +1: collision-safe MCP server-name assignment for native harness catalogs.
// +2: prepared channel inbound projection and reply-policy resolution.
2884,
2882,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -43,7 +43,6 @@ export {
createReplyPrefixContext,
createReplyPrefixOptions,
createTypingCallbacks,
resolveChannelInboundReplyPolicy,
resolveChannelSourceReplyDeliveryMode,
} from "./reply-pipeline.js";
export type { ChannelIngressDrain } from "./ingress-drain.js";

View File

@@ -45,43 +45,6 @@ export function resolveChannelSourceReplyDeliveryMode(params: {
return resolveSourceReplyDeliveryMode(params);
}
type ChannelInboundReplyPolicy = {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
disableBlockStreaming?: boolean;
suppressTyping: boolean;
};
/** Resolves product reply policy from portable turn facts and channel capabilities. */
export function resolveChannelInboundReplyPolicy(params: {
cfg: OpenClawConfig;
ctx: SourceReplyDeliveryModeContext & {
ChatType?: string;
WasMentioned?: boolean;
};
blockStreamingEnabled?: boolean;
}): ChannelInboundReplyPolicy {
const isRoom = params.ctx.ChatType === "group" || params.ctx.ChatType === "channel";
const sourceReplyDeliveryMode = isRoom
? resolveChannelSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.ctx,
})
: undefined;
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
return {
sourceReplyDeliveryMode,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: typeof params.blockStreamingEnabled === "boolean"
? !params.blockStreamingEnabled
: undefined,
suppressTyping:
sourceRepliesAreToolOnly &&
params.ctx.ChatType === "group" &&
params.ctx.WasMentioned !== true,
};
}
/** Reply pipeline options shared by core channel turns and plugin SDK callers. */
export type ChannelReplyPipeline = ReplyPrefixOptions & {
/** Resolves a response prefix against the pipeline's live selected-model context. */

View File

@@ -5,8 +5,6 @@ import { describe, expect, expectTypeOf, it } from "vitest";
import {
buildChannelInboundEventContext,
type BuildChannelInboundEventContextParams,
type PreparedChannelInbound,
projectPreparedChannelInbound,
type PluginHookChannelSenderContext,
} from "./channel-inbound.js";
@@ -73,97 +71,4 @@ describe("channel-inbound public helpers", () => {
expect(ctx.ChannelContext?.sender?.testUnionId).toBe("union-1");
});
it("builds a portable prepared inbound without channel-native types", () => {
const inbound = {
channel: "example",
accountId: "work",
event: {
id: "event-1",
fullId: "example:event-1",
timestamp: 1_710_000_000,
},
from: "example:user:u1",
sender: {
id: "u1",
name: "Alice",
},
conversation: {
kind: "group",
id: "room-1",
label: "Example Room",
},
route: {
agentId: "main",
accountId: "work",
routeSessionKey: "agent:main:example:group:room-1",
},
reply: {
to: "example:room:room-1",
replyToId: "quoted-1",
},
message: {
body: "agent body",
bodyForAgent: "agent body",
rawBody: "raw body",
commandBody: "/status",
},
command: {
kind: "text-slash",
body: "/status",
authorization: {
kind: "denied",
reason: "sender_not_allowed",
},
},
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
context: {
senderE164: "+15550001111",
},
} satisfies PreparedChannelInbound;
const projected = projectPreparedChannelInbound({
inbound,
control: { messageReceivedHooks: "core" },
});
expect(projected.input).toEqual({
id: "event-1",
timestamp: 1_710_000_000,
rawText: "raw body",
textForAgent: "agent body",
textForCommands: "/status",
raw: inbound,
});
expect(inbound.command.authorization).toEqual({
kind: "denied",
reason: "sender_not_allowed",
});
const ctx = projected.context;
expect(ctx).toMatchObject({
MessageSid: "event-1",
MessageSidFull: "example:event-1",
BodyForAgent: "agent body",
RawBody: "raw body",
CommandBody: "/status",
ReplyToId: "quoted-1",
CommandAuthorized: false,
ConversationLabel: "Example Room",
GroupSubject: "Example Room",
SenderE164: "+15550001111",
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
});
});
});

View File

@@ -114,11 +114,6 @@ export type {
FinalizeChannelInboundContextParams,
FinalizeChannelInboundContextResult,
};
export {
projectPreparedChannelInbound,
type PreparedChannelInbound,
} from "../channels/inbound-event/prepared.js";
export { resolveChannelInboundReplyPolicy } from "../channels/message/index.js";
/**
* Deprecated turn-context input alias that still accepts the old `inboundTurnKind` name.
*