@openclaw/whatsapp 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/action-runtime-api.ts +1 -0
  2. package/action-runtime.runtime.ts +1 -0
  3. package/api.ts +213 -0
  4. package/auth-presence.ts +80 -0
  5. package/channel-config-api.ts +1 -0
  6. package/channel-plugin-api.ts +3 -0
  7. package/config-api.ts +4 -0
  8. package/constants.ts +1 -0
  9. package/contract-api.ts +29 -0
  10. package/directory-contract-api.ts +4 -0
  11. package/dist/.boundary-tsc.stamp +1 -0
  12. package/dist/.boundary-tsc.tsbuildinfo +1 -0
  13. package/doctor-contract-api.ts +8 -0
  14. package/index.test.ts +13 -0
  15. package/index.ts +16 -0
  16. package/legacy-session-surface-api.ts +6 -0
  17. package/legacy-state-migrations-api.ts +1 -0
  18. package/light-runtime-api.ts +12 -0
  19. package/login-qr-api.ts +1 -0
  20. package/login-qr-runtime.ts +23 -0
  21. package/openclaw.plugin.json +742 -0
  22. package/outbound-payload-test-api.ts +1 -0
  23. package/package.json +74 -0
  24. package/runtime-api.ts +86 -0
  25. package/secret-contract-api.ts +4 -0
  26. package/security-contract-api.ts +4 -0
  27. package/setup-entry.test.ts +21 -0
  28. package/setup-entry.ts +21 -0
  29. package/setup-plugin-api.ts +3 -0
  30. package/src/account-config.ts +77 -0
  31. package/src/account-ids.ts +13 -0
  32. package/src/account-types.ts +5 -0
  33. package/src/accounts.test.ts +182 -0
  34. package/src/accounts.ts +176 -0
  35. package/src/accounts.whatsapp-auth.test.ts +59 -0
  36. package/src/action-runtime-target-auth.ts +27 -0
  37. package/src/action-runtime.test.ts +330 -0
  38. package/src/action-runtime.ts +76 -0
  39. package/src/active-listener.test.ts +65 -0
  40. package/src/active-listener.ts +17 -0
  41. package/src/agent-tools-login.test.ts +81 -0
  42. package/src/agent-tools-login.ts +113 -0
  43. package/src/approval-auth.test.ts +24 -0
  44. package/src/approval-auth.ts +27 -0
  45. package/src/auth-store.runtime.ts +1 -0
  46. package/src/auth-store.test.ts +311 -0
  47. package/src/auth-store.ts +502 -0
  48. package/src/auto-reply/config.runtime.ts +16 -0
  49. package/src/auto-reply/constants.ts +1 -0
  50. package/src/auto-reply/deliver-reply.test.ts +843 -0
  51. package/src/auto-reply/deliver-reply.ts +279 -0
  52. package/src/auto-reply/heartbeat-runner.runtime.ts +33 -0
  53. package/src/auto-reply/heartbeat-runner.test.ts +214 -0
  54. package/src/auto-reply/heartbeat-runner.ts +330 -0
  55. package/src/auto-reply/loggers.ts +6 -0
  56. package/src/auto-reply/mentions.ts +131 -0
  57. package/src/auto-reply/monitor/ack-reaction.test.ts +170 -0
  58. package/src/auto-reply/monitor/ack-reaction.ts +99 -0
  59. package/src/auto-reply/monitor/audio-preflight.runtime.ts +9 -0
  60. package/src/auto-reply/monitor/broadcast.ts +153 -0
  61. package/src/auto-reply/monitor/commands.ts +19 -0
  62. package/src/auto-reply/monitor/echo.ts +64 -0
  63. package/src/auto-reply/monitor/group-activation.runtime.ts +1 -0
  64. package/src/auto-reply/monitor/group-activation.test.ts +189 -0
  65. package/src/auto-reply/monitor/group-activation.ts +73 -0
  66. package/src/auto-reply/monitor/group-gating.audio-preflight.test.ts +103 -0
  67. package/src/auto-reply/monitor/group-gating.runtime.ts +8 -0
  68. package/src/auto-reply/monitor/group-gating.ts +217 -0
  69. package/src/auto-reply/monitor/group-members.test.ts +56 -0
  70. package/src/auto-reply/monitor/group-members.ts +65 -0
  71. package/src/auto-reply/monitor/inbound-context.test.ts +97 -0
  72. package/src/auto-reply/monitor/inbound-context.ts +92 -0
  73. package/src/auto-reply/monitor/inbound-dispatch.runtime.ts +21 -0
  74. package/src/auto-reply/monitor/inbound-dispatch.test.ts +895 -0
  75. package/src/auto-reply/monitor/inbound-dispatch.ts +422 -0
  76. package/src/auto-reply/monitor/last-route.test.ts +37 -0
  77. package/src/auto-reply/monitor/last-route.ts +61 -0
  78. package/src/auto-reply/monitor/message-line.runtime.ts +38 -0
  79. package/src/auto-reply/monitor/message-line.ts +54 -0
  80. package/src/auto-reply/monitor/on-message.audio-preflight.test.ts +354 -0
  81. package/src/auto-reply/monitor/on-message.ts +291 -0
  82. package/src/auto-reply/monitor/peer.ts +17 -0
  83. package/src/auto-reply/monitor/process-message.audio-preflight.test.ts +420 -0
  84. package/src/auto-reply/monitor/process-message.test.ts +355 -0
  85. package/src/auto-reply/monitor/process-message.ts +531 -0
  86. package/src/auto-reply/monitor/runtime-api.ts +30 -0
  87. package/src/auto-reply/monitor-state.test.ts +64 -0
  88. package/src/auto-reply/monitor-state.ts +106 -0
  89. package/src/auto-reply/monitor.ts +600 -0
  90. package/src/auto-reply/reply-resolver.runtime.ts +1 -0
  91. package/src/auto-reply/session-snapshot.ts +69 -0
  92. package/src/auto-reply/types.ts +48 -0
  93. package/src/auto-reply/util.ts +63 -0
  94. package/src/auto-reply/web-auto-reply-monitor.test.ts +687 -0
  95. package/src/auto-reply/web-auto-reply-utils.test.ts +327 -0
  96. package/src/auto-reply.broadcast-groups.combined.test.ts +246 -0
  97. package/src/auto-reply.broadcast-groups.test-harness.ts +47 -0
  98. package/src/auto-reply.impl.ts +7 -0
  99. package/src/auto-reply.test-harness.ts +421 -0
  100. package/src/auto-reply.ts +1 -0
  101. package/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +427 -0
  102. package/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +908 -0
  103. package/src/auto-reply.web-auto-reply.last-route.test.ts +184 -0
  104. package/src/channel-actions.runtime.ts +7 -0
  105. package/src/channel-actions.test.ts +216 -0
  106. package/src/channel-actions.ts +84 -0
  107. package/src/channel-outbound.test.ts +131 -0
  108. package/src/channel-outbound.ts +36 -0
  109. package/src/channel-react-action.runtime.ts +7 -0
  110. package/src/channel-react-action.test.ts +294 -0
  111. package/src/channel-react-action.ts +84 -0
  112. package/src/channel.runtime.ts +117 -0
  113. package/src/channel.setup.test.ts +461 -0
  114. package/src/channel.setup.ts +28 -0
  115. package/src/channel.ts +336 -0
  116. package/src/command-policy.ts +7 -0
  117. package/src/config-accessors.test.ts +34 -0
  118. package/src/config-accessors.ts +22 -0
  119. package/src/config-schema.test.ts +121 -0
  120. package/src/config-schema.ts +6 -0
  121. package/src/config-ui-hints.ts +24 -0
  122. package/src/connection-controller-registry.test.ts +27 -0
  123. package/src/connection-controller-registry.ts +49 -0
  124. package/src/connection-controller.test.ts +282 -0
  125. package/src/connection-controller.ts +679 -0
  126. package/src/creds-files.ts +19 -0
  127. package/src/creds-persistence.ts +102 -0
  128. package/src/directory-config.test.ts +60 -0
  129. package/src/directory-config.ts +40 -0
  130. package/src/directory-contract.test.ts +56 -0
  131. package/src/doctor-contract.ts +11 -0
  132. package/src/doctor.test.ts +74 -0
  133. package/src/doctor.ts +56 -0
  134. package/src/group-intro.ts +15 -0
  135. package/src/group-policy.test.ts +36 -0
  136. package/src/group-policy.ts +40 -0
  137. package/src/group-session-contract.ts +20 -0
  138. package/src/group-session-key.test.ts +53 -0
  139. package/src/group-session-key.ts +41 -0
  140. package/src/heartbeat-recipients.runtime.ts +6 -0
  141. package/src/heartbeat-recipients.test.ts +203 -0
  142. package/src/heartbeat-recipients.ts +104 -0
  143. package/src/heartbeat.ts +34 -0
  144. package/src/identity.ts +164 -0
  145. package/src/inbound/access-control.test-harness.ts +37 -0
  146. package/src/inbound/access-control.test.ts +375 -0
  147. package/src/inbound/access-control.ts +237 -0
  148. package/src/inbound/dedupe.ts +132 -0
  149. package/src/inbound/extract.test.ts +282 -0
  150. package/src/inbound/extract.ts +488 -0
  151. package/src/inbound/lifecycle.ts +39 -0
  152. package/src/inbound/media.node.test.ts +83 -0
  153. package/src/inbound/media.ts +102 -0
  154. package/src/inbound/monitor.ts +890 -0
  155. package/src/inbound/runtime-api.ts +7 -0
  156. package/src/inbound/save-media.runtime.ts +1 -0
  157. package/src/inbound/send-api.test.ts +307 -0
  158. package/src/inbound/send-api.ts +141 -0
  159. package/src/inbound/send-result.ts +61 -0
  160. package/src/inbound/types.ts +106 -0
  161. package/src/inbound-context.contract.test.ts +32 -0
  162. package/src/inbound-policy.ts +231 -0
  163. package/src/inbound.media.test.ts +320 -0
  164. package/src/inbound.test.ts +290 -0
  165. package/src/inbound.ts +9 -0
  166. package/src/login-qr.test.ts +483 -0
  167. package/src/login-qr.ts +542 -0
  168. package/src/login.coverage.test.ts +133 -0
  169. package/src/login.test.ts +89 -0
  170. package/src/login.ts +70 -0
  171. package/src/logout.test.ts +162 -0
  172. package/src/media.test.ts +480 -0
  173. package/src/media.ts +10 -0
  174. package/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +400 -0
  175. package/src/monitor-inbox.append-upsert.test-support.ts +133 -0
  176. package/src/monitor-inbox.behavior.test.ts +5 -0
  177. package/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +362 -0
  178. package/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +306 -0
  179. package/src/monitor-inbox.streams-inbound-messages.test-support.ts +729 -0
  180. package/src/monitor-inbox.test-harness.ts +304 -0
  181. package/src/normalize-target.ts +112 -0
  182. package/src/normalize.ts +6 -0
  183. package/src/outbound-adapter.poll.test.ts +62 -0
  184. package/src/outbound-adapter.sendpayload.test.ts +195 -0
  185. package/src/outbound-adapter.ts +36 -0
  186. package/src/outbound-base.test.ts +560 -0
  187. package/src/outbound-base.ts +244 -0
  188. package/src/outbound-media-contract.ts +278 -0
  189. package/src/outbound-media.runtime.ts +36 -0
  190. package/src/outbound-payload.contract.test.ts +61 -0
  191. package/src/outbound-send-deps.ts +1 -0
  192. package/src/outbound-test-support.ts +16 -0
  193. package/src/pairing-security.test-harness.ts +59 -0
  194. package/src/qr-image.ts +1 -0
  195. package/src/qr-terminal.ts +1 -0
  196. package/src/quoted-message.test.ts +83 -0
  197. package/src/quoted-message.ts +184 -0
  198. package/src/reaction-level.test.ts +94 -0
  199. package/src/reaction-level.ts +21 -0
  200. package/src/reconnect.test.ts +51 -0
  201. package/src/reconnect.ts +55 -0
  202. package/src/resolve-outbound-target.test.ts +265 -0
  203. package/src/resolve-outbound-target.ts +54 -0
  204. package/src/resolve-target.test.ts +97 -0
  205. package/src/runtime-api.ts +60 -0
  206. package/src/runtime-group-policy.ts +16 -0
  207. package/src/runtime.ts +9 -0
  208. package/src/security-contract.ts +47 -0
  209. package/src/security-fix.ts +71 -0
  210. package/src/send.test.ts +515 -0
  211. package/src/send.ts +287 -0
  212. package/src/session-contract.test.ts +31 -0
  213. package/src/session-contract.ts +43 -0
  214. package/src/session-errors.ts +125 -0
  215. package/src/session-route.ts +26 -0
  216. package/src/session.runtime.ts +8 -0
  217. package/src/session.test.ts +559 -0
  218. package/src/session.ts +330 -0
  219. package/src/setup-core.ts +52 -0
  220. package/src/setup-finalize.ts +446 -0
  221. package/src/setup-surface.test.ts +367 -0
  222. package/src/setup-surface.ts +69 -0
  223. package/src/setup-test-helpers.ts +216 -0
  224. package/src/shared.ts +291 -0
  225. package/src/socket-timing.test.ts +49 -0
  226. package/src/socket-timing.ts +38 -0
  227. package/src/state-migrations.ts +54 -0
  228. package/src/status-issues.test.ts +137 -0
  229. package/src/status-issues.ts +185 -0
  230. package/src/system-prompt.test.ts +199 -0
  231. package/src/system-prompt.ts +31 -0
  232. package/src/targets-runtime.ts +180 -0
  233. package/src/test-helpers.ts +690 -0
  234. package/src/text-runtime.test.ts +162 -0
  235. package/src/text-runtime.ts +11 -0
  236. package/src/vcard.ts +84 -0
  237. package/targets.ts +5 -0
  238. package/test-api.ts +2 -0
  239. package/tsconfig.json +16 -0
