mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 07:01:34 +00:00
refactor(whatsapp): keep inbound boundary transport-private
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -43,7 +43,6 @@ export {
|
||||
createReplyPrefixContext,
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
resolveChannelInboundReplyPolicy,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "./reply-pipeline.js";
|
||||
export type { ChannelIngressDrain } from "./ingress-drain.js";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user