refactor(channels): add portable inbound boundary

This commit is contained in:
Vincent Koc
2026-07-29 18:02:02 +08:00
committed by Josh Avant
parent f4969c33d9
commit f970e5093b
25 changed files with 1090 additions and 313 deletions

View File

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

View File

@@ -38,6 +38,78 @@ 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

@@ -10,7 +10,7 @@ import {
resetLoadConfigMock,
setLoadConfigMock,
} from "./auto-reply.test-harness.js";
import type { WebInboundCallbackMessage, WebInboundMessageInput } from "./inbound.js";
import type { WebInboundCallbackMessage } from "./inbound.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
installWebAutoReplyTestHomeHooks();
@@ -45,7 +45,7 @@ describe("web auto-reply", () => {
const sendMedia = params.sendMedia ?? spies.sendMedia;
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
let capturedOnMessage: Parameters<ListenerFactory>[0]["onMessage"] | undefined;
const listenerFactory: ListenerFactory = async ({ onMessage }) => {
capturedOnMessage = onMessage;
return createMockWebListener();

View File

@@ -4,7 +4,7 @@ import { createAcceptedWhatsAppSendResult } from "../inbound/send-result.test-he
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import { loadWebMedia } from "../media.js";
import { deliverWebReply } from "./deliver-reply.js";
import { createWhatsAppReplyTransportContext, deliverWebReply } from "./deliver-reply.js";
const hoisted = vi.hoisted(() => ({
transcodeAudioBufferToOpus: vi.fn(),
@@ -161,7 +161,7 @@ describe("WhatsApp filename-only media delivery", () => {
await deliverWebReply({
replyResult: { text: "caption", mediaUrl: "https://example.com/download" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger: { info: vi.fn(), warn: vi.fn() },

View File

@@ -43,6 +43,7 @@ vi.mock("../media.js", () => ({
}));
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
let createWhatsAppReplyTransportContext: typeof import("./deliver-reply.js").createWhatsAppReplyTransportContext;
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
function unacceptedSendResult(kind: "media" | "text") {
@@ -217,7 +218,7 @@ async function expectReplySuppressed(replyResult: { text: string; isReasoning?:
const msg = makeMsg();
await deliverWebReply({
replyResult,
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -229,7 +230,7 @@ async function expectReplySuppressed(replyResult: { text: string; isReasoning?:
describe("deliverWebReply", () => {
beforeAll(async () => {
({ deliverWebReply } = await import("./deliver-reply.js"));
({ createWhatsAppReplyTransportContext, deliverWebReply } = await import("./deliver-reply.js"));
({ whatsappOutbound } = await import("../outbound-adapter.js"));
});
@@ -250,7 +251,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "Intro line\nReasoning: appears in content but is not a prefix" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -269,7 +270,7 @@ describe("deliverWebReply", () => {
const delivery = await deliverWebReply({
replyResult: { text: "aaaaaa" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 3,
replyLogger,
@@ -295,7 +296,7 @@ describe("deliverWebReply", () => {
const delivery = await deliverWebReply({
replyResult: { text: "hello" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -319,7 +320,7 @@ describe("deliverWebReply", () => {
replyResult: {
text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 4000,
replyLogger,
@@ -349,7 +350,7 @@ describe("deliverWebReply", () => {
"<div>After</div>",
].join("\n"),
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 4000,
replyLogger,
@@ -371,7 +372,7 @@ describe("deliverWebReply", () => {
"After",
].join("\n"),
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 4000,
replyLogger,
@@ -392,7 +393,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "aaaaaa", replyToId: "reply-1" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 3,
replyLogger,
@@ -426,7 +427,7 @@ describe("deliverWebReply", () => {
await runWithFakeTimers(() =>
deliverWebReply({
replyResult: { text: "hi" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -446,7 +447,7 @@ describe("deliverWebReply", () => {
await runWithFakeTimers(() =>
deliverWebReply({
replyResult: { text: "hi" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -467,7 +468,7 @@ describe("deliverWebReply", () => {
await expect(
deliverWebReply({
replyResult: { text: "hi" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -485,7 +486,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "aaaaaa", mediaUrl: "http://example.com/img.jpg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
mediaLocalRoots,
maxMediaBytes: 1024 * 1024,
textLimit: 3,
@@ -520,7 +521,7 @@ describe("deliverWebReply", () => {
await expect(
deliverWebReply({
replyResult: { text: "captiontail", mediaUrl: "http://example.com/img.jpg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 7,
replyLogger,
@@ -540,7 +541,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "\n \n indented block" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -566,7 +567,7 @@ describe("deliverWebReply", () => {
mediaUrl: "http://example.com/img.jpg",
replyToId: "reply-2",
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 7,
replyLogger,
@@ -606,7 +607,7 @@ describe("deliverWebReply", () => {
await runWithFakeTimers(() =>
deliverWebReply({
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -624,7 +625,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 20,
replyLogger,
@@ -649,7 +650,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "ALPHALINEBRAVOLINE", mediaUrl: "http://example.com/img.jpg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 9,
replyLogger,
@@ -696,7 +697,7 @@ describe("deliverWebReply", () => {
text: "caption",
mediaUrls: ["http://example.com/bad.jpg", "http://example.com/good.pdf"],
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -746,7 +747,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "caption", mediaUrl, mediaUrls },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -792,7 +793,7 @@ describe("deliverWebReply", () => {
text: "caption",
mediaUrls: ["http://example.com/img1.jpg", "http://example.com/img2.jpg"],
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -856,7 +857,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: payload,
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -906,7 +907,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "cap", mediaUrl: "http://example.com/a.ogg" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -940,7 +941,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "cap", mediaUrl: "http://example.com/a.mp3" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -981,7 +982,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "cap", mediaUrl: "http://example.com/v.mp4" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -1011,7 +1012,7 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "cap", mediaUrl: "http://example.com/x.bin" },
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,
@@ -1044,7 +1045,7 @@ describe("deliverWebReply", () => {
text: "cap",
mediaUrl: "https://example.com/report.pdf?X-Amz-Signature=secret#frag",
},
msg,
transport: createWhatsAppReplyTransportContext(msg),
maxMediaBytes: 1024 * 1024,
textLimit: 200,
replyLogger,

View File

@@ -37,6 +37,35 @@ export type WhatsAppReplyDeliveryResult = {
providerAccepted: boolean;
};
export type WhatsAppReplyTransportContext = {
accountId: string;
conversationId: string;
conversationKind: "direct" | "group";
chatJid: string;
senderJid?: string;
recipientJid: string;
correlationId?: string;
reply: AdmittedWebInboundMessage["platform"]["reply"];
sendMedia: AdmittedWebInboundMessage["platform"]["sendMedia"];
};
export function createWhatsAppReplyTransportContext(
msg: AdmittedWebInboundMessage,
): WhatsAppReplyTransportContext {
const admission = requireWhatsAppInboundAdmission(msg);
return {
accountId: admission.accountId,
conversationId: admission.conversation.id,
conversationKind: admission.conversation.kind,
chatJid: msg.platform.chatJid,
senderJid: msg.platform.senderJid,
recipientJid: msg.platform.recipientJid,
correlationId: msg.event.id,
reply: msg.platform.reply,
sendMedia: msg.platform.sendMedia,
};
}
function resolveWhatsAppReceiptKind(
results: readonly WhatsAppSendResult[],
): Parameters<typeof createMessageReceiptFromOutboundResults>[0]["kind"] {
@@ -87,7 +116,7 @@ function createWhatsAppReplyDeliveryReceipt(
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
transport: WhatsAppReplyTransportContext;
mediaLocalRoots?: readonly string[];
maxMediaBytes: number;
textLimit: number;
@@ -100,10 +129,10 @@ export async function deliverWebReply(params: {
skipLog?: boolean;
tableMode?: MarkdownTableMode;
}): Promise<WhatsAppReplyDeliveryResult> {
const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
const admission = requireWhatsAppInboundAdmission(msg);
const conversationId = admission.conversation.id;
const isGroupConversation = admission.conversation.kind === "group";
const { replyResult, transport, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } =
params;
const conversationId = transport.conversationId;
const isGroupConversation = transport.conversationKind === "group";
const replyStarted = Date.now();
const sendResults: WhatsAppSendResult[] = [];
const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
@@ -146,16 +175,15 @@ export async function deliverWebReply(params: {
// per-message target. Look up cached metadata for the specific
// message being quoted — msg.payload.body may be a combined batch body.
const cached = lookupInboundMessageMeta(
admission.accountId,
msg.platform.chatJid,
transport.accountId,
transport.chatJid,
replyResult.replyToId,
);
return buildQuotedMessageOptions({
messageId: replyResult.replyToId,
remoteJid: msg.platform.chatJid,
remoteJid: transport.chatJid,
fromMe: cached?.fromMe ?? false,
participant:
cached?.participant ?? (isGroupConversation ? msg.platform.senderJid : undefined),
participant: cached?.participant ?? (isGroupConversation ? transport.senderJid : undefined),
messageText: cached?.body ?? "",
media: cached?.media,
});
@@ -185,7 +213,7 @@ export async function deliverWebReply(params: {
for (const [index, chunk] of textChunks.entries()) {
const chunkStarted = Date.now();
const quote = getQuote();
rememberSendResult(await sendWithRetry(() => msg.platform.reply(chunk, quote), "text"));
rememberSendResult(await sendWithRetry(() => transport.reply(chunk, quote), "text"));
if (!skipLog) {
const durationMs = Date.now() - chunkStarted;
whatsappOutboundLog.debug(
@@ -195,10 +223,10 @@ export async function deliverWebReply(params: {
}
const delivery = finishDelivery();
const logPayload = {
correlationId: msg.event.id ?? newConnectionId(),
correlationId: transport.correlationId ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
from: transport.recipientJid,
text: elide(replyResult.text, 240),
mediaUrl: null,
mediaSizeBytes: null,
@@ -239,7 +267,7 @@ export async function deliverWebReply(params: {
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
transport.sendMedia(
{
image: media.buffer,
caption,
@@ -255,7 +283,7 @@ export async function deliverWebReply(params: {
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
transport.sendMedia(
{
audio: media.buffer,
ptt: true,
@@ -268,7 +296,7 @@ export async function deliverWebReply(params: {
);
if (caption) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(caption, quote), "media:audio-text"),
await sendWithRetry(() => transport.reply(caption, quote), "media:audio-text"),
);
}
} else if (media.kind === "video") {
@@ -276,7 +304,7 @@ export async function deliverWebReply(params: {
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
transport.sendMedia(
{
video: media.buffer,
caption,
@@ -292,7 +320,7 @@ export async function deliverWebReply(params: {
rememberSendResult(
await sendWithRetry(
() =>
msg.platform.sendMedia(
transport.sendMedia(
{
document: media.buffer,
fileName: media.fileName,
@@ -310,10 +338,10 @@ export async function deliverWebReply(params: {
);
replyLogger.info(
{
correlationId: msg.event.id ?? newConnectionId(),
correlationId: transport.correlationId ?? newConnectionId(),
connectionId: connectionId ?? null,
to: conversationId,
from: msg.platform.recipientJid,
from: transport.recipientJid,
text: caption ?? null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
@@ -334,7 +362,7 @@ export async function deliverWebReply(params: {
whatsappOutboundLog.warn(`Trailing media failed; sent warning to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply("⚠️ Media unavailable.", getQuote()),
() => transport.reply("⚠️ Media unavailable.", getQuote()),
"media:fallback-unavailable",
),
);
@@ -348,19 +376,14 @@ export async function deliverWebReply(params: {
}
whatsappOutboundLog.warn(`Media skipped; sent text-only to ${conversationId}`);
rememberSendResult(
await sendWithRetry(
() => msg.platform.reply(fallbackText, getQuote()),
"media:fallback-text",
),
await sendWithRetry(() => transport.reply(fallbackText, getQuote()), "media:fallback-text"),
);
},
});
// Remaining text chunks after media
for (const chunk of remainingText) {
rememberSendResult(
await sendWithRetry(() => msg.platform.reply(chunk, getQuote()), "media:text"),
);
rememberSendResult(await sendWithRetry(() => transport.reply(chunk, getQuote()), "media:text"));
}
return finishDelivery();
}

View File

@@ -33,9 +33,8 @@ import {
type WhatsAppBaileysMessageCache,
} from "../inbound/baileys-cache.js";
import type { WhatsAppGroupMetadataCache } from "../inbound/group-metadata-cache.js";
import { normalizeWebInboundMessage } from "../inbound/message-aliases.js";
import { attachWebInboxToSocket } from "../inbound/monitor.js";
import type { WebInboundMessageInput } from "../inbound/types.js";
import type { AdmittedWebInboundMessage } from "../inbound/types.js";
import {
newConnectionId,
resolveHeartbeatSeconds,
@@ -230,17 +229,12 @@ export async function monitorWebChannel(
cfg,
channel: "whatsapp",
});
const shouldDebounce = (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
const shouldDebounce = (msg: AdmittedWebInboundMessage) => {
return shouldDebounceTextInbound({
text: normalized.payload.commandBody ?? normalized.payload.body,
text: msg.payload.commandBody ?? msg.payload.body,
cfg,
hasMedia: Boolean(normalized.payload.media?.path || normalized.payload.media?.type),
allowDebounce: !(
normalized.payload.location ||
normalized.quote?.id ||
normalized.quote?.body
),
hasMedia: Boolean(msg.payload.media?.path || msg.payload.media?.type),
allowDebounce: !(msg.payload.location || msg.quote?.id || msg.quote?.body),
});
};
@@ -299,12 +293,11 @@ export async function monitorWebChannel(
groupMetadataCache,
recentMessageKeys,
baileysGroupMetaCache,
onMessage: async (msg: WebInboundMessageInput) => {
const normalized = normalizeWebInboundMessage(msg);
onMessage: async (msg: AdmittedWebInboundMessage) => {
const inboundAt = Date.now();
controller.noteInbound(inboundAt);
statusController.noteInbound(inboundAt);
await onMessage(normalized);
await onMessage(msg);
},
onPendingWorkChanged: (pendingWorkCount, at) => {
statusController.noteBusy(pendingWorkCount > 0, at);

View File

@@ -4,7 +4,6 @@ export {
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
@@ -12,7 +11,6 @@ export {
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,

View File

@@ -54,6 +54,53 @@ vi.mock("openclaw/plugin-sdk/channel-outbound", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
resolveChannelInboundReplyPolicy: (params: {
cfg: {
messages?: {
visibleReplies?: "automatic" | "message_tool";
groupChat?: { visibleReplies?: "automatic" | "message_tool" };
};
};
ctx: {
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)
? "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,
};
},
};
});
vi.mock("./runtime-api.js", async () => {
return {
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyWithBufferedBlockDispatcherMock,
@@ -63,32 +110,6 @@ vi.mock("./runtime-api.js", async () => {
return phone ? `+${phone}` : null;
},
logVerbose: () => {},
resolveChannelMessageSourceReplyDeliveryMode: ({
cfg,
ctx,
}: {
cfg: {
messages?: {
visibleReplies?: "automatic" | "message_tool";
groupChat?: { visibleReplies?: "automatic" | "message_tool" };
};
};
ctx: { ChatType?: string; CommandSource?: "native" | "text"; CommandAuthorized?: boolean };
}) => {
sourceReplyDeliveryModeContexts.push(ctx);
if (
ctx.CommandSource === "native" ||
(ctx.CommandSource === "text" && ctx.CommandAuthorized === true)
) {
return "automatic";
}
if (ctx.ChatType === "group" || ctx.ChatType === "channel") {
const configuredMode =
cfg.messages?.groupChat?.visibleReplies ?? cfg.messages?.visibleReplies;
return configuredMode === "automatic" ? "automatic" : "message_tool_only";
}
return cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic";
},
resolveChunkMode: () => "length",
resolveIdentityNamePrefix: (cfg: {
agents?: { list?: Array<{ id?: string; default?: boolean; identity?: { name?: string } }> };
@@ -115,20 +136,20 @@ vi.mock("./runtime-api.js", async () => {
},
resolveTextChunkLimit: () => 4000,
shouldLogVerbose: () => false,
toLocationContext: () => ({}),
};
});
import {
buildWhatsAppInboundContext,
buildWhatsAppInboundTransportContext,
createWhatsAppReplyPlan,
prepareWhatsAppInboundContext,
resolveWhatsAppDmRouteTarget,
resolveWhatsAppResponsePrefix,
updateWhatsAppMainLastRoute,
} from "./inbound-dispatch.js";
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"];
type TestRoute = Parameters<typeof prepareWhatsAppInboundContext>[0]["route"];
type TestMsg = Parameters<typeof prepareWhatsAppInboundContext>[0]["msg"];
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
type TestAdmissionOverride = NonNullable<TestMsgOverrides["admission"]>;
@@ -186,6 +207,72 @@ function makeMsg(overrides: TestMsgOverrides = {}): TestMsg {
});
}
function collectNonPortablePaths(
value: unknown,
path = "inbound",
seen = new Set<object>(),
): string[] {
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
return [];
}
if (typeof value === "function") {
return [path];
}
if (seen.has(value)) {
return [];
}
seen.add(value);
const record = value as Record<string, unknown>;
const symbolPaths = Object.getOwnPropertySymbols(record).map(
(symbol) => `${path}.${String(symbol)}`,
);
return [
...symbolPaths,
...Object.entries(record).flatMap(([key, child]) =>
collectNonPortablePaths(child, `${path}.${key}`, seen),
),
];
}
type PrepareWhatsAppInboundParams = Parameters<typeof prepareWhatsAppInboundContext>[0];
type LegacyTestCommand = Omit<
NonNullable<PrepareWhatsAppInboundParams["command"]>,
"authorization"
> & {
authorized?: boolean;
authorization?: NonNullable<PrepareWhatsAppInboundParams["command"]>["authorization"];
};
async function buildWhatsAppInboundContext(
params: Omit<PrepareWhatsAppInboundParams, "command"> & {
command?: LegacyTestCommand;
},
) {
const { command: legacyCommand, ...preparedParams } = params;
const command = legacyCommand
? {
...legacyCommand,
authorization:
legacyCommand.authorization ??
(legacyCommand.authorized === undefined
? { kind: "not_checked" as const }
: legacyCommand.authorized
? { kind: "authorized" as const }
: { kind: "denied" as const }),
}
: undefined;
if (!command) {
return (await prepareWhatsAppInboundContext(preparedParams)).ctxPayload;
}
const { authorized: _legacyAuthorized, ...preparedCommand } = command;
return (
await prepareWhatsAppInboundContext({
...preparedParams,
command: preparedCommand,
})
).ctxPayload;
}
function directAdmission(conversationId: string): TestAdmissionOverride {
return {
conversation: {
@@ -210,6 +297,141 @@ function groupAdmission(conversationId: string): TestAdmissionOverride {
};
}
describe("prepared WhatsApp inbound boundary", () => {
it("separates portable facts from WhatsApp transport callbacks", async () => {
const msg = makeMsg({
event: { id: "current-1", timestamp: 1_710_000_000 },
payload: {
body: "agent body",
commandBody: "/status",
media: {
path: "/tmp/photo.jpg",
type: "image/jpeg",
kind: "image",
},
},
admission: groupAdmission("120363000000000000@g.us"),
groupMention: {
wasMentioned: false,
requireMention: false,
},
group: {
subject: "Boundary Room",
participants: ["15550001111@s.whatsapp.net"],
},
});
const prepared = await prepareWhatsAppInboundContext({
bodyForAgent: "agent body",
combinedBody: "formatted agent body",
command: {
kind: "text-slash",
body: "/status",
authorization: { kind: "denied", reason: "sender_not_allowed" },
},
msg,
route: makeRoute({
sessionKey: "agent:main:whatsapp:group:120363000000000000@g.us",
}),
sender: {
id: "+15550001111",
name: "Alice",
e164: "+15550001111",
},
transcript: "prepared transcript",
mediaTranscribedIndexes: [0],
visibleReplyTo: {
id: "quoted-1",
body: "quoted body",
sender: { label: "Bob" },
},
replyThreading: { implicitCurrentMessage: "allow" },
suppressMessageReceivedHooks: true,
});
expect(prepared.inbound).toMatchObject({
event: {
id: "current-1",
timestamp: 1_710_000_000,
},
message: {
body: "formatted agent body",
bodyForAgent: "agent body",
rawBody: "agent body",
commandBody: "/status",
},
conversation: {
kind: "group",
id: "120363000000000000@g.us",
label: "120363000000000000@g.us",
},
reply: {
replyToId: "quoted-1",
},
command: {
kind: "text-slash",
body: "/status",
authorization: { kind: "denied", reason: "sender_not_allowed" },
},
media: [
{
path: "/tmp/photo.jpg",
contentType: "image/jpeg",
kind: "image",
transcribed: true,
},
],
context: {
transcript: "prepared transcript",
groupSubject: "Boundary Room",
senderE164: "+15550001111",
replyThreading: { implicitCurrentMessage: "allow" },
},
});
expect(prepared.ctxPayload).toMatchObject({
ConversationLabel: "120363000000000000@g.us",
GroupSubject: "Boundary Room",
});
expect(collectNonPortablePaths(prepared.inbound)).toEqual([]);
expect(prepared.inbound).not.toHaveProperty("platform");
expect(prepared.inbound).not.toHaveProperty("admission");
expect(prepared.control).toEqual({ messageReceivedHooks: "channel" });
const transport = buildWhatsAppInboundTransportContext(msg);
expect(transport).toMatchObject({
accountId: "default",
conversationId: "120363000000000000@g.us",
conversationKind: "group",
chatJid: "+1000",
recipientJid: "+2000",
correlationId: "current-1",
});
expect(transport.reply).toBe(msg.platform.reply);
expect(transport.sendMedia).toBe(msg.platform.sendMedia);
expect(transport.sendComposing).toBe(msg.platform.sendComposing);
expect(transport).not.toHaveProperty("wasMentioned");
});
it("assigns unique portable identities without inventing native message IDs", async () => {
const msg = makeMsg({
event: { id: undefined, timestamp: 1_710_000_000 },
});
const params = {
combinedBody: "hi",
msg,
route: makeRoute(),
sender: { id: "+15550001111" },
};
const [first, second] = await Promise.all([
prepareWhatsAppInboundContext(params),
prepareWhatsAppInboundContext(params),
]);
expect(first.inbound.event.id).not.toBe(second.inbound.event.id);
expect(buildWhatsAppInboundTransportContext(msg).correlationId).toBeUndefined();
});
});
function getCapturedDeliver() {
return (capturedDispatchParams as CapturedDispatchParams)?.dispatcherOptions?.deliver;
}
@@ -276,8 +498,9 @@ function expectRememberSentContextFields(
}
type BufferedReplyParams = Parameters<typeof createWhatsAppReplyPlan>[0];
type BufferedReplyOverrides = Partial<Omit<BufferedReplyParams, "context">> & {
type BufferedReplyOverrides = Partial<Omit<BufferedReplyParams, "context" | "transport">> & {
context?: Partial<BufferedReplyParams["context"]>;
msg?: TestMsg;
cancelAfterPrepare?: (payload: CapturedReplyPayload) => boolean;
};
@@ -319,8 +542,37 @@ function unacceptedDeliveryResult() {
};
}
function makePreparedInbound(msg: TestMsg): BufferedReplyParams["inbound"] {
const admission = msg.admission;
return {
channel: "whatsapp",
event: { id: msg.event.id ?? "msg1", timestamp: msg.event.timestamp },
from: admission.conversation.id,
sender: { id: admission.sender.id },
conversation: {
kind: admission.conversation.kind,
id: admission.conversation.id,
},
route: {
agentId: "main",
accountId: admission.accountId,
routeSessionKey: makeRoute().sessionKey,
},
reply: {
to: msg.platform.recipientJid,
originatingTo: admission.conversation.id,
},
message: {
body: msg.payload.body,
bodyForAgent: msg.payload.body,
rawBody: msg.payload.commandBody ?? msg.payload.body,
commandBody: msg.payload.commandBody ?? msg.payload.body,
},
};
}
async function dispatchBufferedReply(overrides: BufferedReplyOverrides = {}) {
const { cancelAfterPrepare, ...paramOverrides } = overrides;
const { cancelAfterPrepare, msg = makeMsg(), ...paramOverrides } = overrides;
const params: BufferedReplyParams = {
cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never,
connectionId: "conn",
@@ -329,13 +581,14 @@ async function dispatchBufferedReply(overrides: BufferedReplyOverrides = {}) {
groupHistories: new Map(),
groupHistoryKey: "+1000",
maxMediaBytes: 1,
msg: makeMsg(),
inbound: makePreparedInbound(msg),
rememberSentText: () => {},
replyLogger: makeReplyLogger(),
replyPipeline: {} as never,
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
transport: buildWhatsAppInboundTransportContext(msg),
};
return runWhatsAppReplyPlan(
@@ -1135,6 +1388,47 @@ describe("whatsapp inbound dispatch", () => {
expect(deliverReply).not.toHaveBeenCalled();
});
it("does not use a synthetic portable event ID as a WhatsApp reply target", async () => {
deliverInboundReplyWithMessageSendContextMock.mockResolvedValueOnce({
status: "handled_visible",
delivery: {
messageIds: ["wa-1"],
visibleReplySent: true,
},
});
const msg = makeMsg({
event: { id: undefined, timestamp: 1_710_000_000 },
});
await dispatchBufferedReply({
context: {
Body: "incoming",
ReplyToId: "quoted-bot-message",
},
msg,
inbound: {
...makePreparedInbound(msg),
event: {
id: "120363000000000000@g.us:1710000000",
timestamp: 1_710_000_000,
},
},
});
const deliver = getCapturedDeliver();
await deliver?.({ text: "final payload" }, { kind: "final" });
const durableParams = requireMockArg(
deliverInboundReplyWithMessageSendContextMock,
0,
0,
"durable delivery params",
);
expectRecordFields(durableParams, {
replyToId: null,
});
});
it("does not fall back when durable WhatsApp delivery suppresses a send", async () => {
deliverInboundReplyWithMessageSendContextMock.mockResolvedValueOnce({
status: "handled_no_send",
@@ -1583,7 +1877,7 @@ describe("whatsapp inbound dispatch", () => {
it("suppresses typing for message-tool-only group chat without mention", async () => {
await dispatchBufferedReply({
context: { Body: "hi", ChatType: "group" },
context: { Body: "hi", ChatType: "group", WasMentioned: false },
msg: makeMsg({
admission: groupAdmission("120363000000000000@g.us"),
wasMentioned: false,
@@ -1595,7 +1889,7 @@ describe("whatsapp inbound dispatch", () => {
it("does not suppress typing for group chat when mentioned", async () => {
await dispatchBufferedReply({
context: { Body: "@bot hi", ChatType: "group" },
context: { Body: "@bot hi", ChatType: "group", WasMentioned: true },
msg: makeMsg({
admission: groupAdmission("120363000000000000@g.us"),
wasMentioned: true,
@@ -1700,6 +1994,7 @@ describe("whatsapp inbound dispatch", () => {
it("returns true for tool-only media turns after delivering media", async () => {
const deliverReply = vi.fn(async () => acceptedDeliveryResult());
const rememberSentText = vi.fn();
const msg = makeMsg();
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementationOnce(
async (params: CapturedDispatchParams) => {
capturedDispatchParams = params;
@@ -1720,8 +2015,8 @@ describe("whatsapp inbound dispatch", () => {
deliverReply,
groupHistories: new Map(),
groupHistoryKey: "+1000",
inbound: makePreparedInbound(msg),
maxMediaBytes: 1,
msg: makeMsg(),
rememberSentText,
replyLogger: {
info: () => {},
@@ -1733,6 +2028,7 @@ describe("whatsapp inbound dispatch", () => {
replyResolver: (async () => undefined) as never,
route: makeRoute(),
shouldClearGroupHistory: false,
transport: buildWhatsAppInboundTransportContext(msg),
}),
).resolves.toBe(true);

View File

@@ -4,8 +4,10 @@ import {
buildChannelInboundEventContext,
createChannelPartialDeliveryError,
isChannelPartialDeliveryError,
type CommandFacts,
projectPreparedChannelInbound,
resolveChannelInboundReplyPolicy,
type ChannelInboundTurnPlan,
type PreparedChannelInbound,
toInboundMediaFactsWithMetadata,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
@@ -18,14 +20,18 @@ import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-histor
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import { resolveWhatsAppIngressLifecycle } from "../../inbound/ingress-lifecycle.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import {
type DeliverableWhatsAppOutboundPayload,
normalizeWhatsAppOutboundPayload,
normalizeWhatsAppPayloadTextPreservingIndentation,
} from "../../outbound-media-contract.js";
import type { WhatsAppReplyDeliveryResult } from "../deliver-reply.js";
import { newConnectionId } from "../../reconnect.js";
import type {
WhatsAppReplyDeliveryResult,
WhatsAppReplyTransportContext,
} from "../deliver-reply.js";
import { createWhatsAppReplyTransportContext } from "../deliver-reply.js";
import { markWhatsAppVisibleDeliveryError } from "../util.js";
import type { EchoTracker } from "./echo.js";
import { formatGroupMembers } from "./group-members.js";
@@ -35,7 +41,6 @@ import {
getAgentScopedMediaLocalRoots,
jidToE164,
logVerbose,
resolveChannelMessageSourceReplyDeliveryMode,
resolveChunkMode,
resolveIdentityNamePrefix,
resolveInboundLastRouteSessionKey,
@@ -43,7 +48,6 @@ import {
resolveSendableOutboundReplyParts,
resolveTextChunkLimit,
shouldLogVerbose,
toLocationContext,
type getChildLogger,
type getReplyFromConfig,
type LoadConfigFn,
@@ -78,6 +82,10 @@ type SenderContext = {
e164?: string;
};
type WhatsAppInboundTransportContext = WhatsAppReplyTransportContext & {
sendComposing: AdmittedWebInboundMessage["platform"]["sendComposing"];
};
type ReplyDeliveryInfo = { kind: ReplyLifecycleKind };
type PendingWhatsAppMediaOnlyPayload = {
@@ -162,20 +170,19 @@ function logWhatsAppReplyDeliveryError(params: {
err: unknown;
info: ReplyDeliveryInfo;
connectionId: string;
msg: AdmittedWebInboundMessage;
transport: WhatsAppInboundTransportContext;
replyLogger: ReturnType<typeof getChildLogger>;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
params.replyLogger.error(
{
err: normalizeErrForLog(params.err),
replyKind: params.info.kind,
correlationId: params.msg.event.id ?? null,
correlationId: params.transport.correlationId ?? null,
connectionId: params.connectionId,
conversationId: admission.conversation.id,
chatId: params.msg.platform.chatJid ?? null,
to: admission.conversation.id,
from: params.msg.platform.recipientJid ?? null,
conversationId: params.transport.conversationId,
chatId: params.transport.chatJid,
to: params.transport.conversationId,
from: params.transport.recipientJid,
},
"auto-reply delivery failed",
);
@@ -184,7 +191,7 @@ function logWhatsAppReplyDeliveryError(params: {
function resolveWhatsAppDurableReplyToId(params: {
context: FinalizedMsgContext;
info: ReplyDeliveryInfo;
msg: AdmittedWebInboundMessage;
currentMessageId?: string;
payload: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
}): string | null {
if (params.payload.replyToId === null) {
@@ -197,20 +204,13 @@ function resolveWhatsAppDurableReplyToId(params: {
const hasVisibleInboundReplyTarget =
Boolean(readTrimmedString(params.context.ReplyToId)) ||
Boolean(readTrimmedString(params.context.ReplyToIdFull));
const currentInboundMessageId = readTrimmedString(params.msg.event.id);
const currentInboundMessageId = readTrimmedString(params.currentMessageId);
if (params.info.kind === "final" && hasVisibleInboundReplyTarget && currentInboundMessageId) {
return currentInboundMessageId;
}
return null;
}
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
// The monitor snapshot pins the account-resolved streaming object onto the
// root channel entry, so this root-level read is already account-scoped.
const enabled = resolveChannelStreamingBlockEnabled(cfg.channels?.whatsapp);
return typeof enabled === "boolean" ? !enabled : undefined;
}
function resolveWhatsAppDeliverablePayload(
payload: ReplyPayload,
info: { kind: ReplyLifecycleKind },
@@ -371,10 +371,19 @@ export function resolveWhatsAppResponsePrefix(params: {
);
}
export async function buildWhatsAppInboundContext(params: {
export function buildWhatsAppInboundTransportContext(
msg: AdmittedWebInboundMessage,
): WhatsAppInboundTransportContext {
return {
...createWhatsAppReplyTransportContext(msg),
sendComposing: msg.platform.sendComposing,
};
}
export async function prepareWhatsAppInboundContext(params: {
bodyForAgent?: string;
combinedBody: string;
command?: CommandFacts;
command?: NonNullable<PreparedChannelInbound["command"]>;
groupHistory?: GroupHistoryEntry[];
groupHistoryLimit?: number;
groupMemberRoster?: Map<string, string>;
@@ -388,7 +397,12 @@ export async function buildWhatsAppInboundContext(params: {
replyThreading?: ReplyThreadingContext;
visibleReplyTo?: VisibleReplyTarget;
suppressMessageReceivedHooks?: boolean;
}): Promise<FinalizedMsgContext> {
}): Promise<{
inbound: PreparedChannelInbound;
control: Parameters<typeof projectPreparedChannelInbound>[0]["control"];
turnInput: ReturnType<typeof projectPreparedChannelInbound>["input"];
ctxPayload: FinalizedMsgContext;
}> {
const admission = requireWhatsAppInboundAdmission(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
@@ -420,7 +434,10 @@ export async function buildWhatsAppInboundContext(params: {
: undefined,
{ transcribed: (_entry, index) => params.mediaTranscribedIndexes?.includes(index) === true },
);
return buildChannelInboundEventContext({
const control = {
messageReceivedHooks: params.suppressMessageReceivedHooks ? "channel" : "core",
} as const;
const inbound: PreparedChannelInbound = {
channel: "whatsapp",
supplemental: {
quote: params.visibleReplyTo
@@ -434,8 +451,10 @@ export async function buildWhatsAppInboundContext(params: {
channelStructuredContext: params.msg.payload.channelStructuredContext,
},
media,
messageId: params.msg.event.id,
timestamp: params.msg.event.timestamp,
event: {
id: params.msg.event.id ?? `${conversationId}:${newConnectionId()}`,
timestamp: params.msg.event.timestamp,
},
from: conversationId,
sender: {
id: params.sender.id ?? params.sender.e164,
@@ -455,6 +474,7 @@ export async function buildWhatsAppInboundContext(params: {
reply: {
to: params.msg.platform.recipientJid,
originatingTo: conversationId,
replyToId: params.visibleReplyTo?.id,
},
message: {
body: params.combinedBody,
@@ -469,35 +489,35 @@ export async function buildWhatsAppInboundContext(params: {
? (params.groupHistoryLimit ?? params.groupHistory?.length ?? 0)
: 0,
},
access: {
...(wasMentioned !== undefined
mentions:
wasMentioned !== undefined
? {
mentions: {
canDetectMention: conversationKind === "group",
wasMentioned,
requireMention: params.msg.groupMention?.requireMention,
},
canDetectMention: conversationKind === "group",
wasMentioned,
requireMention: params.msg.groupMention?.requireMention,
}
: {}),
commands: {
authorized: params.command?.authorized === true,
},
},
: undefined,
command: params.command,
extra: {
Transcript: params.transcript,
GroupSubject: params.msg.group?.subject,
GroupMembers: formatGroupMembers({
context: {
transcript: params.transcript,
groupSubject: params.msg.group?.subject ?? null,
groupMembers: formatGroupMembers({
participants: params.msg.group?.participants,
roster: params.groupMemberRoster,
fallbackE164: params.sender.e164,
}),
SenderE164: params.sender.e164,
ReplyThreading: params.replyThreading,
SuppressMessageReceivedHooks: params.suppressMessageReceivedHooks,
...(params.msg.payload.location ? toLocationContext(params.msg.payload.location) : {}),
senderE164: params.sender.e164,
replyThreading: params.replyThreading,
location: params.msg.payload.location,
},
});
};
const projected = projectPreparedChannelInbound({ inbound, control });
return {
inbound,
control,
turnInput: projected.input,
ctxPayload: projected.context,
};
}
export function resolveWhatsAppDmRouteTarget(params: {
@@ -583,7 +603,7 @@ export function createWhatsAppReplyPlan(params: {
deliverReply: (params: {
replyResult: ReplyPayload;
normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
msg: AdmittedWebInboundMessage;
transport: WhatsAppReplyTransportContext;
mediaLocalRoots: readonly string[];
maxMediaBytes: number;
textLimit: number;
@@ -597,7 +617,7 @@ export function createWhatsAppReplyPlan(params: {
groupHistoryKey: string;
maxMediaBytes: number;
maxMediaTextChunkLimit?: number;
msg: AdmittedWebInboundMessage;
inbound: PreparedChannelInbound;
onModelSelected?: ChannelReplyOnModelSelected;
rememberSentText: EchoTracker["rememberText"];
replyLogger: ReturnType<typeof getChildLogger>;
@@ -606,11 +626,12 @@ export function createWhatsAppReplyPlan(params: {
route: ReturnType<typeof resolveAgentRoute>;
shouldClearGroupHistory: boolean;
statusReactionController?: StatusReactionController | null;
transport: WhatsAppInboundTransportContext;
turnAdoptionLifecycle?: NonNullable<
NonNullable<ChannelInboundTurnPlan["replyOptions"]>["turnAdoptionLifecycle"]
>;
}) {
const admission = requireWhatsAppInboundAdmission(params.msg);
const ingressLifecycle = resolveWhatsAppIngressLifecycle(params.msg);
const conversationId = admission.conversation.id;
const conversationKind = admission.conversation.kind;
const conversationId = params.inbound.conversation.id;
const statusReactionController = params.statusReactionController ?? null;
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
const chunkMode = resolveChunkMode(params.cfg, "whatsapp", params.route.accountId);
@@ -620,17 +641,11 @@ export function createWhatsAppReplyPlan(params: {
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
const sourceReplyDeliveryMode =
params.context.ChatType === "group" || params.context.ChatType === "channel"
? resolveChannelMessageSourceReplyDeliveryMode({
cfg: params.cfg,
ctx: params.context,
})
: undefined;
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
const disableBlockStreaming = sourceRepliesAreToolOnly
? true
: resolveWhatsAppDisableBlockStreaming(params.cfg);
const replyPolicy = resolveChannelInboundReplyPolicy({
cfg: params.cfg,
ctx: params.context,
blockStreamingEnabled: resolveChannelStreamingBlockEnabled(params.cfg.channels?.whatsapp),
});
let didSendReply = false;
let didLogHeartbeatStrip = false;
@@ -666,7 +681,7 @@ export function createWhatsAppReplyPlan(params: {
delivery = await params.deliverReply({
replyResult: normalizedDeliveryPayload,
normalizedReplyResult: normalizedDeliveryPayload,
msg: params.msg,
transport: params.transport,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
textLimit,
@@ -692,12 +707,12 @@ export function createWhatsAppReplyPlan(params: {
if (!result.visibleReplySent) {
params.replyLogger.warn(
{
correlationId: params.msg.event.id ?? null,
correlationId: params.transport.correlationId ?? null,
connectionId: params.connectionId,
conversationId,
chatId: params.msg.platform.chatJid,
chatId: params.transport.chatJid,
to: conversationId,
from: params.msg.platform.recipientJid,
from: params.transport.recipientJid,
replyKind: info.kind,
},
"auto-reply was not accepted by WhatsApp provider",
@@ -736,7 +751,7 @@ export function createWhatsAppReplyPlan(params: {
logWhatsAppMediaOnlyFlushResult(flushResult);
return whatsAppReplyDeliveryVisibility(didSendReply || flushResult.delivered > 0);
},
onReplyStart: params.msg.platform.sendComposing,
onReplyStart: params.transport.sendComposing,
};
const delivery: ChannelInboundTurnPlan["delivery"] = {
observeMessageSent: true,
@@ -775,7 +790,7 @@ export function createWhatsAppReplyPlan(params: {
replyToId: resolveWhatsAppDurableReplyToId({
context: params.context,
info,
msg: params.msg,
currentMessageId: params.transport.correlationId,
payload,
}),
formatting: {
@@ -836,21 +851,20 @@ export function createWhatsAppReplyPlan(params: {
err,
info: info as ReplyDeliveryInfo,
connectionId: params.connectionId,
msg: params.msg,
transport: params.transport,
replyLogger: params.replyLogger,
});
},
};
const replyOptions = {
...(ingressLifecycle ? bindIngressLifecycleToReplyOptions(ingressLifecycle) : {}),
// Message-tool-only unmentioned group turns have no automatic visible reply.
// Suppress composing there so silent background runs do not leak presence.
suppressTyping:
sourceRepliesAreToolOnly &&
conversationKind === "group" &&
!(params.msg.groupMention?.wasMentioned ?? params.msg.wasMentioned),
disableBlockStreaming,
...(sourceReplyDeliveryMode ? { sourceReplyDeliveryMode } : {}),
...(params.turnAdoptionLifecycle
? { turnAdoptionLifecycle: params.turnAdoptionLifecycle }
: {}),
suppressTyping: replyPolicy.suppressTyping,
disableBlockStreaming: replyPolicy.disableBlockStreaming,
...(replyPolicy.sourceReplyDeliveryMode
? { sourceReplyDeliveryMode: replyPolicy.sourceReplyDeliveryMode }
: {}),
onModelSelected: params.onModelSelected,
...(statusReactionController
? {

View File

@@ -95,15 +95,21 @@ vi.mock("openclaw/plugin-sdk/routing", () => ({
}),
}));
import { requireAdmittedWhatsAppInboundMessage } from "../../inbound/admission.js";
import { normalizeWebInboundMessage } from "../../inbound/message-aliases.js";
import {
createTestLegacyFlatWebInboundMessage,
createTestWebAudioInboundMessage,
} from "../../inbound/test-message.test-helper.js";
import type { WebInboundMessage } from "../../inbound/types.js";
import type { AdmittedWebInboundMessage, WebInboundMessageInput } from "../../inbound/types.js";
import { createWebOnMessageHandler } from "./on-message.js";
function makeAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage();
function admitTestMessage(msg: WebInboundMessageInput): AdmittedWebInboundMessage {
return requireAdmittedWhatsAppInboundMessage(normalizeWebInboundMessage(msg));
}
function makeAudioMsg(): AdmittedWebInboundMessage {
return admitTestMessage(createTestWebAudioInboundMessage());
}
function makeLegacyAudioMsg() {
@@ -114,42 +120,46 @@ function makeLegacyAudioMsg() {
});
}
function makeGroupAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
platform: { chatJid: "1203630@g.us" },
admission: {
conversation: {
kind: "group",
id: "1203630@g.us",
function makeGroupAudioMsg(): AdmittedWebInboundMessage {
return admitTestMessage(
createTestWebAudioInboundMessage({
platform: { chatJid: "1203630@g.us" },
admission: {
conversation: {
kind: "group",
id: "1203630@g.us",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
wasMentioned: false,
});
wasMentioned: false,
}),
);
}
function makeBlockedDirectAudioMsg(): WebInboundMessage {
return createTestWebAudioInboundMessage({
admission: {
ingress: {
admission: "drop",
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
function makeBlockedDirectAudioMsg(): AdmittedWebInboundMessage {
return admitTestMessage(
createTestWebAudioInboundMessage({
admission: {
ingress: {
admission: "drop",
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
senderAccess: {
allowed: false,
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
activationAccess: {
allowed: false,
shouldSkip: true,
reasonCode: "dm_policy_not_allowlisted",
},
},
senderAccess: {
allowed: false,
decision: "block",
reasonCode: "dm_policy_not_allowlisted",
},
activationAccess: {
allowed: false,
shouldSkip: true,
reasonCode: "dm_policy_not_allowlisted",
},
},
});
}),
);
}
function makeEchoTracker() {
@@ -281,7 +291,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
it("skips early DM ack/preflight for legacy audio without explicit access proof", async () => {
const handler = makeHandler();
await handler(makeLegacyAudioMsg());
await handler(admitTestMessage(makeLegacyAudioMsg()));
expect(events).toStrictEqual([]);
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
@@ -313,7 +323,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
} as never,
});
await handler(makeLegacyAudioMsg());
await handler(admitTestMessage(makeLegacyAudioMsg()));
expect(events).toStrictEqual(["stt"]);
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);

View File

@@ -17,11 +17,11 @@ import {
requireAdmittedWhatsAppInboundMessage,
requireWhatsAppInboundAdmission,
} from "../../inbound/admission.js";
import {
normalizeWebInboundMessage,
withDeprecatedWebInboundMessageFlatAliases,
} from "../../inbound/message-aliases.js";
import type { AdmittedWebInboundMessage, WebInboundMessageInput } from "../../inbound/types.js";
import { withDeprecatedWebInboundMessageFlatAliases } from "../../inbound/message-aliases.js";
import type {
AdmittedWebInboundMessage,
DeprecatedWebInboundAdmissionTopLevelFields,
} from "../../inbound/types.js";
import { normalizeE164 } from "../../text-runtime.js";
import { buildMentionConfig } from "../mentions.js";
import type { MentionConfig } from "../mentions.js";
@@ -38,6 +38,15 @@ import {
type StatusReactionController,
} from "./status-reaction.js";
function readDeprecatedAccessControlPassed(msg: AdmittedWebInboundMessage): boolean | undefined {
// The admitted type hides deprecated flat aliases, but normalized legacy
// listener inputs retain this one tri-state proof for preflight safety.
return (
msg as AdmittedWebInboundMessage &
Pick<DeprecatedWebInboundAdmissionTopLevelFields, "accessControlPassed">
).accessControlPassed;
}
export function createWebOnMessageHandler(params: {
cfg: OpenClawConfig;
loadConfig?: () => OpenClawConfig;
@@ -54,8 +63,12 @@ export function createWebOnMessageHandler(params: {
baseMentionConfig: MentionConfig;
account: { authDir?: string; accountId?: string; selfChatMode?: boolean };
}) {
const hasExplicitlyPassedInboundAccess = (msg: WebInboundMessageInput): boolean =>
msg.admission ? msg.admission.ingress.decision === "allow" : msg.accessControlPassed === true;
const hasExplicitlyPassedInboundAccess = (msg: AdmittedWebInboundMessage): boolean => {
if (msg.admission.ingress.decisiveGateId === "legacy-flat-compat") {
return readDeprecatedAccessControlPassed(msg) === true;
}
return msg.admission.ingress.decision === "allow";
};
const withDirectSenderPeer = (
msg: AdmittedWebInboundMessage,
@@ -140,9 +153,8 @@ export function createWebOnMessageHandler(params: {
return processMessage(processParams);
};
return async (rawMsg: WebInboundMessageInput) => {
const canRunDirectEarlyAudioPreflight = hasExplicitlyPassedInboundAccess(rawMsg);
const normalizedMsg = requireAdmittedWhatsAppInboundMessage(normalizeWebInboundMessage(rawMsg));
return async (normalizedMsg: AdmittedWebInboundMessage) => {
const canRunDirectEarlyAudioPreflight = hasExplicitlyPassedInboundAccess(normalizedMsg);
const cfg = params.loadConfig?.() ?? params.cfg;
const peerId = resolvePeerId(normalizedMsg);
const msg = withDirectSenderPeer(normalizedMsg, peerId);

View File

@@ -38,9 +38,13 @@ vi.mock("../../session.js", () => ({
formatError: (err: unknown) => String(err),
}));
vi.mock("../deliver-reply.js", () => ({
deliverWebReply: vi.fn(async () => {}),
}));
vi.mock("../deliver-reply.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../deliver-reply.js")>();
return {
...actual,
deliverWebReply: vi.fn(async () => {}),
};
});
vi.mock("../loggers.js", () => ({
whatsappInboundLog: { info: () => {}, debug: () => {} },
@@ -88,38 +92,41 @@ vi.mock("./runtime-api.js", () => ({
type: undefined,
}));
vi.mock("./inbound-dispatch.js", () => ({
buildWhatsAppInboundContext: (params: {
bodyForAgent?: string;
combinedBody: string;
commandAuthorized?: boolean;
commandBody?: string;
msg: WebInboundMsg;
mediaTranscribedIndexes?: number[];
rawBody?: string;
transcript?: string;
}) => ({
Body: params.combinedBody,
BodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
CommandAuthorized: params.commandAuthorized,
CommandBody: params.commandBody ?? params.msg.payload.body,
MediaPath: params.msg.payload.media?.path,
MediaType: params.msg.payload.media?.type,
MediaTranscribedIndexes: params.mediaTranscribedIndexes,
RawBody: params.rawBody ?? params.msg.payload.body,
Transcript: params.transcript,
}),
createWhatsAppReplyPlan: vi.fn((params: { replyResolver?: unknown }) => ({
dispatcherOptions: {},
delivery: { deliver: async () => {} },
replyOptions: {},
replyResolver: params.replyResolver,
finalize: () => true,
})),
resolveWhatsAppDmRouteTarget: () => "+15550000002",
resolveWhatsAppResponsePrefix: () => undefined,
updateWhatsAppMainLastRoute: () => {},
}));
vi.mock("./inbound-dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-dispatch.js")>();
return {
...actual,
prepareWhatsAppInboundContext: async (
params: Parameters<typeof actual.prepareWhatsAppInboundContext>[0],
) => {
const prepared = await actual.prepareWhatsAppInboundContext(params);
return {
...prepared,
ctxPayload: {
Body: params.combinedBody,
BodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
CommandAuthorized: params.command?.authorization.kind === "authorized",
CommandBody: params.command?.body ?? params.msg.payload.body,
MediaPath: params.msg.payload.media?.path,
MediaType: params.msg.payload.media?.type,
MediaTranscribedIndexes: params.mediaTranscribedIndexes,
RawBody: params.rawBody ?? params.msg.payload.body,
Transcript: params.transcript,
},
};
},
createWhatsAppReplyPlan: vi.fn((params: { replyResolver?: unknown }) => ({
dispatcherOptions: {},
delivery: { deliver: async () => {} },
replyOptions: {},
replyResolver: params.replyResolver,
finalize: () => true,
})),
resolveWhatsAppDmRouteTarget: () => "+15550000002",
resolveWhatsAppResponsePrefix: () => undefined,
updateWhatsAppMainLastRoute: () => {},
};
});
import { createWhatsAppReplyPlan } from "./inbound-dispatch.js";
import { processMessage } from "./process-message.js";

View File

@@ -9,6 +9,8 @@ const {
buildContextMock,
isControlCommandMessageMock,
dispatchBufferedReplyMock,
replyPlanParamsMock,
runChannelInboundEventParamsMock,
runMessageReceivedMock,
shouldComputeCommandAuthorizedMock,
trackBackgroundTaskMock,
@@ -20,11 +22,24 @@ const {
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
})),
replyPlanParamsMock: vi.fn(),
runChannelInboundEventParamsMock: vi.fn(),
runMessageReceivedMock: vi.fn(async () => undefined),
shouldComputeCommandAuthorizedMock: vi.fn(() => false),
trackBackgroundTaskMock: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
runChannelInboundEvent: async (params: Parameters<typeof actual.runChannelInboundEvent>[0]) => {
runChannelInboundEventParamsMock(params);
return await actual.runChannelInboundEvent(params);
},
};
});
vi.mock("../../inbound-policy.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
return {
@@ -38,9 +53,18 @@ vi.mock("./inbound-dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./inbound-dispatch.js")>();
return {
...actual,
buildWhatsAppInboundContext: buildContextMock,
prepareWhatsAppInboundContext: async (
params: Parameters<typeof actual.prepareWhatsAppInboundContext>[0],
) => {
const prepared = await actual.prepareWhatsAppInboundContext(params);
return {
...prepared,
ctxPayload: buildContextMock(params),
};
},
createWhatsAppReplyPlan: (...args: unknown[]) => {
const params = args[0] as { replyResolver?: unknown };
replyPlanParamsMock(params);
void dispatchBufferedReplyMock(params);
return {
dispatcherOptions: {},
@@ -151,6 +175,7 @@ vi.mock("./runtime-api.js", async (importOriginal) => {
});
import { clearInternalHooks, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
import { attachWhatsAppIngressLifecycle } from "../../inbound/ingress-lifecycle.js";
import { processMessage } from "./process-message.js";
// ---------------------------------------------------------------------------
@@ -282,6 +307,8 @@ describe("processMessage group system prompt wiring", () => {
isControlCommandMessageMock.mockReset();
isControlCommandMessageMock.mockReturnValue(false);
resolvePolicyMock.mockReset();
replyPlanParamsMock.mockClear();
runChannelInboundEventParamsMock.mockClear();
runMessageReceivedMock.mockClear();
shouldComputeCommandAuthorizedMock.mockReset();
shouldComputeCommandAuthorizedMock.mockReturnValue(false);
@@ -329,7 +356,7 @@ describe("processMessage group system prompt wiring", () => {
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({
command: {
kind: "text-slash",
authorized: true,
authorization: { kind: "authorized" },
body: "/status",
},
rawBody: "/status",
@@ -354,7 +381,7 @@ describe("processMessage group system prompt wiring", () => {
bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]",
command: {
kind: "text-slash",
authorized: true,
authorization: { kind: "authorized" },
body: "/reset",
},
rawBody: "/reset",
@@ -373,7 +400,7 @@ describe("processMessage group system prompt wiring", () => {
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({
command: {
kind: "normal",
authorized: true,
authorization: { kind: "authorized" },
body: "please inspect `/tmp/foo`",
},
rawBody: "please inspect `/tmp/foo`",
@@ -557,6 +584,38 @@ describe("processMessage group system prompt wiring", () => {
);
});
it("passes one lifecycle identity through the portable boundary and reply plan", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({
Body: "hi",
RawBody: "hi",
CommandBody: "hi",
SessionKey: baseRoute.sessionKey,
Provider: "whatsapp",
Surface: "whatsapp",
}));
const lifecycle = {
abortSignal: new AbortController().signal,
onAdopted: vi.fn(async () => undefined),
onDeferred: vi.fn(),
onAbandoned: vi.fn(async () => undefined),
};
const msg = attachWhatsAppIngressLifecycle(makeBaseMsg(), lifecycle as never);
await callProcessMessage({ msg });
const runParams = mockCallArg(runChannelInboundEventParamsMock, "runChannelInboundEvent") as {
raw?: unknown;
turnAdoptionLifecycle?: unknown;
};
const replyPlanParams = mockCallArg(replyPlanParamsMock, "createWhatsAppReplyPlan") as {
turnAdoptionLifecycle?: unknown;
};
expect(runParams.turnAdoptionLifecycle).toBe(replyPlanParams.turnAdoptionLifecycle);
expect(runParams.raw).not.toHaveProperty("platform");
expect(runParams.raw).not.toHaveProperty("admission");
});
it("drops blocked admission before session record and reply dispatch", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
buildContextMock.mockImplementationOnce(() => ({

View File

@@ -8,6 +8,7 @@ import {
formatMediaPlaceholderText,
runChannelInboundEvent,
} from "openclaw/plugin-sdk/channel-inbound";
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
import {
createInternalHookEvent,
deriveInboundMessageHookContext,
@@ -25,6 +26,7 @@ import {
resolveWhatsAppInboundPolicy,
} from "../../inbound-policy.js";
import { requireWhatsAppInboundAdmission } from "../../inbound/admission.js";
import { resolveWhatsAppIngressLifecycle } from "../../inbound/ingress-lifecycle.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
@@ -43,8 +45,9 @@ import {
type GroupHistoryEntry,
} from "./inbound-context.js";
import {
buildWhatsAppInboundContext,
buildWhatsAppInboundTransportContext,
createWhatsAppReplyPlan,
prepareWhatsAppInboundContext,
resolveWhatsAppDmRouteTarget,
resolveWhatsAppResponsePrefix,
updateWhatsAppMainLastRoute,
@@ -118,7 +121,7 @@ function shouldEmitWhatsAppMessageReceivedHooks(params: {
}
function emitWhatsAppMessageReceivedHooks(params: {
ctx: Awaited<ReturnType<typeof buildWhatsAppInboundContext>>;
ctx: Awaited<ReturnType<typeof prepareWhatsAppInboundContext>>["ctxPayload"];
sessionKey: string;
}): void {
const canonical = deriveInboundMessageHookContext(params.ctx);
@@ -153,7 +156,7 @@ function emitWhatsAppMessageReceivedHooks(params: {
function emitWhatsAppMessageReceivedHooksIfEnabled(params: {
cfg: ReturnType<LoadConfigFn>;
ctx: Awaited<ReturnType<typeof buildWhatsAppInboundContext>>;
ctx: Awaited<ReturnType<typeof prepareWhatsAppInboundContext>>["ctxPayload"];
accountId?: string;
sessionKey: string;
}): void {
@@ -470,13 +473,19 @@ export async function processMessage(params: {
peerId: dmRouteTarget ?? conversationId,
});
const ctxPayload = await buildWhatsAppInboundContext({
const commandAuthorization =
commandAuthorized === undefined
? ({ kind: "not_checked" } as const)
: commandAuthorized
? ({ kind: "authorized" } as const)
: ({ kind: "denied" } as const);
const prepared = await prepareWhatsAppInboundContext({
bodyForAgent: msgForAgent.payload.body,
combinedBody,
command: {
kind: isTextCommand ? "text-slash" : "normal",
body: commandBody,
authorized: commandAuthorized,
authorization: commandAuthorization,
},
groupHistory: visibleGroupHistory,
groupHistoryLimit: params.groupHistoryLimit,
@@ -496,6 +505,12 @@ export async function processMessage(params: {
visibleReplyTo: visibleReplyTo ?? undefined,
suppressMessageReceivedHooks: true,
});
const { inbound, turnInput, ctxPayload } = prepared;
const transport = buildWhatsAppInboundTransportContext(params.msg);
const ingressLifecycle = resolveWhatsAppIngressLifecycle(params.msg);
const turnAdoptionLifecycle = ingressLifecycle
? bindIngressLifecycleToReplyOptions(ingressLifecycle).turnAdoptionLifecycle
: undefined;
emitWhatsAppMessageReceivedHooksIfEnabled({
cfg: params.cfg,
ctx: ctxPayload,
@@ -522,16 +537,10 @@ export async function processMessage(params: {
const turnResult = await runChannelInboundEvent({
channel: "whatsapp",
accountId: params.route.accountId,
raw: params.msg,
raw: inbound,
...(turnAdoptionLifecycle ? { turnAdoptionLifecycle } : {}),
adapter: {
ingest: () => ({
id: params.msg.event.id ?? `${conversationId}:${Date.now()}`,
timestamp: params.msg.event.timestamp,
rawText: ctxPayload.RawBody ?? "",
textForAgent: ctxPayload.BodyForAgent,
textForCommands: ctxPayload.CommandBody,
raw: params.msg,
}),
ingest: () => turnInput,
preflight: () => {
const reason = admission.ingress.reasonCode;
if (admission.ingress.admission === "dispatch") {
@@ -561,7 +570,7 @@ export async function processMessage(params: {
groupHistoryKey: params.groupHistoryKey,
maxMediaBytes: params.maxMediaBytes,
maxMediaTextChunkLimit: params.maxMediaTextChunkLimit,
msg: params.msg,
inbound,
onModelSelected,
rememberSentText: params.rememberSentText,
replyLogger: params.replyLogger,
@@ -573,6 +582,8 @@ export async function processMessage(params: {
route: params.route,
shouldClearGroupHistory,
statusReactionController,
transport,
turnAdoptionLifecycle,
});
finalizeReply = finalize;
return {

View File

@@ -2,11 +2,7 @@
export { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime";
export { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
export { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-inbound";
export { toLocationContext } from "openclaw/plugin-sdk/channel-inbound";
export {
createChannelMessageReplyPipeline,
resolveChannelMessageSourceReplyDeliveryMode,
} from "openclaw/plugin-sdk/channel-outbound";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
export {
isControlCommandMessage,
shouldComputeCommandAuthorized,

View File

@@ -6,7 +6,10 @@ import {
} from "./message-aliases.js";
import type { monitorWebInbox } from "./monitor.js";
import { createAcceptedWhatsAppSendResult } from "./send-result.test-helper.js";
import { createTestWhatsAppInboundAdmission } from "./test-message.test-helper.js";
import {
createTestLegacyFlatWebInboundMessage,
createTestWhatsAppInboundAdmission,
} from "./test-message.test-helper.js";
import type { LegacyFlatWebInboundMessage, WebInboundCallbackMessage } from "./types.js";
type MonitorWebInboxMessage = Parameters<Parameters<typeof monitorWebInbox>[0]["onMessage"]>[0];
@@ -361,7 +364,7 @@ describe("WhatsApp inbound flat aliases", () => {
decisiveGateId: "legacy-flat-compat",
reasonCode: "dm_policy_allowlisted",
});
expect(normalized.accessControlPassed).toBe(true);
expect(normalized.accessControlPassed).toBeUndefined();
expect(normalized.quote).toMatchObject({
id: "quote-legacy",
body: "legacy quoted",
@@ -414,4 +417,16 @@ describe("WhatsApp inbound flat aliases", () => {
});
expect(normalized.accessControlPassed).toBe(false);
});
it("preserves explicit legacy access proof through normalization", () => {
const normalized = normalizeWebInboundMessage({
...createTestLegacyFlatWebInboundMessage(),
accessControlPassed: true,
});
expect(normalized.admission?.ingress.decisiveGateId).toBe("legacy-flat-compat");
expect(normalized.accessControlPassed).toBe(true);
normalized.accessControlPassed = false;
expect(normalized.accessControlPassed).toBe(true);
});
});

View File

@@ -192,12 +192,22 @@ function defineDeprecatedAdmissionTopLevelAccessors<T extends WebInboundCallback
},
},
accessControlPassed: {
get: () =>
msg.admission ? msg.admission.ingress.decision === "allow" : fallbackAccessControlPassed,
get: () => {
// Legacy flat inputs used absence to mean access was not explicitly proven.
// Preserve that tri-state after normalization so preflight work cannot run early.
if (msg.admission?.ingress.decisiveGateId === "legacy-flat-compat") {
return fallbackAccessControlPassed;
}
return msg.admission
? msg.admission.ingress.decision === "allow"
: fallbackAccessControlPassed;
},
set: (value) => {
// The legacy boolean is derived from the ingress graph; writes only preserve
// no-admission legacy inputs instead of fabricating a partial graph update.
fallbackAccessControlPassed = value as boolean | undefined;
if (!msg.admission) {
fallbackAccessControlPassed = value as boolean | undefined;
}
},
},
chatType: {

View File

@@ -84,13 +84,8 @@ type MonitorWebInboxOptions = {
durableInboundQueue?: WhatsAppDurableInboundQueue;
};
type AttachWebInboxToSocketOptions = Omit<
MonitorWebInboxOptions,
"onMessage" | "shouldDebounce" | "socketTiming"
> & {
type AttachWebInboxToSocketOptions = Omit<MonitorWebInboxOptions, "socketTiming"> & {
socketTiming: Required<WhatsAppSocketTimingOptions>;
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
};
export async function attachWebInboxToSocket(

View File

@@ -204,7 +204,8 @@ 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.
4774,
// +3: prepared channel inbound envelope, projection, and reply-policy contracts.
4777,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -242,7 +243,8 @@ 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.
2882,
// +2: prepared channel inbound projection and reply-policy resolution.
2884,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -0,0 +1,125 @@
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,
type BuildChannelInboundEventContextParams,
type BuiltChannelInboundEventContext,
} from "./context.js";
type PreparedChannelInboundCommandAuthorization =
| { kind: "not_checked" }
| { kind: "authorized" }
| { kind: "denied"; reason?: string };
type PreparedChannelInboundCommand = Omit<CommandFacts, "authorized"> & {
authorization: PreparedChannelInboundCommandAuthorization;
};
type PreparedChannelInboundContextFacts = {
transcript?: string;
/** Null suppresses the generic fallback from conversation label to group subject. */
groupSubject?: string | null;
groupMembers?: string;
senderE164?: string;
replyThreading?: ReplyThreadingPolicy;
location?: NormalizedLocation;
};
export type PreparedChannelInbound = Pick<
BuildChannelInboundEventContextParams,
| "channel"
| "accountId"
| "provider"
| "surface"
| "from"
| "sender"
| "conversation"
| "route"
| "reply"
| "message"
| "sessionTranscript"
| "media"
| "contextVisibility"
> & {
event: {
id: string;
fullId?: string;
timestamp?: number;
};
command?: PreparedChannelInboundCommand;
mentions?: NonNullable<BuildChannelInboundEventContextParams["access"]>["mentions"];
supplemental?: SupplementalContextFacts;
context?: PreparedChannelInboundContextFacts;
};
type PreparedChannelInboundControl = {
messageReceivedHooks: "channel" | "core";
};
function resolvePreparedCommandFacts(
command: PreparedChannelInboundCommand | undefined,
): CommandFacts | undefined {
if (!command) {
return undefined;
}
const { authorization, ...facts } = command;
return {
...facts,
...(authorization.kind === "not_checked"
? {}
: { authorized: authorization.kind === "authorized" }),
};
}
export function projectPreparedChannelInbound(params: {
inbound: PreparedChannelInbound;
control: PreparedChannelInboundControl;
}): {
input: NormalizedTurnInput;
context: BuiltChannelInboundEventContext;
} {
const { inbound, control } = params;
const command = resolvePreparedCommandFacts(inbound.command);
const commandAuthorization = inbound.command?.authorization;
const commandAccess =
commandAuthorization && commandAuthorization.kind !== "not_checked"
? { authorized: commandAuthorization.kind === "authorized" }
: undefined;
return {
input: {
id: inbound.event.id,
timestamp: inbound.event.timestamp,
rawText: inbound.message.rawBody,
textForAgent: inbound.message.bodyForAgent,
textForCommands: inbound.message.commandBody,
raw: inbound,
},
context: buildChannelInboundEventContext({
...inbound,
messageId: inbound.event.id,
messageIdFull: inbound.event.fullId,
timestamp: inbound.event.timestamp,
command,
access:
inbound.mentions || commandAccess
? {
mentions: inbound.mentions,
commands: commandAccess,
}
: undefined,
extra: {
Transcript: inbound.context?.transcript,
...(inbound.context && "groupSubject" in inbound.context
? { GroupSubject: inbound.context.groupSubject ?? undefined }
: {}),
GroupMembers: inbound.context?.groupMembers,
SenderE164: inbound.context?.senderE164,
ReplyThreading: inbound.context?.replyThreading,
SuppressMessageReceivedHooks: control.messageReceivedHooks === "channel",
...(inbound.context?.location ? toLocationContext(inbound.context.location) : {}),
},
}),
};
}

View File

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

View File

@@ -45,6 +45,43 @@ 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,6 +5,8 @@ import { describe, expect, expectTypeOf, it } from "vitest";
import {
buildChannelInboundEventContext,
type BuildChannelInboundEventContextParams,
type PreparedChannelInbound,
projectPreparedChannelInbound,
type PluginHookChannelSenderContext,
} from "./channel-inbound.js";
@@ -71,4 +73,97 @@ 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,6 +114,11 @@ 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.
*