Skip to content

Commit cbca976

Browse files
committed
fix(whatsapp): preserve shipped admission callbacks
1 parent e23b866 commit cbca976

4 files changed

Lines changed: 125 additions & 57 deletions

File tree

extensions/whatsapp/src/auto-reply/monitor/on-message.audio-preflight.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,33 @@ import {
9999
createTestLegacyFlatWebInboundMessage,
100100
createTestWebAudioInboundMessage,
101101
} from "../../inbound/test-message.test-helper.js";
102-
import type { WebInboundMessage } from "../../inbound/types.js";
102+
import type { WebInboundCallbackMessage, WebInboundMessage } from "../../inbound/types.js";
103103
import { createWebOnMessageHandler } from "./on-message.js";
104104

105105
function makeAudioMsg(): WebInboundMessage {
106106
return createTestWebAudioInboundMessage();
107107
}
108108

109+
function makeShippedNestedAudioMsg(params: {
110+
admission: "dispatch" | "observe" | "skip" | "drop";
111+
reasonCode: "activation_allowed" | "activation_skipped" | "route_blocked";
112+
}): WebInboundCallbackMessage {
113+
const msg = createTestWebAudioInboundMessage();
114+
const { turnAdmission: _turnAdmission, ...admission } = msg.admission!;
115+
return {
116+
...msg,
117+
admission: {
118+
...admission,
119+
ingress: {
120+
...admission.ingress,
121+
admission: params.admission,
122+
decision: params.admission === "drop" ? "block" : "allow",
123+
reasonCode: params.reasonCode,
124+
},
125+
},
126+
};
127+
}
128+
109129
function makeLegacyAudioMsg() {
110130
return createTestLegacyFlatWebInboundMessage({
111131
body: "<media:audio>",
@@ -242,6 +262,29 @@ describe("createWebOnMessageHandler audio preflight", () => {
242262
expect(processParams.ackReaction).toBe(ackReactionHandle);
243263
});
244264

265+
it.each([
266+
["dispatch", "activation_allowed", { kind: "dispatch", reason: "activation_allowed" }, true],
267+
["observe", "activation_allowed", { kind: "observeOnly", reason: "activation_allowed" }, true],
268+
["skip", "activation_skipped", { kind: "handled", reason: "activation_skipped" }, false],
269+
[
270+
"drop",
271+
"route_blocked",
272+
{ kind: "drop", reason: "route_blocked", recordHistory: false },
273+
false,
274+
],
275+
] as const)(
276+
"normalizes shipped nested %s admission before gating the turn",
277+
async (admission, reasonCode, expectedTurnAdmission, shouldProcess) => {
278+
const msg = makeShippedNestedAudioMsg({ admission, reasonCode });
279+
const handler = makeHandler();
280+
281+
await handler(msg);
282+
283+
expect(msg.admission).toMatchObject({ turnAdmission: expectedTurnAdmission });
284+
expect(processMessageMock).toHaveBeenCalledTimes(shouldProcess ? 1 : 0);
285+
},
286+
);
287+
245288
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
246289
const handler = makeHandler({
247290
cfg: {

extensions/whatsapp/src/inbound/admission.ts

Lines changed: 65 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ type WhatsAppInboundActivationAccess = Pick<
2929
"ran" | "allowed" | "shouldSkip" | "reasonCode"
3030
>;
3131

32-
type WhatsAppInboundAdmissionAccess = Pick<
33-
ResolvedChannelMessageIngress,
34-
"ingress" | "senderAccess" | "commandAccess" | "activationAccess"
35-
>;
32+
type WhatsAppInboundAdmissionAccess = {
33+
ingress: WhatsAppInboundIngressDecision;
34+
senderAccess: WhatsAppInboundSenderAccess;
35+
commandAccess: WhatsAppInboundCommandAccess;
36+
activationAccess: WhatsAppInboundActivationAccess;
37+
};
3638

3739
type WhatsAppInboundAdmissionPolicy = {
3840
account: {
@@ -105,6 +107,24 @@ export type WhatsAppInboundAdmission = {
105107
turnAdmission: WhatsAppInboundTurnAdmission;
106108
};
107109

110+
export type WhatsAppInboundAdmissionInput =
111+
| WhatsAppInboundAdmission
112+
| Omit<WhatsAppInboundAdmission, "turnAdmission">;
113+
114+
export function normalizeWhatsAppInboundAdmission(
115+
admission: WhatsAppInboundAdmissionInput,
116+
): WhatsAppInboundAdmission {
117+
if ("turnAdmission" in admission) {
118+
return admission;
119+
}
120+
// Shipped nested callbacks predate turnAdmission. Normalize once at the
121+
// callback boundary so all runtime consumers receive the canonical shape.
122+
return {
123+
...admission,
124+
turnAdmission: mapChannelIngressDecisionToTurnAdmission(admission.ingress),
125+
};
126+
}
127+
108128
function copyAccount(
109129
account: WhatsAppInboundAdmissionPolicy["account"],
110130
): WhatsAppInboundAdmission["account"] {
@@ -197,53 +217,49 @@ export function buildDeprecatedFlatWhatsAppInboundAdmission(
197217
? "group_policy_allowed"
198218
: "dm_policy_allowlisted"
199219
: "no_policy_match";
200-
const ingress: WhatsAppInboundIngressDecision = {
201-
admission: admitted ? "dispatch" : "drop",
202-
decision: admitted ? "allow" : "block",
203-
decisiveGateId: "legacy-flat-compat",
204-
reasonCode,
205-
};
206-
207-
// Compatibility only: flat listenerFactory inputs predate SDK ingress, so
208-
// construct their legacy admission directly instead of inventing an access graph.
209-
return {
210-
accountId,
211-
isSelfChat: false,
212-
account: {
213-
accountId,
214-
enabled: true,
215-
sendReadReceipts: true,
220+
// Compatibility only: deprecated listenerFactory flat inputs predate the
221+
// admission envelope, so convert them through the canonical admission builder.
222+
// Canonical nested inputs without admission remain malformed for runtime use.
223+
return buildWhatsAppInboundAdmission({
224+
policy: {
225+
account: {
226+
accountId,
227+
enabled: true,
228+
sendReadReceipts: true,
229+
},
230+
isSelfChat: false,
231+
isSamePhone: () => false,
216232
},
217-
conversation: {
218-
kind: msg.chatType === "group" ? "group" : "direct",
219-
id: conversationId,
220-
groupSessionId: resolveWhatsAppGroupConversationId(conversationId),
233+
access: {
234+
ingress: {
235+
admission: admitted ? "dispatch" : "drop",
236+
decision: admitted ? "allow" : "block",
237+
decisiveGateId: "legacy-flat-compat",
238+
reasonCode,
239+
},
240+
senderAccess: {
241+
allowed: admitted,
242+
decision: admitted ? "allow" : "block",
243+
reasonCode,
244+
providerMissingFallbackApplied: false,
245+
},
246+
commandAccess: {
247+
requested: false,
248+
authorized: false,
249+
shouldBlockControlCommand: false,
250+
reasonCode: "command_authorized",
251+
},
252+
activationAccess: {
253+
ran: false,
254+
allowed: admitted,
255+
shouldSkip: !admitted,
256+
reasonCode: admitted ? "activation_allowed" : "activation_skipped",
257+
},
221258
},
222-
sender: {
223-
id: senderId,
224-
isSamePhone: false,
225-
},
226-
ingress,
227-
senderAccess: {
228-
allowed: admitted,
229-
decision: admitted ? "allow" : "block",
230-
reasonCode,
231-
providerMissingFallbackApplied: false,
232-
},
233-
commandAccess: {
234-
requested: false,
235-
authorized: false,
236-
shouldBlockControlCommand: false,
237-
reasonCode: "command_authorized",
238-
},
239-
activationAccess: {
240-
ran: false,
241-
allowed: admitted,
242-
shouldSkip: !admitted,
243-
reasonCode: admitted ? "activation_allowed" : "activation_skipped",
244-
},
245-
turnAdmission: mapChannelIngressDecisionToTurnAdmission(ingress),
246-
};
259+
isGroup: msg.chatType === "group",
260+
conversationId,
261+
senderId,
262+
});
247263
}
248264

249265
export function requireWhatsAppInboundAdmission(

extensions/whatsapp/src/inbound/message-aliases.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { buildDeprecatedFlatWhatsAppInboundAdmission } from "./admission.js";
1+
import {
2+
buildDeprecatedFlatWhatsAppInboundAdmission,
3+
normalizeWhatsAppInboundAdmission,
4+
} from "./admission.js";
25
import { resolveWhatsAppGroupConversationId } from "./group-conversation.js";
36
import type {
47
DeprecatedWebInboundAdmissionTopLevelFields,
@@ -202,6 +205,9 @@ function defineDeprecatedAdmissionTopLevelAccessors<T extends WebInboundCallback
202205
export function withDeprecatedWebInboundMessageFlatAliases<T extends WebInboundCallbackMessage>(
203206
msg: T,
204207
): T & WebInboundMessage {
208+
if (msg.admission) {
209+
msg.admission = normalizeWhatsAppInboundAdmission(msg.admission);
210+
}
205211
// Keep the shipped callback shape alive while nested/admission contexts remain canonical.
206212
const withAdmissionAliases = defineDeprecatedAdmissionTopLevelAccessors(msg);
207213
return defineDeprecatedAliasAccessors(withAdmissionAliases, {

extensions/whatsapp/src/inbound/types.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
44
import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
55
import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
66
import type { DeprecatedWebInboundAdmissionTopLevelFields } from "./admission-types.js";
7-
import type { WhatsAppInboundAdmission } from "./admission.js";
7+
import type { WhatsAppInboundAdmission, WhatsAppInboundAdmissionInput } from "./admission.js";
88
import type { WhatsAppSendResult } from "./send-result.js";
99

1010
export type { DeprecatedWebInboundAdmissionTopLevelFields } from "./admission-types.js";
@@ -216,8 +216,10 @@ type WebInboundCallbackMessageCommon = {
216216
};
217217

218218
type WebInboundCallbackAdmissionFields =
219-
| ({ admission: WhatsAppInboundAdmission } & Partial<DeprecatedWebInboundAdmissionTopLevelFields>)
220-
| ({ admission?: WhatsAppInboundAdmission } & DeprecatedWebInboundAdmissionTopLevelFields);
219+
| ({
220+
admission: WhatsAppInboundAdmissionInput;
221+
} & Partial<DeprecatedWebInboundAdmissionTopLevelFields>)
222+
| ({ admission?: WhatsAppInboundAdmissionInput } & DeprecatedWebInboundAdmissionTopLevelFields);
221223

222224
export type WebInboundCallbackMessage = WebInboundCallbackMessageCommon &
223225
WebInboundCallbackAdmissionFields & {
@@ -226,8 +228,9 @@ export type WebInboundCallbackMessage = WebInboundCallbackMessageCommon &
226228
platform: WhatsAppInboundPlatform;
227229
};
228230

229-
export type WebInboundMessage = WebInboundCallbackMessage &
230-
DeprecatedWebInboundAdmissionTopLevelFields &
231+
export type WebInboundMessage = Omit<WebInboundCallbackMessage, "admission"> & {
232+
admission?: WhatsAppInboundAdmission;
233+
} & DeprecatedWebInboundAdmissionTopLevelFields &
231234
DeprecatedWebInboundMessageFlatAliases;
232235

233236
export type AdmittedWebInboundMessage = Omit<
@@ -239,7 +242,7 @@ export type AdmittedWebInboundMessage = Omit<
239242

240243
export type LegacyFlatWebInboundMessage = DeprecatedWebInboundAdmissionTopLevelFields &
241244
Pick<WebInboundCallbackMessageCommon, "wasMentioned"> & {
242-
admission?: WhatsAppInboundAdmission;
245+
admission?: WhatsAppInboundAdmissionInput;
243246
} & DeprecatedWebInboundMessageFlatAliases & {
244247
event?: never;
245248
payload?: never;

0 commit comments

Comments
 (0)