@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,375 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import {
3
+ readAllowFromStoreMock,
4
+ sendMessageMock,
5
+ getAccessControlTestConfig,
6
+ setAccessControlTestConfig,
7
+ setupAccessControlTestHarness,
8
+ upsertPairingRequestMock,
9
+ } from "./access-control.test-harness.js";
10
+
11
+ setupAccessControlTestHarness();
12
+ let checkInboundAccessControl: typeof import("./access-control.js").checkInboundAccessControl;
13
+ let resolveWhatsAppCommandAuthorized: typeof import("../inbound-policy.js").resolveWhatsAppCommandAuthorized;
14
+
15
+ beforeAll(async () => {
16
+ ({ checkInboundAccessControl } = await import("./access-control.js"));
17
+ ({ resolveWhatsAppCommandAuthorized } = await import("../inbound-policy.js"));
18
+ });
19
+
20
+ async function checkUnauthorizedWorkDmSender() {
21
+ return checkInboundAccessControl({
22
+ cfg: getAccessControlTestConfig() as never,
23
+ accountId: "work",
24
+ from: "+15550001111",
25
+ selfE164: "+15550009999",
26
+ senderE164: "+15550001111",
27
+ group: false,
28
+ pushName: "Stranger",
29
+ isFromMe: false,
30
+ sock: { sendMessage: sendMessageMock },
31
+ remoteJid: "15550001111@s.whatsapp.net",
32
+ });
33
+ }
34
+
35
+ function expectSilentlyBlocked(result: { allowed: boolean }) {
36
+ expect(result.allowed).toBe(false);
37
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
38
+ expect(sendMessageMock).not.toHaveBeenCalled();
39
+ }
40
+
41
+ async function checkCommandAuthorizedForDm(params: {
42
+ cfg: Record<string, unknown>;
43
+ accountId?: string;
44
+ from?: string;
45
+ senderE164?: string;
46
+ selfE164?: string;
47
+ }) {
48
+ return await resolveWhatsAppCommandAuthorized({
49
+ cfg: params.cfg as never,
50
+ msg: {
51
+ accountId: params.accountId ?? "work",
52
+ chatType: "direct",
53
+ from: params.from ?? "+15550001111",
54
+ senderE164: params.senderE164 ?? params.from ?? "+15550001111",
55
+ selfE164: params.selfE164 ?? "+15550009999",
56
+ body: "/status",
57
+ to: params.selfE164 ?? "+15550009999",
58
+ } as never,
59
+ });
60
+ }
61
+
62
+ async function checkCommandAuthorizedForGroup(params: {
63
+ cfg: Record<string, unknown>;
64
+ accountId?: string;
65
+ from?: string;
66
+ senderE164?: string;
67
+ selfE164?: string;
68
+ }) {
69
+ return await resolveWhatsAppCommandAuthorized({
70
+ cfg: params.cfg as never,
71
+ msg: {
72
+ accountId: params.accountId ?? "work",
73
+ chatType: "group",
74
+ from: params.from ?? "120363401234567890@g.us",
75
+ conversationId: params.from ?? "120363401234567890@g.us",
76
+ chatId: params.from ?? "120363401234567890@g.us",
77
+ senderE164: params.senderE164 ?? "+15550001111",
78
+ selfE164: params.selfE164 ?? "+15550009999",
79
+ body: "/status",
80
+ to: params.selfE164 ?? "+15550009999",
81
+ } as never,
82
+ });
83
+ }
84
+
85
+ describe("checkInboundAccessControl pairing grace", () => {
86
+ async function runPairingGraceCase(messageTimestampMs: number) {
87
+ const connectedAtMs = 1_000_000;
88
+ return await checkInboundAccessControl({
89
+ cfg: getAccessControlTestConfig() as never,
90
+ accountId: "default",
91
+ from: "+15550001111",
92
+ selfE164: "+15550009999",
93
+ senderE164: "+15550001111",
94
+ group: false,
95
+ pushName: "Sam",
96
+ isFromMe: false,
97
+ messageTimestampMs,
98
+ connectedAtMs,
99
+ pairingGraceMs: 30_000,
100
+ sock: { sendMessage: sendMessageMock },
101
+ remoteJid: "15550001111@s.whatsapp.net",
102
+ });
103
+ }
104
+
105
+ it("suppresses pairing replies for historical DMs on connect", async () => {
106
+ const result = await runPairingGraceCase(1_000_000 - 31_000);
107
+
108
+ expect(result.allowed).toBe(false);
109
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
110
+ expect(sendMessageMock).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it("sends pairing replies for live DMs", async () => {
114
+ const result = await runPairingGraceCase(1_000_000 - 10_000);
115
+
116
+ expect(result.allowed).toBe(false);
117
+ expect(upsertPairingRequestMock).toHaveBeenCalled();
118
+ expect(sendMessageMock).toHaveBeenCalled();
119
+ });
120
+ });
121
+
122
+ describe("WhatsApp dmPolicy precedence", () => {
123
+ it("uses account-level dmPolicy instead of channel-level (#8736)", async () => {
124
+ // Channel-level says "pairing" but the account-level says "allowlist".
125
+ // The account-level override should take precedence, so an unauthorized
126
+ // sender should be blocked silently (no pairing reply).
127
+ const cfg = {
128
+ channels: {
129
+ whatsapp: {
130
+ dmPolicy: "pairing",
131
+ accounts: {
132
+ work: {
133
+ dmPolicy: "allowlist",
134
+ allowFrom: ["+15559999999"],
135
+ },
136
+ },
137
+ },
138
+ },
139
+ };
140
+ setAccessControlTestConfig(cfg);
141
+
142
+ const result = await checkUnauthorizedWorkDmSender();
143
+ const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
144
+ expectSilentlyBlocked(result);
145
+ expect(commandAuthorized).toBe(false);
146
+ });
147
+
148
+ it("inherits channel-level dmPolicy when account-level dmPolicy is unset", async () => {
149
+ // Account has allowFrom set, but no dmPolicy override. Should inherit the channel default.
150
+ // With dmPolicy=allowlist, unauthorized senders are silently blocked.
151
+ const cfg = {
152
+ channels: {
153
+ whatsapp: {
154
+ dmPolicy: "allowlist",
155
+ accounts: {
156
+ work: {
157
+ allowFrom: ["+15559999999"],
158
+ },
159
+ },
160
+ },
161
+ },
162
+ };
163
+ setAccessControlTestConfig(cfg);
164
+
165
+ const result = await checkUnauthorizedWorkDmSender();
166
+ const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
167
+ expectSilentlyBlocked(result);
168
+ expect(commandAuthorized).toBe(false);
169
+ });
170
+
171
+ it("does not merge persisted pairing approvals in allowlist mode", async () => {
172
+ const cfg = {
173
+ channels: {
174
+ whatsapp: {
175
+ dmPolicy: "allowlist",
176
+ accounts: {
177
+ work: {
178
+ allowFrom: ["+15559999999"],
179
+ },
180
+ },
181
+ },
182
+ },
183
+ };
184
+ setAccessControlTestConfig(cfg);
185
+ readAllowFromStoreMock.mockResolvedValue(["+15550001111"]);
186
+
187
+ const result = await checkUnauthorizedWorkDmSender();
188
+ const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
189
+
190
+ expectSilentlyBlocked(result);
191
+ expect(commandAuthorized).toBe(false);
192
+ expect(readAllowFromStoreMock).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("always allows same-phone DMs even when allowFrom is restrictive", async () => {
196
+ const cfg = {
197
+ channels: {
198
+ whatsapp: {
199
+ dmPolicy: "pairing",
200
+ allowFrom: ["+15550001111"],
201
+ },
202
+ },
203
+ };
204
+ setAccessControlTestConfig(cfg);
205
+
206
+ const result = await checkInboundAccessControl({
207
+ cfg: getAccessControlTestConfig() as never,
208
+ accountId: "default",
209
+ from: "+15550009999",
210
+ selfE164: "+15550009999",
211
+ senderE164: "+15550009999",
212
+ group: false,
213
+ pushName: "Owner",
214
+ isFromMe: false,
215
+ sock: { sendMessage: sendMessageMock },
216
+ remoteJid: "15550009999@s.whatsapp.net",
217
+ });
218
+ const commandAuthorized = await checkCommandAuthorizedForDm({
219
+ cfg,
220
+ accountId: "default",
221
+ from: "+15550009999",
222
+ senderE164: "+15550009999",
223
+ selfE164: "+15550009999",
224
+ });
225
+
226
+ expect(result.allowed).toBe(true);
227
+ expect(commandAuthorized).toBe(true);
228
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
229
+ expect(sendMessageMock).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it("allows DMs from generic message sender access groups", async () => {
233
+ const cfg = {
234
+ accessGroups: {
235
+ owners: {
236
+ type: "message.senders",
237
+ members: {
238
+ whatsapp: ["+15550001111"],
239
+ },
240
+ },
241
+ },
242
+ channels: {
243
+ whatsapp: {
244
+ dmPolicy: "allowlist",
245
+ accounts: {
246
+ work: {
247
+ allowFrom: ["accessGroup:owners"],
248
+ },
249
+ },
250
+ },
251
+ },
252
+ };
253
+ setAccessControlTestConfig(cfg);
254
+
255
+ const result = await checkInboundAccessControl({
256
+ cfg: getAccessControlTestConfig() as never,
257
+ accountId: "work",
258
+ from: "+15550001111",
259
+ selfE164: "+15550009999",
260
+ senderE164: "+15550001111",
261
+ group: false,
262
+ pushName: "Sam",
263
+ isFromMe: false,
264
+ sock: { sendMessage: sendMessageMock },
265
+ remoteJid: "15550001111@s.whatsapp.net",
266
+ });
267
+ const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
268
+
269
+ expect(result.allowed).toBe(true);
270
+ expect(commandAuthorized).toBe(true);
271
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
272
+ expect(sendMessageMock).not.toHaveBeenCalled();
273
+ });
274
+
275
+ it("allows group messages from generic message sender access groups", async () => {
276
+ const cfg = {
277
+ accessGroups: {
278
+ operators: {
279
+ type: "message.senders",
280
+ members: {
281
+ whatsapp: ["+15550001111"],
282
+ },
283
+ },
284
+ },
285
+ channels: {
286
+ whatsapp: {
287
+ dmPolicy: "allowlist",
288
+ groupPolicy: "allowlist",
289
+ groupAllowFrom: ["accessGroup:operators"],
290
+ accounts: {
291
+ work: {
292
+ allowFrom: ["+15559999999"],
293
+ },
294
+ },
295
+ },
296
+ },
297
+ };
298
+ setAccessControlTestConfig(cfg);
299
+
300
+ const result = await checkInboundAccessControl({
301
+ cfg: getAccessControlTestConfig() as never,
302
+ accountId: "work",
303
+ from: "120363401234567890@g.us",
304
+ selfE164: "+15550009999",
305
+ senderE164: "+15550001111",
306
+ group: true,
307
+ pushName: "Sam",
308
+ isFromMe: false,
309
+ sock: { sendMessage: sendMessageMock },
310
+ remoteJid: "120363401234567890@g.us",
311
+ });
312
+ const commandAuthorized = await checkCommandAuthorizedForGroup({ cfg });
313
+
314
+ expect(result.allowed).toBe(true);
315
+ expect(commandAuthorized).toBe(true);
316
+ expect(upsertPairingRequestMock).not.toHaveBeenCalled();
317
+ expect(sendMessageMock).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => {
321
+ const cfg = {
322
+ channels: {
323
+ whatsapp: {
324
+ dmPolicy: "pairing",
325
+ allowFrom: [],
326
+ },
327
+ },
328
+ };
329
+ setAccessControlTestConfig(cfg);
330
+
331
+ const result = await checkInboundAccessControl({
332
+ cfg: getAccessControlTestConfig() as never,
333
+ accountId: "default",
334
+ from: "+15550001111",
335
+ selfE164: "+15550009999",
336
+ senderE164: "+15550001111",
337
+ group: false,
338
+ pushName: "Sam",
339
+ isFromMe: false,
340
+ sock: { sendMessage: sendMessageMock },
341
+ remoteJid: "15550001111@s.whatsapp.net",
342
+ });
343
+
344
+ expect(result.allowed).toBe(false);
345
+ expect(result.isSelfChat).toBe(false);
346
+ });
347
+
348
+ it("treats same-phone DMs as self-chat only when explicitly configured", async () => {
349
+ const cfg = {
350
+ channels: {
351
+ whatsapp: {
352
+ dmPolicy: "pairing",
353
+ allowFrom: ["+15550009999"],
354
+ },
355
+ },
356
+ };
357
+ setAccessControlTestConfig(cfg);
358
+
359
+ const result = await checkInboundAccessControl({
360
+ cfg: getAccessControlTestConfig() as never,
361
+ accountId: "default",
362
+ from: "+15550009999",
363
+ selfE164: "+15550009999",
364
+ senderE164: "+15550009999",
365
+ group: false,
366
+ pushName: "Owner",
367
+ isFromMe: false,
368
+ sock: { sendMessage: sendMessageMock },
369
+ remoteJid: "15550009999@s.whatsapp.net",
370
+ });
371
+
372
+ expect(result.allowed).toBe(true);
373
+ expect(result.isSelfChat).toBe(true);
374
+ });
375
+ });
@@ -0,0 +1,237 @@
1
+ import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
3
+ import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
4
+ import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
5
+ import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
6
+ import {
7
+ expandAllowFromWithAccessGroups,
8
+ readStoreAllowFromForDmPolicy,
9
+ resolveDmGroupAccessWithLists,
10
+ } from "openclaw/plugin-sdk/security-runtime";
11
+ import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
12
+
13
+ export type InboundAccessControlResult = {
14
+ allowed: boolean;
15
+ shouldMarkRead: boolean;
16
+ isSelfChat: boolean;
17
+ resolvedAccountId: string;
18
+ };
19
+
20
+ const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
21
+
22
+ function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
23
+ if (!enabled) {
24
+ return;
25
+ }
26
+ defaultRuntime.log(message);
27
+ }
28
+
29
+ export async function checkInboundAccessControl(params: {
30
+ cfg: OpenClawConfig;
31
+ accountId: string;
32
+ from: string;
33
+ selfE164: string | null;
34
+ senderE164: string | null;
35
+ group: boolean;
36
+ pushName?: string;
37
+ isFromMe: boolean;
38
+ messageTimestampMs?: number;
39
+ connectedAtMs?: number;
40
+ pairingGraceMs?: number;
41
+ verbose?: boolean;
42
+ sock: {
43
+ sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
44
+ };
45
+ remoteJid: string;
46
+ }): Promise<InboundAccessControlResult> {
47
+ const policy = resolveWhatsAppInboundPolicy({
48
+ cfg: params.cfg,
49
+ accountId: params.accountId,
50
+ selfE164: params.selfE164,
51
+ });
52
+ const storeAllowFrom = params.group
53
+ ? []
54
+ : await readStoreAllowFromForDmPolicy({
55
+ provider: "whatsapp",
56
+ accountId: policy.account.accountId,
57
+ dmPolicy: policy.dmPolicy,
58
+ shouldRead: policy.shouldReadStorePairingApprovals,
59
+ });
60
+ const pairingGraceMs =
61
+ typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
62
+ ? params.pairingGraceMs
63
+ : PAIRING_REPLY_HISTORY_GRACE_MS;
64
+ const suppressPairingReply =
65
+ typeof params.connectedAtMs === "number" &&
66
+ typeof params.messageTimestampMs === "number" &&
67
+ params.messageTimestampMs < params.connectedAtMs - pairingGraceMs;
68
+
69
+ // Group policy filtering:
70
+ // - "open": groups bypass allowFrom, only mention-gating applies
71
+ // - "disabled": block all group messages entirely
72
+ // - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
73
+ warnMissingProviderGroupPolicyFallbackOnce({
74
+ providerMissingFallbackApplied: policy.providerMissingFallbackApplied,
75
+ providerKey: "whatsapp",
76
+ accountId: policy.account.accountId,
77
+ log: (message) => logWhatsAppVerbose(params.verbose, message),
78
+ });
79
+ const accessGroupSenderId = params.group ? (params.senderE164 ?? params.from) : params.from;
80
+ const isAccessGroupSenderAllowed = (senderId: string, allowEntries: string[]) => {
81
+ return params.group
82
+ ? policy.isGroupSenderAllowed(allowEntries, senderId)
83
+ : policy.isDmSenderAllowed(allowEntries, senderId);
84
+ };
85
+ const [allowFrom, groupAllowFrom] = await Promise.all([
86
+ expandAllowFromWithAccessGroups({
87
+ cfg: params.cfg,
88
+ allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom,
89
+ channel: "whatsapp",
90
+ accountId: policy.account.accountId,
91
+ senderId: accessGroupSenderId,
92
+ isSenderAllowed: isAccessGroupSenderAllowed,
93
+ }),
94
+ expandAllowFromWithAccessGroups({
95
+ cfg: params.cfg,
96
+ allowFrom: policy.groupAllowFrom,
97
+ channel: "whatsapp",
98
+ accountId: policy.account.accountId,
99
+ senderId: accessGroupSenderId,
100
+ isSenderAllowed: isAccessGroupSenderAllowed,
101
+ }),
102
+ ]);
103
+ const dmStoreAllowFrom = params.group
104
+ ? []
105
+ : await expandAllowFromWithAccessGroups({
106
+ cfg: params.cfg,
107
+ allowFrom: storeAllowFrom,
108
+ channel: "whatsapp",
109
+ accountId: policy.account.accountId,
110
+ senderId: accessGroupSenderId,
111
+ isSenderAllowed: isAccessGroupSenderAllowed,
112
+ });
113
+ const access = resolveDmGroupAccessWithLists({
114
+ isGroup: params.group,
115
+ dmPolicy: policy.dmPolicy,
116
+ groupPolicy: policy.groupPolicy,
117
+ allowFrom,
118
+ groupAllowFrom,
119
+ storeAllowFrom: dmStoreAllowFrom,
120
+ isSenderAllowed: (allowEntries) => {
121
+ return params.group
122
+ ? policy.isGroupSenderAllowed(allowEntries, params.senderE164)
123
+ : policy.isDmSenderAllowed(allowEntries, params.from);
124
+ },
125
+ });
126
+ if (params.group && access.decision !== "allow") {
127
+ if (access.reason === "groupPolicy=disabled") {
128
+ logWhatsAppVerbose(params.verbose, "Blocked group message (groupPolicy: disabled)");
129
+ } else if (access.reason === "groupPolicy=allowlist (empty allowlist)") {
130
+ logWhatsAppVerbose(
131
+ params.verbose,
132
+ "Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
133
+ );
134
+ } else {
135
+ logWhatsAppVerbose(
136
+ params.verbose,
137
+ `Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
138
+ );
139
+ }
140
+ return {
141
+ allowed: false,
142
+ shouldMarkRead: false,
143
+ isSelfChat: policy.isSelfChat,
144
+ resolvedAccountId: policy.account.accountId,
145
+ };
146
+ }
147
+
148
+ // DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled".
149
+ if (!params.group) {
150
+ if (params.isFromMe && !policy.isSamePhone(params.from)) {
151
+ logWhatsAppVerbose(params.verbose, "Skipping outbound DM (fromMe); no pairing reply needed.");
152
+ return {
153
+ allowed: false,
154
+ shouldMarkRead: false,
155
+ isSelfChat: policy.isSelfChat,
156
+ resolvedAccountId: policy.account.accountId,
157
+ };
158
+ }
159
+ if (access.decision === "block" && access.reason === "dmPolicy=disabled") {
160
+ logWhatsAppVerbose(params.verbose, "Blocked dm (dmPolicy: disabled)");
161
+ return {
162
+ allowed: false,
163
+ shouldMarkRead: false,
164
+ isSelfChat: policy.isSelfChat,
165
+ resolvedAccountId: policy.account.accountId,
166
+ };
167
+ }
168
+ if (access.decision === "pairing" && !policy.isSamePhone(params.from)) {
169
+ const candidate = params.from;
170
+ if (suppressPairingReply) {
171
+ logWhatsAppVerbose(
172
+ params.verbose,
173
+ `Skipping pairing reply for historical DM from ${candidate}.`,
174
+ );
175
+ } else {
176
+ await createChannelPairingChallengeIssuer({
177
+ channel: "whatsapp",
178
+ upsertPairingRequest: async ({ id, meta }) =>
179
+ await upsertChannelPairingRequest({
180
+ channel: "whatsapp",
181
+ id,
182
+ accountId: policy.account.accountId,
183
+ meta,
184
+ }),
185
+ })({
186
+ senderId: candidate,
187
+ senderIdLine: `Your WhatsApp phone number: ${candidate}`,
188
+ meta: { name: (params.pushName ?? "").trim() || undefined },
189
+ onCreated: () => {
190
+ logWhatsAppVerbose(
191
+ params.verbose,
192
+ `whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
193
+ );
194
+ },
195
+ sendPairingReply: async (text) => {
196
+ await params.sock.sendMessage(params.remoteJid, { text });
197
+ },
198
+ onReplyError: (err) => {
199
+ logWhatsAppVerbose(
200
+ params.verbose,
201
+ `whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
202
+ );
203
+ },
204
+ });
205
+ }
206
+ return {
207
+ allowed: false,
208
+ shouldMarkRead: false,
209
+ isSelfChat: policy.isSelfChat,
210
+ resolvedAccountId: policy.account.accountId,
211
+ };
212
+ }
213
+ if (access.decision !== "allow") {
214
+ logWhatsAppVerbose(
215
+ params.verbose,
216
+ `Blocked unauthorized sender ${params.from} (dmPolicy=${policy.dmPolicy})`,
217
+ );
218
+ return {
219
+ allowed: false,
220
+ shouldMarkRead: false,
221
+ isSelfChat: policy.isSelfChat,
222
+ resolvedAccountId: policy.account.accountId,
223
+ };
224
+ }
225
+ }
226
+
227
+ return {
228
+ allowed: true,
229
+ shouldMarkRead: true,
230
+ isSelfChat: policy.isSelfChat,
231
+ resolvedAccountId: policy.account.accountId,
232
+ };
233
+ }
234
+
235
+ export const __testing = {
236
+ resolveWhatsAppInboundPolicy,
237
+ };