@@ -0,0 +1,231 @@
1
+ import {
2
+ resolveChannelGroupPolicy,
3
+ resolveChannelGroupRequireMention,
4
+ } from "openclaw/plugin-sdk/channel-policy";
5
+ import type {
6
+ ChannelGroupPolicy,
7
+ DmPolicy,
8
+ GroupPolicy,
9
+ OpenClawConfig,
10
+ } from "openclaw/plugin-sdk/config-types";
11
+ import { resolveDefaultGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
12
+ import {
13
+ expandAllowFromWithAccessGroups,
14
+ readStoreAllowFromForDmPolicy,
15
+ resolveEffectiveAllowFromLists,
16
+ resolveDmGroupAccessWithCommandGate,
17
+ } from "openclaw/plugin-sdk/security-runtime";
18
+ import { resolveGroupSessionKey } from "openclaw/plugin-sdk/session-store-runtime";
19
+ import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
20
+ import { getSelfIdentity, getSenderIdentity } from "./identity.js";
21
+ import type { WebInboundMessage } from "./inbound/types.js";
22
+ import { resolveWhatsAppRuntimeGroupPolicy } from "./runtime-group-policy.js";
23
+ import { isSelfChatMode, normalizeE164 } from "./text-runtime.js";
24
+
25
+ export type ResolvedWhatsAppInboundPolicy = {
26
+ account: ResolvedWhatsAppAccount;
27
+ dmPolicy: DmPolicy;
28
+ groupPolicy: GroupPolicy;
29
+ configuredAllowFrom: string[];
30
+ dmAllowFrom: string[];
31
+ groupAllowFrom: string[];
32
+ isSelfChat: boolean;
33
+ providerMissingFallbackApplied: boolean;
34
+ shouldReadStorePairingApprovals: boolean;
35
+ isSamePhone: (value?: string | null) => boolean;
36
+ isDmSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
37
+ isGroupSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
38
+ resolveConversationGroupPolicy: (conversationId: string) => ChannelGroupPolicy;
39
+ resolveConversationRequireMention: (conversationId: string) => boolean;
40
+ };
41
+
42
+ function resolveGroupConversationId(conversationId: string): string {
43
+ return (
44
+ resolveGroupSessionKey({
45
+ From: conversationId,
46
+ ChatType: "group",
47
+ Provider: "whatsapp",
48
+ })?.id ?? conversationId
49
+ );
50
+ }
51
+
52
+ function isNormalizedSenderAllowed(allowEntries: string[], sender?: string | null): boolean {
53
+ if (allowEntries.includes("*")) {
54
+ return true;
55
+ }
56
+ const normalizedSender = normalizeE164(sender ?? "");
57
+ if (!normalizedSender) {
58
+ return false;
59
+ }
60
+ const normalizedEntrySet = new Set(
61
+ allowEntries
62
+ .map((entry) => normalizeE164(entry))
63
+ .filter((entry): entry is string => Boolean(entry)),
64
+ );
65
+ return normalizedEntrySet.has(normalizedSender);
66
+ }
67
+
68
+ function buildResolvedWhatsAppGroupConfig(params: {
69
+ groupPolicy: GroupPolicy;
70
+ groups: ResolvedWhatsAppAccount["groups"];
71
+ }): OpenClawConfig {
72
+ return {
73
+ channels: {
74
+ whatsapp: {
75
+ groupPolicy: params.groupPolicy,
76
+ groups: params.groups,
77
+ },
78
+ },
79
+ } as OpenClawConfig;
80
+ }
81
+
82
+ export function resolveWhatsAppInboundPolicy(params: {
83
+ cfg: OpenClawConfig;
84
+ accountId?: string | null;
85
+ selfE164?: string | null;
86
+ }): ResolvedWhatsAppInboundPolicy {
87
+ const account = resolveWhatsAppAccount({
88
+ cfg: params.cfg,
89
+ accountId: params.accountId,
90
+ });
91
+ const configuredAllowFrom = account.allowFrom ?? [];
92
+ const dmPolicy = account.dmPolicy ?? "pairing";
93
+ const dmAllowFrom =
94
+ configuredAllowFrom.length > 0 ? configuredAllowFrom : params.selfE164 ? [params.selfE164] : [];
95
+ const groupAllowFrom =
96
+ account.groupAllowFrom ??
97
+ (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined) ??
98
+ [];
99
+ const { effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
100
+ allowFrom: configuredAllowFrom,
101
+ groupAllowFrom,
102
+ });
103
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
104
+ const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
105
+ providerConfigPresent: params.cfg.channels?.whatsapp !== undefined,
106
+ groupPolicy: account.groupPolicy,
107
+ defaultGroupPolicy,
108
+ });
109
+ const resolvedGroupCfg = buildResolvedWhatsAppGroupConfig({
110
+ groupPolicy,
111
+ groups: account.groups,
112
+ });
113
+ const isSamePhone = (value?: string | null) =>
114
+ typeof value === "string" && typeof params.selfE164 === "string" && value === params.selfE164;
115
+ return {
116
+ account,
117
+ dmPolicy,
118
+ groupPolicy,
119
+ configuredAllowFrom,
120
+ dmAllowFrom,
121
+ groupAllowFrom,
122
+ isSelfChat: account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom),
123
+ providerMissingFallbackApplied,
124
+ shouldReadStorePairingApprovals: dmPolicy !== "allowlist",
125
+ isSamePhone,
126
+ isDmSenderAllowed: (allowEntries, sender) =>
127
+ isSamePhone(sender) || isNormalizedSenderAllowed(allowEntries, sender),
128
+ isGroupSenderAllowed: (allowEntries, sender) => isNormalizedSenderAllowed(allowEntries, sender),
129
+ resolveConversationGroupPolicy: (conversationId) =>
130
+ resolveChannelGroupPolicy({
131
+ cfg: resolvedGroupCfg,
132
+ channel: "whatsapp",
133
+ groupId: resolveGroupConversationId(conversationId),
134
+ hasGroupAllowFrom: effectiveGroupAllowFrom.length > 0,
135
+ }),
136
+ resolveConversationRequireMention: (conversationId) =>
137
+ resolveChannelGroupRequireMention({
138
+ cfg: resolvedGroupCfg,
139
+ channel: "whatsapp",
140
+ groupId: resolveGroupConversationId(conversationId),
141
+ }),
142
+ };
143
+ }
144
+
145
+ export async function resolveWhatsAppCommandAuthorized(params: {
146
+ cfg: OpenClawConfig;
147
+ msg: WebInboundMessage;
148
+ policy?: ResolvedWhatsAppInboundPolicy;
149
+ }): Promise<boolean> {
150
+ const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
151
+ if (!useAccessGroups) {
152
+ return true;
153
+ }
154
+
155
+ const self = getSelfIdentity(params.msg);
156
+ const policy =
157
+ params.policy ??
158
+ resolveWhatsAppInboundPolicy({
159
+ cfg: params.cfg,
160
+ accountId: params.msg.accountId,
161
+ selfE164: self.e164 ?? null,
162
+ });
163
+ const isGroup = params.msg.chatType === "group";
164
+ const sender = getSenderIdentity(params.msg);
165
+ const dmSender = sender.e164 ?? params.msg.from ?? "";
166
+ const groupSender = sender.e164 ?? "";
167
+ const normalizedSender = normalizeE164(isGroup ? groupSender : dmSender);
168
+ if (!normalizedSender) {
169
+ return false;
170
+ }
171
+
172
+ const storeAllowFrom =
173
+ isGroup || !policy.shouldReadStorePairingApprovals
174
+ ? []
175
+ : await readStoreAllowFromForDmPolicy({
176
+ provider: "whatsapp",
177
+ accountId: policy.account.accountId,
178
+ dmPolicy: policy.dmPolicy,
179
+ shouldRead: policy.shouldReadStorePairingApprovals,
180
+ });
181
+ const isSenderAllowed = (senderId: string, allowEntries: string[]) =>
182
+ isGroup
183
+ ? policy.isGroupSenderAllowed(allowEntries, senderId)
184
+ : policy.isDmSenderAllowed(allowEntries, senderId);
185
+ const [allowFrom, groupAllowFrom] = await Promise.all([
186
+ expandAllowFromWithAccessGroups({
187
+ cfg: params.cfg,
188
+ allowFrom: policy.dmAllowFrom,
189
+ channel: "whatsapp",
190
+ accountId: policy.account.accountId,
191
+ senderId: normalizedSender,
192
+ isSenderAllowed,
193
+ }),
194
+ expandAllowFromWithAccessGroups({
195
+ cfg: params.cfg,
196
+ allowFrom: policy.groupAllowFrom,
197
+ channel: "whatsapp",
198
+ accountId: policy.account.accountId,
199
+ senderId: normalizedSender,
200
+ isSenderAllowed,
201
+ }),
202
+ ]);
203
+ const dmStoreAllowFrom = isGroup
204
+ ? []
205
+ : await expandAllowFromWithAccessGroups({
206
+ cfg: params.cfg,
207
+ allowFrom: storeAllowFrom,
208
+ channel: "whatsapp",
209
+ accountId: policy.account.accountId,
210
+ senderId: normalizedSender,
211
+ isSenderAllowed,
212
+ });
213
+ const access = resolveDmGroupAccessWithCommandGate({
214
+ isGroup,
215
+ dmPolicy: policy.dmPolicy,
216
+ groupPolicy: policy.groupPolicy,
217
+ allowFrom,
218
+ groupAllowFrom,
219
+ storeAllowFrom: dmStoreAllowFrom,
220
+ isSenderAllowed: (allowEntries) =>
221
+ isGroup
222
+ ? policy.isGroupSenderAllowed(allowEntries, groupSender)
223
+ : policy.isDmSenderAllowed(allowEntries, dmSender),
224
+ command: {
225
+ useAccessGroups,
226
+ allowTextCommands: true,
227
+ hasControlCommand: true,
228
+ },
229
+ });
230
+ return access.commandAuthorized;
231
+ }
@@ -0,0 +1,320 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ mockExtractMessageContent,
8
+ mockGetContentType,
9
+ mockIsJidGroup,
10
+ mockNormalizeMessageContent,
11
+ } from "../../../test/mocks/baileys.js";
12
+
13
+ type MockMessageInput = Parameters<typeof mockNormalizeMessageContent>[0];
14
+
15
+ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
16
+ const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
17
+ const saveMediaBufferSpy = vi.fn();
18
+ let currentMockSocket:
19
+ | {
20
+ ev: import("node:events").EventEmitter;
21
+ ws: { close: ReturnType<typeof vi.fn> };
22
+ sendPresenceUpdate: ReturnType<typeof vi.fn>;
23
+ sendMessage: ReturnType<typeof vi.fn>;
24
+ readMessages: ReturnType<typeof vi.fn>;
25
+ groupFetchAllParticipating: ReturnType<typeof vi.fn>;
26
+ updateMediaMessage: ReturnType<typeof vi.fn>;
27
+ logger: Record<string, never>;
28
+ user: { id: string };
29
+ }
30
+ | undefined;
31
+
32
+ vi.mock("openclaw/plugin-sdk/runtime-config-snapshot", async () => {
33
+ const actual = await vi.importActual<
34
+ typeof import("openclaw/plugin-sdk/runtime-config-snapshot")
35
+ >("openclaw/plugin-sdk/runtime-config-snapshot");
36
+ return {
37
+ ...actual,
38
+ getRuntimeConfig: vi.fn().mockReturnValue({
39
+ channels: {
40
+ whatsapp: {
41
+ allowFrom: ["*"], // Allow all in tests
42
+ },
43
+ },
44
+ messages: {
45
+ messagePrefix: undefined,
46
+ responsePrefix: undefined,
47
+ },
48
+ }),
49
+ };
50
+ });
51
+
52
+ vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
53
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
54
+ "openclaw/plugin-sdk/conversation-runtime",
55
+ );
56
+ return {
57
+ ...actual,
58
+ readChannelAllowFromStore(...args: unknown[]) {
59
+ return readAllowFromStoreMock(...args);
60
+ },
61
+ upsertChannelPairingRequest(...args: unknown[]) {
62
+ return upsertPairingRequestMock(...args);
63
+ },
64
+ };
65
+ });
66
+
67
+ vi.mock("openclaw/plugin-sdk/channel-pairing", async () => {
68
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-pairing")>(
69
+ "openclaw/plugin-sdk/channel-pairing",
70
+ );
71
+ return {
72
+ ...actual,
73
+ readChannelAllowFromStore(...args: unknown[]) {
74
+ return readAllowFromStoreMock(...args);
75
+ },
76
+ };
77
+ });
78
+
79
+ vi.mock("openclaw/plugin-sdk/media-store", async () => {
80
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-store")>(
81
+ "openclaw/plugin-sdk/media-store",
82
+ );
83
+ return {
84
+ ...actual,
85
+ saveMediaBuffer: vi.fn(async (...args: Parameters<typeof actual.saveMediaBuffer>) => {
86
+ saveMediaBufferSpy(...args);
87
+ return actual.saveMediaBuffer(...args);
88
+ }),
89
+ };
90
+ });
91
+
92
+ const HOME = path.join(os.tmpdir(), `openclaw-inbound-media-${crypto.randomUUID()}`);
93
+ process.env.HOME = HOME;
94
+
95
+ vi.mock("@whiskeysockets/baileys", async () => {
96
+ const actual =
97
+ await vi.importActual<typeof import("@whiskeysockets/baileys")>("@whiskeysockets/baileys");
98
+ const jpegBuffer = Buffer.from([
99
+ 0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x02, 0x02, 0x02, 0x03, 0x02, 0x02,
100
+ 0x02, 0x03, 0x03, 0x03, 0x03, 0x04, 0x06, 0x04, 0x04, 0x04, 0x04, 0x04, 0x08, 0x06, 0x06, 0x05,
101
+ 0x06, 0x09, 0x08, 0x0a, 0x0a, 0x09, 0x08, 0x09, 0x09, 0x0a, 0x0c, 0x0f, 0x0c, 0x0a, 0x0b, 0x0e,
102
+ 0x0b, 0x09, 0x09, 0x0d, 0x11, 0x0d, 0x0e, 0x0f, 0x10, 0x10, 0x11, 0x10, 0x0a, 0x0c, 0x12, 0x13,
103
+ 0x12, 0x10, 0x13, 0x0f, 0x10, 0x10, 0x10, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x01, 0x00, 0x01,
104
+ 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01,
105
+ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff,
106
+ 0xc4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
107
+ 0x00, 0x00, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00,
108
+ 0xff, 0xd9,
109
+ ]);
110
+ return {
111
+ ...actual,
112
+ DisconnectReason: actual.DisconnectReason ?? { loggedOut: 401 },
113
+ downloadMediaMessage: vi.fn().mockResolvedValue(jpegBuffer),
114
+ extractMessageContent: vi.fn((message: MockMessageInput) => mockExtractMessageContent(message)),
115
+ getContentType: vi.fn((message: MockMessageInput) => mockGetContentType(message)),
116
+ isJidGroup: vi.fn((jid: string | undefined | null) => mockIsJidGroup(jid)),
117
+ normalizeMessageContent: vi.fn((message: MockMessageInput) =>
118
+ mockNormalizeMessageContent(message),
119
+ ),
120
+ };
121
+ });
122
+
123
+ vi.mock("./session.js", async () => {
124
+ const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
125
+ const { EventEmitter } = require("node:events");
126
+ return {
127
+ ...actual,
128
+ createWaSocket: vi.fn().mockImplementation(async () => {
129
+ currentMockSocket ??= {
130
+ ev: new EventEmitter(),
131
+ ws: { close: vi.fn() },
132
+ sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
133
+ sendMessage: vi.fn().mockResolvedValue(undefined),
134
+ readMessages: vi.fn().mockResolvedValue(undefined),
135
+ groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
136
+ updateMediaMessage: vi.fn(),
137
+ logger: {},
138
+ user: { id: "me@s.whatsapp.net" },
139
+ };
140
+ return currentMockSocket;
141
+ }),
142
+ waitForWaConnection: vi.fn().mockResolvedValue(undefined),
143
+ getStatusCode: vi.fn(() => 200),
144
+ };
145
+ });
146
+
147
+ let monitorWebInbox: typeof import("./inbound.js").monitorWebInbox;
148
+ let resetWebInboundDedupe: typeof import("./inbound.js").resetWebInboundDedupe;
149
+ let createWaSocket: typeof import("./session.js").createWaSocket;
150
+
151
+ async function waitForMessage(onMessage: ReturnType<typeof vi.fn>) {
152
+ await vi.waitFor(() => expect(onMessage).toHaveBeenCalledTimes(1), {
153
+ interval: 1,
154
+ timeout: 250,
155
+ });
156
+ return onMessage.mock.calls[0][0];
157
+ }
158
+
159
+ describe("web inbound media saves with extension", () => {
160
+ async function getMockSocket() {
161
+ return (await createWaSocket(false, false)) as unknown as {
162
+ ev: import("node:events").EventEmitter;
163
+ };
164
+ }
165
+
166
+ beforeEach(() => {
167
+ vi.useRealTimers();
168
+ currentMockSocket = undefined;
169
+ saveMediaBufferSpy.mockClear();
170
+ resetWebInboundDedupe();
171
+ });
172
+
173
+ beforeAll(async () => {
174
+ await fs.rm(HOME, { recursive: true, force: true });
175
+ ({ monitorWebInbox, resetWebInboundDedupe } = await import("./inbound.js"));
176
+ ({ createWaSocket } = await import("./session.js"));
177
+ });
178
+
179
+ afterAll(async () => {
180
+ await fs.rm(HOME, { recursive: true, force: true });
181
+ });
182
+
183
+ it("stores image extension and keeps document filename", async () => {
184
+ const onMessage = vi.fn();
185
+ const listener = await monitorWebInbox({
186
+ cfg: {
187
+ channels: { whatsapp: { allowFrom: ["*"] } },
188
+ messages: { messagePrefix: undefined, responsePrefix: undefined },
189
+ } as never,
190
+ verbose: false,
191
+ onMessage,
192
+ accountId: "default",
193
+ authDir: path.join(HOME, "wa-auth"),
194
+ });
195
+ const realSock = await getMockSocket();
196
+
197
+ realSock.ev.emit("messages.upsert", {
198
+ type: "notify",
199
+ messages: [
200
+ {
201
+ key: { id: "img1", fromMe: false, remoteJid: "111@s.whatsapp.net" },
202
+ message: { imageMessage: { mimetype: "image/jpeg" } },
203
+ messageTimestamp: 1_700_000_001,
204
+ },
205
+ ],
206
+ });
207
+
208
+ const first = await waitForMessage(onMessage);
209
+ const mediaPath = first.mediaPath;
210
+ expect(mediaPath).toBeDefined();
211
+ expect(path.extname(mediaPath as string)).toBe(".jpg");
212
+ const stat = await fs.stat(mediaPath as string);
213
+ expect(stat.size).toBeGreaterThan(0);
214
+
215
+ onMessage.mockClear();
216
+ const fileName = "invoice.pdf";
217
+ realSock.ev.emit("messages.upsert", {
218
+ type: "notify",
219
+ messages: [
220
+ {
221
+ key: { id: "doc1", fromMe: false, remoteJid: "333@s.whatsapp.net" },
222
+ message: { documentMessage: { mimetype: "application/pdf", fileName } },
223
+ messageTimestamp: 1_700_000_004,
224
+ },
225
+ ],
226
+ });
227
+
228
+ const second = await waitForMessage(onMessage);
229
+ expect(second.mediaFileName).toBe(fileName);
230
+ expect(saveMediaBufferSpy).toHaveBeenCalled();
231
+ const lastCall = saveMediaBufferSpy.mock.calls.at(-1);
232
+ expect(lastCall?.[4]).toBe(fileName);
233
+
234
+ await listener.close();
235
+ });
236
+
237
+ it("stores quoted image media from reply context", async () => {
238
+ const onMessage = vi.fn();
239
+ const listener = await monitorWebInbox({
240
+ cfg: {
241
+ channels: { whatsapp: { allowFrom: ["*"] } },
242
+ messages: { messagePrefix: undefined, responsePrefix: undefined },
243
+ } as never,
244
+ verbose: false,
245
+ onMessage,
246
+ accountId: "default",
247
+ authDir: path.join(HOME, "wa-auth"),
248
+ });
249
+ const realSock = await getMockSocket();
250
+
251
+ realSock.ev.emit("messages.upsert", {
252
+ type: "notify",
253
+ messages: [
254
+ {
255
+ key: { id: "quote-img-reply", fromMe: false, remoteJid: "111@g.us" },
256
+ message: {
257
+ extendedTextMessage: {
258
+ text: "@bot what is this?",
259
+ contextInfo: {
260
+ stanzaId: "quoted-image",
261
+ participant: "222@s.whatsapp.net",
262
+ mentionedJid: ["me@s.whatsapp.net"],
263
+ quotedMessage: {
264
+ imageMessage: { mimetype: "image/jpeg" },
265
+ },
266
+ },
267
+ },
268
+ },
269
+ messageTimestamp: 1_700_000_005,
270
+ },
271
+ ],
272
+ });
273
+
274
+ const inbound = await waitForMessage(onMessage);
275
+ expect(inbound.replyToBody).toBe("<media:image>");
276
+ expect(inbound.mediaPath).toBeDefined();
277
+ expect(path.extname(inbound.mediaPath as string)).toBe(".jpg");
278
+ expect(saveMediaBufferSpy).toHaveBeenCalled();
279
+ const lastCall = saveMediaBufferSpy.mock.calls.at(-1);
280
+ expect(lastCall?.[1]).toBe("image/jpeg");
281
+
282
+ await listener.close();
283
+ });
284
+
285
+ it("passes mediaMaxMb to saveMediaBuffer", async () => {
286
+ const onMessage = vi.fn();
287
+ const listener = await monitorWebInbox({
288
+ cfg: {
289
+ channels: { whatsapp: { allowFrom: ["*"] } },
290
+ messages: { messagePrefix: undefined, responsePrefix: undefined },
291
+ } as never,
292
+ verbose: false,
293
+ onMessage,
294
+ mediaMaxMb: 1,
295
+ accountId: "default",
296
+ authDir: path.join(HOME, "wa-auth"),
297
+ });
298
+ const realSock = await getMockSocket();
299
+
300
+ const upsert = {
301
+ type: "notify",
302
+ messages: [
303
+ {
304
+ key: { id: "img3", fromMe: false, remoteJid: "222@s.whatsapp.net" },
305
+ message: { imageMessage: { mimetype: "image/jpeg" } },
306
+ messageTimestamp: 1_700_000_003,
307
+ },
308
+ ],
309
+ };
310
+
311
+ realSock.ev.emit("messages.upsert", upsert);
312
+
313
+ await waitForMessage(onMessage);
314
+ expect(saveMediaBufferSpy).toHaveBeenCalled();
315
+ const lastCall = saveMediaBufferSpy.mock.calls.at(-1);
316
+ expect(lastCall?.[3]).toBe(1 * 1024 * 1024);
317
+
318
+ await listener.close();
319
+ });
320
+ });