@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,515 @@
1
+ import crypto from "node:crypto";
2
+ import fsSync from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
6
+ import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
7
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
9
+ import type { ActiveWebListener } from "./inbound/types.js";
10
+
11
+ const hoisted = vi.hoisted(() => ({
12
+ loadOutboundMediaFromUrl: vi.fn(),
13
+ controllerListeners: new Map<string, ActiveWebListener>(),
14
+ runFfmpeg: vi.fn(),
15
+ }));
16
+ const loadWebMediaMock = vi.fn();
17
+ let sendMessageWhatsApp: typeof import("./send.js").sendMessageWhatsApp;
18
+ let sendPollWhatsApp: typeof import("./send.js").sendPollWhatsApp;
19
+ let sendReactionWhatsApp: typeof import("./send.js").sendReactionWhatsApp;
20
+ let resetLogger: typeof import("openclaw/plugin-sdk/runtime-env").resetLogger;
21
+ let setLoggerOverride: typeof import("openclaw/plugin-sdk/runtime-env").setLoggerOverride;
22
+
23
+ const WHATSAPP_TEST_CFG: OpenClawConfig = {
24
+ channels: { whatsapp: {} },
25
+ };
26
+
27
+ function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
28
+ return {
29
+ kind,
30
+ messageId: id,
31
+ messageIds: [id],
32
+ keys: [{ id }],
33
+ providerAccepted: true,
34
+ };
35
+ }
36
+
37
+ vi.mock("./connection-controller-registry.js", async () => {
38
+ const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
39
+ "./connection-controller-registry.js",
40
+ );
41
+ return {
42
+ ...actual,
43
+ getRegisteredWhatsAppConnectionController: vi.fn((accountId: string) => {
44
+ const listener = hoisted.controllerListeners.get(accountId) ?? null;
45
+ return listener
46
+ ? {
47
+ getActiveListener: () => listener,
48
+ }
49
+ : null;
50
+ }),
51
+ };
52
+ });
53
+
54
+ vi.mock("./outbound-media.runtime.js", async () => {
55
+ const actual = await vi.importActual<typeof import("./outbound-media.runtime.js")>(
56
+ "./outbound-media.runtime.js",
57
+ );
58
+ return {
59
+ ...actual,
60
+ loadOutboundMediaFromUrl: hoisted.loadOutboundMediaFromUrl,
61
+ };
62
+ });
63
+
64
+ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
65
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
66
+ "openclaw/plugin-sdk/media-runtime",
67
+ );
68
+ return {
69
+ ...actual,
70
+ runFfmpeg: hoisted.runFfmpeg,
71
+ };
72
+ });
73
+
74
+ vi.mock("./text-runtime.js", async () => {
75
+ const actual = await vi.importActual<typeof import("./text-runtime.js")>("./text-runtime.js");
76
+ return {
77
+ ...actual,
78
+ sleep: vi.fn(async () => {}),
79
+ };
80
+ });
81
+
82
+ describe("web outbound", () => {
83
+ const sendComposingTo = vi.fn(async () => {});
84
+ const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
85
+ const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
86
+ const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
87
+
88
+ beforeAll(async () => {
89
+ ({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));
90
+ ({ resetLogger, setLoggerOverride } = await import("openclaw/plugin-sdk/runtime-env"));
91
+ });
92
+
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ hoisted.runFfmpeg.mockReset().mockImplementation(async (args: string[]) => {
96
+ fsSync.writeFileSync(args.at(-1) ?? "", Buffer.from("opus-output"));
97
+ return "";
98
+ });
99
+ hoisted.loadOutboundMediaFromUrl.mockReset().mockImplementation(
100
+ async (
101
+ mediaUrl: string,
102
+ options?: {
103
+ maxBytes?: number;
104
+ mediaAccess?: {
105
+ localRoots?: readonly string[];
106
+ readFile?: (filePath: string) => Promise<Buffer>;
107
+ };
108
+ mediaLocalRoots?: readonly string[];
109
+ mediaReadFile?: (filePath: string) => Promise<Buffer>;
110
+ },
111
+ ) =>
112
+ await loadWebMediaMock(mediaUrl, {
113
+ maxBytes: options?.maxBytes,
114
+ localRoots: options?.mediaAccess?.localRoots ?? options?.mediaLocalRoots,
115
+ readFile: options?.mediaAccess?.readFile ?? options?.mediaReadFile,
116
+ hostReadCapability: Boolean(options?.mediaAccess?.readFile ?? options?.mediaReadFile),
117
+ }),
118
+ );
119
+ hoisted.controllerListeners.clear();
120
+ hoisted.controllerListeners.set("default", {
121
+ sendComposingTo,
122
+ sendMessage,
123
+ sendPoll,
124
+ sendReaction,
125
+ });
126
+ });
127
+
128
+ afterEach(() => {
129
+ resetLogger();
130
+ setLoggerOverride(null);
131
+ hoisted.controllerListeners.clear();
132
+ });
133
+
134
+ it("sends message via active listener", async () => {
135
+ const result = await sendMessageWhatsApp("+1555", "hi", {
136
+ verbose: false,
137
+ cfg: WHATSAPP_TEST_CFG,
138
+ });
139
+ expect(result).toEqual({
140
+ messageId: "msg123",
141
+ toJid: "1555@s.whatsapp.net",
142
+ });
143
+ expect(sendComposingTo).toHaveBeenCalledWith("+1555");
144
+ expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
145
+ });
146
+
147
+ it("uses configured defaultAccount when outbound accountId is omitted", async () => {
148
+ hoisted.controllerListeners.clear();
149
+ hoisted.controllerListeners.set("work", {
150
+ sendComposingTo,
151
+ sendMessage,
152
+ sendPoll,
153
+ sendReaction,
154
+ });
155
+
156
+ const result = await sendMessageWhatsApp("+1555", "hi", {
157
+ verbose: false,
158
+ cfg: {
159
+ channels: {
160
+ whatsapp: {
161
+ defaultAccount: "work",
162
+ accounts: {
163
+ work: {},
164
+ },
165
+ },
166
+ },
167
+ } as OpenClawConfig,
168
+ });
169
+
170
+ expect(result).toEqual({
171
+ messageId: "msg123",
172
+ toJid: "1555@s.whatsapp.net",
173
+ });
174
+ expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
175
+ });
176
+
177
+ it("trims leading whitespace before sending text and captions", async () => {
178
+ await sendMessageWhatsApp("+1555", "\n \thello", {
179
+ verbose: false,
180
+ cfg: WHATSAPP_TEST_CFG,
181
+ });
182
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "hello", undefined, undefined);
183
+
184
+ const buf = Buffer.from("img");
185
+ loadWebMediaMock.mockResolvedValueOnce({
186
+ buffer: buf,
187
+ contentType: "image/jpeg",
188
+ kind: "image",
189
+ });
190
+ await sendMessageWhatsApp("+1555", "\n \tcaption", {
191
+ verbose: false,
192
+ cfg: WHATSAPP_TEST_CFG,
193
+ mediaUrl: "/tmp/pic.jpg",
194
+ });
195
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/jpeg");
196
+ });
197
+
198
+ it("preserves intentional indentation when the caller opts out of transport trimming", async () => {
199
+ await sendMessageWhatsApp("+1555", " indented", {
200
+ verbose: false,
201
+ cfg: WHATSAPP_TEST_CFG,
202
+ preserveLeadingWhitespace: true,
203
+ });
204
+
205
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", " indented", undefined, undefined);
206
+ });
207
+
208
+ it("skips whitespace-only text sends without media", async () => {
209
+ const result = await sendMessageWhatsApp("+1555", "\n \t", {
210
+ verbose: false,
211
+ cfg: WHATSAPP_TEST_CFG,
212
+ });
213
+
214
+ expect(result).toEqual({
215
+ messageId: "",
216
+ toJid: "1555@s.whatsapp.net",
217
+ });
218
+ expect(sendComposingTo).not.toHaveBeenCalled();
219
+ expect(sendMessage).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it("throws a helpful error when no active listener exists", async () => {
223
+ hoisted.controllerListeners.clear();
224
+ await expect(
225
+ sendMessageWhatsApp("+1555", "hi", {
226
+ verbose: false,
227
+ cfg: WHATSAPP_TEST_CFG,
228
+ accountId: "work",
229
+ }),
230
+ ).rejects.toThrow(/No active WhatsApp Web listener/);
231
+ await expect(
232
+ sendMessageWhatsApp("+1555", "hi", {
233
+ verbose: false,
234
+ cfg: WHATSAPP_TEST_CFG,
235
+ accountId: "work",
236
+ }),
237
+ ).rejects.toThrow(/channels login/);
238
+ await expect(
239
+ sendMessageWhatsApp("+1555", "hi", {
240
+ verbose: false,
241
+ cfg: WHATSAPP_TEST_CFG,
242
+ accountId: "work",
243
+ }),
244
+ ).rejects.toThrow(/account: work/);
245
+ });
246
+
247
+ it("maps audio to PTT with opus mime when ogg", async () => {
248
+ const buf = Buffer.from("audio");
249
+ loadWebMediaMock.mockResolvedValueOnce({
250
+ buffer: buf,
251
+ contentType: "audio/ogg",
252
+ kind: "audio",
253
+ });
254
+ await sendMessageWhatsApp("+1555", "voice note", {
255
+ verbose: false,
256
+ cfg: WHATSAPP_TEST_CFG,
257
+ mediaUrl: "/tmp/voice.ogg",
258
+ });
259
+ expect(sendMessage).toHaveBeenNthCalledWith(1, "+1555", "", buf, "audio/ogg; codecs=opus");
260
+ expect(sendMessage).toHaveBeenNthCalledWith(2, "+1555", "voice note", undefined, undefined);
261
+ });
262
+
263
+ it.each([
264
+ { name: "mp3", contentType: "audio/mpeg", fileName: "voice.mp3" },
265
+ { name: "webm", contentType: "audio/webm", fileName: "voice.webm" },
266
+ ])("transcodes $name audio to Ogg Opus before sending a PTT voice note", async (media) => {
267
+ const buf = Buffer.from(media.name);
268
+ loadWebMediaMock.mockResolvedValueOnce({
269
+ buffer: buf,
270
+ contentType: media.contentType,
271
+ kind: "audio",
272
+ fileName: media.fileName,
273
+ });
274
+
275
+ await sendMessageWhatsApp("+1555", "voice note", {
276
+ verbose: false,
277
+ cfg: WHATSAPP_TEST_CFG,
278
+ mediaUrl: `/tmp/${media.fileName}`,
279
+ });
280
+
281
+ expect(hoisted.runFfmpeg).toHaveBeenCalledWith(
282
+ expect.arrayContaining(["-c:a", "libopus", "-ar", "48000", "-b:a", "64k"]),
283
+ );
284
+ expect(sendMessage).toHaveBeenNthCalledWith(
285
+ 1,
286
+ "+1555",
287
+ "",
288
+ Buffer.from("opus-output"),
289
+ "audio/ogg; codecs=opus",
290
+ );
291
+ expect(sendMessage).toHaveBeenNthCalledWith(2, "+1555", "voice note", undefined, undefined);
292
+ });
293
+
294
+ it("maps video with caption", async () => {
295
+ const buf = Buffer.from("video");
296
+ loadWebMediaMock.mockResolvedValueOnce({
297
+ buffer: buf,
298
+ contentType: "video/mp4",
299
+ kind: "video",
300
+ });
301
+ await sendMessageWhatsApp("+1555", "clip", {
302
+ verbose: false,
303
+ cfg: WHATSAPP_TEST_CFG,
304
+ mediaUrl: "/tmp/video.mp4",
305
+ });
306
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "clip", buf, "video/mp4");
307
+ });
308
+
309
+ it("marks gif playback for video when requested", async () => {
310
+ const buf = Buffer.from("gifvid");
311
+ loadWebMediaMock.mockResolvedValueOnce({
312
+ buffer: buf,
313
+ contentType: "video/mp4",
314
+ kind: "video",
315
+ });
316
+ await sendMessageWhatsApp("+1555", "gif", {
317
+ verbose: false,
318
+ cfg: WHATSAPP_TEST_CFG,
319
+ mediaUrl: "/tmp/anim.mp4",
320
+ gifPlayback: true,
321
+ });
322
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "gif", buf, "video/mp4", {
323
+ gifPlayback: true,
324
+ });
325
+ });
326
+
327
+ it("maps image with caption", async () => {
328
+ const buf = Buffer.from("img");
329
+ loadWebMediaMock.mockResolvedValueOnce({
330
+ buffer: buf,
331
+ contentType: "image/jpeg",
332
+ kind: "image",
333
+ });
334
+ await sendMessageWhatsApp("+1555", "pic", {
335
+ verbose: false,
336
+ cfg: WHATSAPP_TEST_CFG,
337
+ mediaUrl: "/tmp/pic.jpg",
338
+ });
339
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg");
340
+ });
341
+
342
+ it("does not retry transient outbound send failures to avoid duplicate sends", async () => {
343
+ sendMessage.mockRejectedValueOnce({ error: { message: "connection closed" } });
344
+
345
+ await expect(
346
+ sendMessageWhatsApp("+1555", "hi", { verbose: false, cfg: WHATSAPP_TEST_CFG }),
347
+ ).rejects.toEqual({ error: { message: "connection closed" } });
348
+ expect(sendMessage).toHaveBeenCalledTimes(1);
349
+ });
350
+
351
+ it("prefers explicit mediaUrl over mediaUrls when both are present", async () => {
352
+ const buf = Buffer.from("img");
353
+ loadWebMediaMock.mockResolvedValueOnce({
354
+ buffer: buf,
355
+ contentType: "image/jpeg",
356
+ kind: "image",
357
+ });
358
+
359
+ await sendMessageWhatsApp("+1555", "pic", {
360
+ verbose: false,
361
+ cfg: WHATSAPP_TEST_CFG,
362
+ mediaUrl: "/tmp/primary.jpg",
363
+ mediaUrls: [" /tmp/secondary.jpg "],
364
+ });
365
+
366
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
367
+ "/tmp/primary.jpg",
368
+ expect.objectContaining({
369
+ hostReadCapability: false,
370
+ }),
371
+ );
372
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg");
373
+ });
374
+
375
+ it("falls back to the first mediaUrls entry when mediaUrl is omitted", async () => {
376
+ const buf = Buffer.from("img");
377
+ loadWebMediaMock.mockResolvedValueOnce({
378
+ buffer: buf,
379
+ contentType: "image/jpeg",
380
+ kind: "image",
381
+ });
382
+ await sendMessageWhatsApp("+1555", "pic", {
383
+ verbose: false,
384
+ cfg: WHATSAPP_TEST_CFG,
385
+ mediaUrls: [" ", " /tmp/pic.jpg "],
386
+ });
387
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
388
+ "/tmp/pic.jpg",
389
+ expect.objectContaining({
390
+ hostReadCapability: false,
391
+ }),
392
+ );
393
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg");
394
+ });
395
+
396
+ it("maps other kinds to document with filename", async () => {
397
+ const buf = Buffer.from("pdf");
398
+ loadWebMediaMock.mockResolvedValueOnce({
399
+ buffer: buf,
400
+ contentType: "application/pdf",
401
+ kind: "document",
402
+ fileName: "file.pdf",
403
+ });
404
+ await sendMessageWhatsApp("+1555", "doc", {
405
+ verbose: false,
406
+ cfg: WHATSAPP_TEST_CFG,
407
+ mediaUrl: "/tmp/file.pdf",
408
+ });
409
+ expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf", {
410
+ fileName: "file.pdf",
411
+ });
412
+ });
413
+
414
+ it("uses account-aware WhatsApp media caps for outbound uploads", async () => {
415
+ hoisted.controllerListeners.set("work", {
416
+ sendComposingTo,
417
+ sendMessage,
418
+ sendPoll,
419
+ sendReaction,
420
+ });
421
+ loadWebMediaMock.mockResolvedValueOnce({
422
+ buffer: Buffer.from("img"),
423
+ contentType: "image/jpeg",
424
+ kind: "image",
425
+ });
426
+
427
+ const cfg = {
428
+ channels: {
429
+ whatsapp: {
430
+ mediaMaxMb: 25,
431
+ accounts: {
432
+ work: {
433
+ mediaMaxMb: 100,
434
+ },
435
+ },
436
+ },
437
+ },
438
+ } as OpenClawConfig;
439
+
440
+ await sendMessageWhatsApp("+1555", "pic", {
441
+ verbose: false,
442
+ accountId: "work",
443
+ cfg,
444
+ mediaUrl: "/tmp/pic.jpg",
445
+ mediaLocalRoots: ["/tmp/workspace"],
446
+ });
447
+
448
+ expect(loadWebMediaMock).toHaveBeenCalledWith(
449
+ "/tmp/pic.jpg",
450
+ expect.objectContaining({
451
+ maxBytes: 100 * 1024 * 1024,
452
+ localRoots: ["/tmp/workspace"],
453
+ }),
454
+ );
455
+ });
456
+
457
+ it("sends polls via active listener", async () => {
458
+ const result = await sendPollWhatsApp(
459
+ "+1555",
460
+ { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 },
461
+ { verbose: false, cfg: WHATSAPP_TEST_CFG },
462
+ );
463
+ expect(result).toEqual({
464
+ messageId: "poll123",
465
+ toJid: "1555@s.whatsapp.net",
466
+ });
467
+ expect(sendPoll).toHaveBeenCalledWith("+1555", {
468
+ question: "Lunch?",
469
+ options: ["Pizza", "Sushi"],
470
+ maxSelections: 2,
471
+ durationSeconds: undefined,
472
+ durationHours: undefined,
473
+ });
474
+ });
475
+
476
+ it("redacts recipients and poll text in outbound logs", async () => {
477
+ const logPath = path.join(os.tmpdir(), `openclaw-outbound-${crypto.randomUUID()}.log`);
478
+ setLoggerOverride({ level: "trace", file: logPath });
479
+
480
+ await sendPollWhatsApp(
481
+ "+1555",
482
+ { question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 },
483
+ { verbose: false, cfg: WHATSAPP_TEST_CFG },
484
+ );
485
+
486
+ await vi.waitFor(
487
+ () => {
488
+ expect(fsSync.existsSync(logPath)).toBe(true);
489
+ },
490
+ { timeout: 2_000, interval: 5 },
491
+ );
492
+
493
+ const content = fsSync.readFileSync(logPath, "utf-8");
494
+ expect(content).toContain(redactIdentifier("+1555"));
495
+ expect(content).toContain(redactIdentifier("1555@s.whatsapp.net"));
496
+ expect(content).not.toContain(`"to":"+1555"`);
497
+ expect(content).not.toContain(`"jid":"1555@s.whatsapp.net"`);
498
+ expect(content).not.toContain("Lunch?");
499
+ });
500
+
501
+ it("sends reactions via active listener", async () => {
502
+ await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", {
503
+ verbose: false,
504
+ cfg: WHATSAPP_TEST_CFG,
505
+ fromMe: false,
506
+ });
507
+ expect(sendReaction).toHaveBeenCalledWith(
508
+ "1555@s.whatsapp.net",
509
+ "msg123",
510
+ "✅",
511
+ false,
512
+ undefined,
513
+ );
514
+ });
515
+ });