@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,843 @@
1
+ import fsSync from "node:fs";
2
+ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
3
+ import { sleep } from "openclaw/plugin-sdk/text-runtime";
4
+ import { beforeAll, describe, expect, it, vi } from "vitest";
5
+ import { loadWebMedia } from "../media.js";
6
+ import { cacheInboundMessageMeta } from "../quoted-message.js";
7
+ import type { WebInboundMsg } from "./types.js";
8
+
9
+ const hoisted = vi.hoisted(() => ({
10
+ runFfmpeg: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
14
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
15
+ "openclaw/plugin-sdk/media-runtime",
16
+ );
17
+ return {
18
+ ...actual,
19
+ runFfmpeg: hoisted.runFfmpeg,
20
+ };
21
+ });
22
+
23
+ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
24
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
25
+ "openclaw/plugin-sdk/runtime-env",
26
+ );
27
+ return {
28
+ ...actual,
29
+ shouldLogVerbose: vi.fn(() => true),
30
+ logVerbose: vi.fn(),
31
+ };
32
+ });
33
+
34
+ vi.mock("openclaw/plugin-sdk/text-runtime", async () => {
35
+ const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/text-runtime")>(
36
+ "openclaw/plugin-sdk/text-runtime",
37
+ );
38
+ return {
39
+ ...actual,
40
+ sleep: vi.fn(async () => {}),
41
+ };
42
+ });
43
+
44
+ vi.mock("../media.js", () => ({
45
+ loadWebMedia: vi.fn(),
46
+ }));
47
+
48
+ let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
49
+ let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
50
+
51
+ function acceptedSendResult(kind: "media" | "text", id: string) {
52
+ return {
53
+ kind,
54
+ messageId: id,
55
+ messageIds: [id],
56
+ keys: [{ id }],
57
+ providerAccepted: true,
58
+ };
59
+ }
60
+
61
+ function unacceptedSendResult(kind: "media" | "text") {
62
+ return {
63
+ kind,
64
+ messageId: "unknown",
65
+ messageIds: [],
66
+ keys: [],
67
+ providerAccepted: false,
68
+ };
69
+ }
70
+
71
+ function makeMsg(): WebInboundMsg {
72
+ return {
73
+ from: "+10000000000",
74
+ to: "+20000000000",
75
+ accountId: "work",
76
+ chatId: "15551234567@s.whatsapp.net",
77
+ chatType: "group",
78
+ id: "msg-1",
79
+ body: "latest batch body",
80
+ senderJid: "222@s.whatsapp.net",
81
+ reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
82
+ sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
83
+ } as unknown as WebInboundMsg;
84
+ }
85
+
86
+ function mockLoadedImageMedia() {
87
+ (
88
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
89
+ ).mockResolvedValueOnce({
90
+ buffer: Buffer.from("img"),
91
+ contentType: "image/jpeg",
92
+ kind: "image",
93
+ });
94
+ }
95
+
96
+ function mockFirstSendMediaFailure(msg: WebInboundMsg, message: string) {
97
+ (
98
+ msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void }
99
+ ).mockRejectedValueOnce(new Error(message));
100
+ }
101
+
102
+ function mockFirstReplyFailure(msg: WebInboundMsg, message: string) {
103
+ (msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce(
104
+ new Error(message),
105
+ );
106
+ }
107
+
108
+ function mockFirstReplyFailureWithWrappedError(msg: WebInboundMsg, message: string) {
109
+ (msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce({
110
+ error: { message },
111
+ });
112
+ }
113
+
114
+ function expectFirstSendMediaPayload(msg: WebInboundMsg) {
115
+ const payload = vi.mocked(msg.sendMedia).mock.calls[0]?.[0];
116
+ expect(payload).toBeDefined();
117
+ return payload;
118
+ }
119
+
120
+ function mockSecondReplySuccess(msg: WebInboundMsg) {
121
+ (msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
122
+ acceptedSendResult("text", "reply-retry-2"),
123
+ );
124
+ }
125
+
126
+ const replyLogger = {
127
+ info: vi.fn(),
128
+ warn: vi.fn(),
129
+ };
130
+
131
+ async function expectReplySuppressed(replyResult: { text: string; isReasoning?: boolean }) {
132
+ const msg = makeMsg();
133
+ await deliverWebReply({
134
+ replyResult,
135
+ msg,
136
+ maxMediaBytes: 1024 * 1024,
137
+ textLimit: 200,
138
+ replyLogger,
139
+ skipLog: true,
140
+ });
141
+ expect(msg.reply).not.toHaveBeenCalled();
142
+ expect(msg.sendMedia).not.toHaveBeenCalled();
143
+ }
144
+
145
+ describe("deliverWebReply", () => {
146
+ beforeAll(async () => {
147
+ ({ deliverWebReply } = await import("./deliver-reply.js"));
148
+ ({ whatsappOutbound } = await import("../outbound-adapter.js"));
149
+ });
150
+
151
+ it("suppresses payloads flagged as reasoning", async () => {
152
+ await expectReplySuppressed({ text: "Reasoning:\n_hidden_", isReasoning: true });
153
+ });
154
+
155
+ it("suppresses payloads that start with reasoning prefix text", async () => {
156
+ await expectReplySuppressed({ text: " \n Reasoning:\n_hidden_" });
157
+ });
158
+
159
+ it("suppresses payloads that start with a quoted reasoning prefix", async () => {
160
+ await expectReplySuppressed({ text: " > Reasoning:\n> _hidden_" });
161
+ });
162
+
163
+ it("does not suppress messages that mention Reasoning: mid-text", async () => {
164
+ const msg = makeMsg();
165
+
166
+ await deliverWebReply({
167
+ replyResult: { text: "Intro line\nReasoning: appears in content but is not a prefix" },
168
+ msg,
169
+ maxMediaBytes: 1024 * 1024,
170
+ textLimit: 200,
171
+ replyLogger,
172
+ skipLog: true,
173
+ });
174
+
175
+ expect(msg.reply).toHaveBeenCalledTimes(1);
176
+ expect(msg.reply).toHaveBeenCalledWith(
177
+ "Intro line\nReasoning: appears in content but is not a prefix",
178
+ undefined,
179
+ );
180
+ });
181
+
182
+ it("sends chunked text replies and logs a summary", async () => {
183
+ const msg = makeMsg();
184
+
185
+ const delivery = await deliverWebReply({
186
+ replyResult: { text: "aaaaaa" },
187
+ msg,
188
+ maxMediaBytes: 1024 * 1024,
189
+ textLimit: 3,
190
+ replyLogger,
191
+ skipLog: true,
192
+ });
193
+
194
+ expect(msg.reply).toHaveBeenCalledTimes(2);
195
+ expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
196
+ expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
197
+ expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
198
+ expect(delivery.providerAccepted).toBe(true);
199
+ expect(delivery.messageIds).toEqual(["reply-sent-1"]);
200
+ });
201
+
202
+ it("reports text replies that Baileys did not accept", async () => {
203
+ const msg = makeMsg();
204
+ vi.mocked(msg.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
205
+
206
+ const delivery = await deliverWebReply({
207
+ replyResult: { text: "hello" },
208
+ msg,
209
+ maxMediaBytes: 1024 * 1024,
210
+ textLimit: 200,
211
+ replyLogger,
212
+ skipLog: true,
213
+ });
214
+
215
+ expect(msg.reply).toHaveBeenCalledTimes(1);
216
+ expect(delivery).toMatchObject({
217
+ messageIds: [],
218
+ providerAccepted: false,
219
+ });
220
+ expect(replyLogger.warn).toHaveBeenCalledWith(
221
+ expect.any(Object),
222
+ "auto-reply text was not accepted by WhatsApp provider",
223
+ );
224
+ });
225
+
226
+ it("strips raw XML tool-call blocks before WhatsApp text delivery", async () => {
227
+ const msg = makeMsg();
228
+
229
+ await deliverWebReply({
230
+ replyResult: {
231
+ text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
232
+ },
233
+ msg,
234
+ maxMediaBytes: 1024 * 1024,
235
+ textLimit: 4000,
236
+ replyLogger,
237
+ skipLog: true,
238
+ });
239
+
240
+ expect(msg.reply).toHaveBeenCalledTimes(1);
241
+ const sentText = vi.mocked(msg.reply).mock.calls[0]?.[0];
242
+ expect(sentText).not.toContain("function_calls");
243
+ expect(sentText).not.toContain("invoke");
244
+ expect(sentText).toContain("Before");
245
+ expect(sentText).toContain("After");
246
+ });
247
+
248
+ it("uses the same final sanitizer stack for auto-reply text delivery", async () => {
249
+ const msg = makeMsg();
250
+
251
+ await deliverWebReply({
252
+ replyResult: {
253
+ text: [
254
+ "Before",
255
+ "<function_calls>",
256
+ ' <invoke name="send_message">',
257
+ ' <parameter name="text"><b>hidden</b></parameter>',
258
+ " </invoke>",
259
+ "</function_calls>",
260
+ "<div>After</div>",
261
+ ].join("\n"),
262
+ },
263
+ msg,
264
+ maxMediaBytes: 1024 * 1024,
265
+ textLimit: 4000,
266
+ replyLogger,
267
+ skipLog: true,
268
+ });
269
+
270
+ expect(msg.reply).toHaveBeenCalledTimes(1);
271
+ expect(vi.mocked(msg.reply).mock.calls[0]?.[0]).toBe("Before\n\nAfter\n");
272
+ });
273
+
274
+ it("strips legacy uppercase TOOL_CALL text before WhatsApp text delivery", async () => {
275
+ const msg = makeMsg();
276
+
277
+ await deliverWebReply({
278
+ replyResult: {
279
+ text: [
280
+ "Before",
281
+ '[TOOL_CALL]{tool => "web_search", args => {"query":"NET stock price"}}[/TOOL_CALL]',
282
+ "After",
283
+ ].join("\n"),
284
+ },
285
+ msg,
286
+ maxMediaBytes: 1024 * 1024,
287
+ textLimit: 4000,
288
+ replyLogger,
289
+ skipLog: true,
290
+ });
291
+
292
+ expect(msg.reply).toHaveBeenCalledTimes(1);
293
+ expect(vi.mocked(msg.reply).mock.calls[0]?.[0]).toBe("Before\n\nAfter");
294
+ });
295
+
296
+ it("keeps quote threading on every text chunk for a threaded reply", async () => {
297
+ const msg = makeMsg();
298
+ cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", {
299
+ participant: "111@s.whatsapp.net",
300
+ body: "quoted body",
301
+ fromMe: true,
302
+ });
303
+
304
+ await deliverWebReply({
305
+ replyResult: { text: "aaaaaa", replyToId: "reply-1" },
306
+ msg,
307
+ maxMediaBytes: 1024 * 1024,
308
+ textLimit: 3,
309
+ replyLogger,
310
+ skipLog: true,
311
+ });
312
+
313
+ expect(msg.reply).toHaveBeenCalledTimes(2);
314
+ expect(msg.reply).toHaveBeenNthCalledWith(
315
+ 1,
316
+ "aaa",
317
+ expect.objectContaining({
318
+ quoted: expect.objectContaining({
319
+ key: expect.objectContaining({
320
+ id: "reply-1",
321
+ fromMe: true,
322
+ participant: "111@s.whatsapp.net",
323
+ }),
324
+ message: { conversation: "quoted body" },
325
+ }),
326
+ }),
327
+ );
328
+ expect(msg.reply).toHaveBeenNthCalledWith(
329
+ 2,
330
+ "aaa",
331
+ expect.objectContaining({
332
+ quoted: expect.objectContaining({
333
+ key: expect.objectContaining({
334
+ id: "reply-1",
335
+ fromMe: true,
336
+ participant: "111@s.whatsapp.net",
337
+ }),
338
+ message: { conversation: "quoted body" },
339
+ }),
340
+ }),
341
+ );
342
+ });
343
+
344
+ it.each(["connection closed", "operation timed out"])(
345
+ "retries text send on transient failure: %s",
346
+ async (errorMessage) => {
347
+ const msg = makeMsg();
348
+ mockFirstReplyFailure(msg, errorMessage);
349
+ mockSecondReplySuccess(msg);
350
+
351
+ await deliverWebReply({
352
+ replyResult: { text: "hi" },
353
+ msg,
354
+ maxMediaBytes: 1024 * 1024,
355
+ textLimit: 200,
356
+ replyLogger,
357
+ skipLog: true,
358
+ });
359
+
360
+ expect(msg.reply).toHaveBeenCalledTimes(2);
361
+ expect(sleep).toHaveBeenCalledWith(500);
362
+ },
363
+ );
364
+
365
+ it("retries text send on wrapped transient failure", async () => {
366
+ const msg = makeMsg();
367
+ mockFirstReplyFailureWithWrappedError(msg, "connection closed");
368
+ mockSecondReplySuccess(msg);
369
+
370
+ await deliverWebReply({
371
+ replyResult: { text: "hi" },
372
+ msg,
373
+ maxMediaBytes: 1024 * 1024,
374
+ textLimit: 200,
375
+ replyLogger,
376
+ skipLog: true,
377
+ });
378
+
379
+ expect(msg.reply).toHaveBeenCalledTimes(2);
380
+ expect(sleep).toHaveBeenCalledWith(500);
381
+ });
382
+
383
+ it("sends image media with caption and then remaining text", async () => {
384
+ const msg = makeMsg();
385
+ const mediaLocalRoots = ["/tmp/workspace-work"];
386
+ mockLoadedImageMedia();
387
+
388
+ await deliverWebReply({
389
+ replyResult: { text: "aaaaaa", mediaUrl: "http://example.com/img.jpg" },
390
+ msg,
391
+ mediaLocalRoots,
392
+ maxMediaBytes: 1024 * 1024,
393
+ textLimit: 3,
394
+ replyLogger,
395
+ skipLog: true,
396
+ });
397
+
398
+ expect(loadWebMedia).toHaveBeenCalledWith("http://example.com/img.jpg", {
399
+ maxBytes: 1024 * 1024,
400
+ localRoots: mediaLocalRoots,
401
+ });
402
+
403
+ expect(msg.sendMedia).toHaveBeenCalledWith(
404
+ expect.objectContaining({
405
+ image: expect.any(Buffer),
406
+ caption: "aaa",
407
+ mimetype: "image/jpeg",
408
+ }),
409
+ undefined,
410
+ );
411
+ expect(msg.reply).toHaveBeenCalledWith("aaa", undefined);
412
+ expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (media)");
413
+ expect(logVerbose).toHaveBeenCalled();
414
+ });
415
+
416
+ it("preserves leading indentation after trimming only leading blank lines", async () => {
417
+ const msg = makeMsg();
418
+
419
+ await deliverWebReply({
420
+ replyResult: { text: "\n \n indented block" },
421
+ msg,
422
+ maxMediaBytes: 1024 * 1024,
423
+ textLimit: 200,
424
+ replyLogger,
425
+ skipLog: true,
426
+ });
427
+
428
+ expect(msg.reply).toHaveBeenCalledTimes(1);
429
+ expect(msg.reply).toHaveBeenCalledWith(" indented block", undefined);
430
+ });
431
+
432
+ it("keeps quote threading on media and trailing text chunks for a threaded reply", async () => {
433
+ const msg = makeMsg();
434
+ mockLoadedImageMedia();
435
+ cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-2", {
436
+ participant: "111@s.whatsapp.net",
437
+ body: "quoted media body",
438
+ fromMe: true,
439
+ });
440
+
441
+ await deliverWebReply({
442
+ replyResult: {
443
+ text: "captiontrail",
444
+ mediaUrl: "http://example.com/img.jpg",
445
+ replyToId: "reply-2",
446
+ },
447
+ msg,
448
+ maxMediaBytes: 1024 * 1024,
449
+ textLimit: 7,
450
+ replyLogger,
451
+ skipLog: true,
452
+ });
453
+
454
+ expect(msg.sendMedia).toHaveBeenCalledWith(
455
+ expect.objectContaining({
456
+ image: expect.any(Buffer),
457
+ caption: "caption",
458
+ mimetype: "image/jpeg",
459
+ }),
460
+ expect.objectContaining({
461
+ quoted: expect.objectContaining({
462
+ key: expect.objectContaining({
463
+ id: "reply-2",
464
+ fromMe: true,
465
+ participant: "111@s.whatsapp.net",
466
+ }),
467
+ message: { conversation: "quoted media body" },
468
+ }),
469
+ }),
470
+ );
471
+ expect(msg.reply).toHaveBeenCalledWith(
472
+ "trail",
473
+ expect.objectContaining({
474
+ quoted: expect.objectContaining({
475
+ key: expect.objectContaining({
476
+ id: "reply-2",
477
+ fromMe: true,
478
+ participant: "111@s.whatsapp.net",
479
+ }),
480
+ message: { conversation: "quoted media body" },
481
+ }),
482
+ }),
483
+ );
484
+ });
485
+
486
+ it("retries media send on transient failure", async () => {
487
+ const msg = makeMsg();
488
+ mockLoadedImageMedia();
489
+ mockFirstSendMediaFailure(msg, "socket reset");
490
+ (
491
+ msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
492
+ ).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
493
+
494
+ await deliverWebReply({
495
+ replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
496
+ msg,
497
+ maxMediaBytes: 1024 * 1024,
498
+ textLimit: 200,
499
+ replyLogger,
500
+ skipLog: true,
501
+ });
502
+
503
+ expect(msg.sendMedia).toHaveBeenCalledTimes(2);
504
+ expect(sleep).toHaveBeenCalledWith(500);
505
+ });
506
+
507
+ it("falls back to text-only when the first media send fails", async () => {
508
+ const msg = makeMsg();
509
+ mockLoadedImageMedia();
510
+ mockFirstSendMediaFailure(msg, "boom");
511
+
512
+ await deliverWebReply({
513
+ replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
514
+ msg,
515
+ maxMediaBytes: 1024 * 1024,
516
+ textLimit: 20,
517
+ replyLogger,
518
+ skipLog: true,
519
+ });
520
+
521
+ expect(msg.reply).toHaveBeenCalledTimes(1);
522
+ expect(
523
+ String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
524
+ ).toContain("⚠️ Media failed");
525
+ expect(
526
+ String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
527
+ ).not.toContain("boom");
528
+ expect(replyLogger.warn).toHaveBeenCalledWith(
529
+ expect.objectContaining({ mediaUrl: "http://example.com/img.jpg" }),
530
+ "failed to send web media reply",
531
+ );
532
+ });
533
+
534
+ it("still attempts later media after the first media fails", async () => {
535
+ vi.clearAllMocks();
536
+ const msg = makeMsg();
537
+ (
538
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
539
+ ).mockResolvedValueOnce({
540
+ buffer: Buffer.from("bad"),
541
+ contentType: "image/jpeg",
542
+ kind: "image",
543
+ });
544
+ (
545
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
546
+ ).mockResolvedValueOnce({
547
+ buffer: Buffer.from("good"),
548
+ contentType: "application/pdf",
549
+ kind: "file",
550
+ fileName: "good.pdf",
551
+ });
552
+ mockFirstSendMediaFailure(msg, "boom");
553
+ (
554
+ msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
555
+ ).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
556
+
557
+ await deliverWebReply({
558
+ replyResult: {
559
+ text: "caption",
560
+ mediaUrls: ["http://example.com/bad.jpg", "http://example.com/good.pdf"],
561
+ },
562
+ msg,
563
+ maxMediaBytes: 1024 * 1024,
564
+ textLimit: 200,
565
+ replyLogger,
566
+ skipLog: true,
567
+ });
568
+
569
+ expect(loadWebMedia).toHaveBeenNthCalledWith(1, "http://example.com/bad.jpg", {
570
+ maxBytes: 1024 * 1024,
571
+ localRoots: undefined,
572
+ });
573
+ expect(loadWebMedia).toHaveBeenNthCalledWith(2, "http://example.com/good.pdf", {
574
+ maxBytes: 1024 * 1024,
575
+ localRoots: undefined,
576
+ });
577
+ expect(msg.sendMedia).toHaveBeenCalledTimes(2);
578
+ expect(msg.sendMedia).toHaveBeenNthCalledWith(
579
+ 2,
580
+ expect.objectContaining({
581
+ document: expect.any(Buffer),
582
+ fileName: "good.pdf",
583
+ caption: undefined,
584
+ mimetype: "application/pdf",
585
+ }),
586
+ undefined,
587
+ );
588
+ expect(msg.reply).toHaveBeenCalledTimes(1);
589
+ expect(
590
+ String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
591
+ ).toContain("⚠️ Media failed");
592
+ expect(
593
+ String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
594
+ ).not.toContain("boom");
595
+ });
596
+
597
+ it("sanitizes XML tool-call blocks for outbound sendPayload delivery", async () => {
598
+ const sendWhatsApp = vi.fn(async (_to: string, _text: string) => ({
599
+ messageId: "wa-1",
600
+ toJid: "jid",
601
+ }));
602
+
603
+ await whatsappOutbound.sendPayload!({
604
+ cfg: {},
605
+ to: "5511999999999@c.us",
606
+ text: "",
607
+ payload: {
608
+ text: 'Before\n<function_calls><invoke name="web_search"><parameter name="query">x</parameter></invoke></function_calls>\nAfter',
609
+ },
610
+ deps: { sendWhatsApp },
611
+ });
612
+
613
+ expect(sendWhatsApp).toHaveBeenCalledTimes(1);
614
+ const sentText = sendWhatsApp.mock.calls[0]?.[1];
615
+ expect(sentText).not.toContain("function_calls");
616
+ expect(sentText).not.toContain("invoke");
617
+ expect(sentText).toContain("Before");
618
+ expect(sentText).toContain("After");
619
+ });
620
+
621
+ it("keeps payload and auto-reply media normalization in parity", async () => {
622
+ const payload = {
623
+ text: "\n\ncaption",
624
+ mediaUrls: [" ", " /tmp/voice.ogg "],
625
+ };
626
+ const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
627
+
628
+ await whatsappOutbound.sendPayload!({
629
+ cfg: {},
630
+ to: "5511999999999@c.us",
631
+ text: "",
632
+ payload,
633
+ deps: { sendWhatsApp },
634
+ });
635
+
636
+ const msg = makeMsg();
637
+ (
638
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
639
+ ).mockResolvedValueOnce({
640
+ buffer: Buffer.from("aud"),
641
+ contentType: "audio/ogg",
642
+ kind: "audio",
643
+ });
644
+
645
+ await deliverWebReply({
646
+ replyResult: payload,
647
+ msg,
648
+ maxMediaBytes: 1024 * 1024,
649
+ textLimit: 200,
650
+ replyLogger,
651
+ skipLog: true,
652
+ });
653
+
654
+ expect(sendWhatsApp).toHaveBeenCalledTimes(1);
655
+ expect(sendWhatsApp).toHaveBeenCalledWith("5511999999999@c.us", "caption", {
656
+ verbose: false,
657
+ cfg: {},
658
+ mediaUrl: "/tmp/voice.ogg",
659
+ mediaLocalRoots: undefined,
660
+ accountId: undefined,
661
+ gifPlayback: undefined,
662
+ });
663
+ expect(loadWebMedia).toHaveBeenCalledWith("/tmp/voice.ogg", {
664
+ maxBytes: 1024 * 1024,
665
+ localRoots: undefined,
666
+ });
667
+ expect(msg.sendMedia).toHaveBeenCalledTimes(1);
668
+ expect(msg.sendMedia).toHaveBeenCalledWith(
669
+ expect.objectContaining({
670
+ audio: expect.any(Buffer),
671
+ ptt: true,
672
+ mimetype: "audio/ogg; codecs=opus",
673
+ }),
674
+ undefined,
675
+ );
676
+ expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
677
+ expect(msg.reply).toHaveBeenCalledWith("caption", undefined);
678
+ });
679
+
680
+ it("sends audio media as ptt voice note with visible text separately", async () => {
681
+ const msg = makeMsg();
682
+ (
683
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
684
+ ).mockResolvedValueOnce({
685
+ buffer: Buffer.from("aud"),
686
+ contentType: "audio/ogg",
687
+ kind: "audio",
688
+ });
689
+
690
+ await deliverWebReply({
691
+ replyResult: { text: "cap", mediaUrl: "http://example.com/a.ogg" },
692
+ msg,
693
+ maxMediaBytes: 1024 * 1024,
694
+ textLimit: 200,
695
+ replyLogger,
696
+ skipLog: true,
697
+ });
698
+
699
+ expect(msg.sendMedia).toHaveBeenCalledWith(
700
+ expect.objectContaining({
701
+ audio: expect.any(Buffer),
702
+ ptt: true,
703
+ mimetype: "audio/ogg; codecs=opus",
704
+ }),
705
+ undefined,
706
+ );
707
+ expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
708
+ expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
709
+ });
710
+
711
+ it("transcodes mp3 audio media before sending a ptt voice note", async () => {
712
+ vi.clearAllMocks();
713
+ hoisted.runFfmpeg.mockImplementation(async (args: string[]) => {
714
+ fsSync.writeFileSync(args.at(-1) ?? "", Buffer.from("opus-output"));
715
+ return "";
716
+ });
717
+ const msg = makeMsg();
718
+ (
719
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
720
+ ).mockResolvedValueOnce({
721
+ buffer: Buffer.from("mp3"),
722
+ contentType: "audio/mpeg",
723
+ kind: "audio",
724
+ fileName: "voice.mp3",
725
+ });
726
+
727
+ await deliverWebReply({
728
+ replyResult: { text: "cap", mediaUrl: "http://example.com/a.mp3" },
729
+ msg,
730
+ maxMediaBytes: 1024 * 1024,
731
+ textLimit: 200,
732
+ replyLogger,
733
+ skipLog: true,
734
+ });
735
+
736
+ expect(hoisted.runFfmpeg).toHaveBeenCalledWith(
737
+ expect.arrayContaining(["-c:a", "libopus", "-ar", "48000", "-b:a", "64k"]),
738
+ );
739
+ expect(msg.sendMedia).toHaveBeenCalledWith(
740
+ expect.objectContaining({
741
+ audio: Buffer.from("opus-output"),
742
+ ptt: true,
743
+ mimetype: "audio/ogg; codecs=opus",
744
+ }),
745
+ undefined,
746
+ );
747
+ expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
748
+ expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
749
+ });
750
+
751
+ it("sends video media", async () => {
752
+ const msg = makeMsg();
753
+ (
754
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
755
+ ).mockResolvedValueOnce({
756
+ buffer: Buffer.from("vid"),
757
+ contentType: "video/mp4",
758
+ kind: "video",
759
+ });
760
+
761
+ await deliverWebReply({
762
+ replyResult: { text: "cap", mediaUrl: "http://example.com/v.mp4" },
763
+ msg,
764
+ maxMediaBytes: 1024 * 1024,
765
+ textLimit: 200,
766
+ replyLogger,
767
+ skipLog: true,
768
+ });
769
+
770
+ expect(msg.sendMedia).toHaveBeenCalledWith(
771
+ expect.objectContaining({
772
+ video: expect.any(Buffer),
773
+ caption: "cap",
774
+ mimetype: "video/mp4",
775
+ }),
776
+ undefined,
777
+ );
778
+ });
779
+
780
+ it("sends non-audio/image/video media as document", async () => {
781
+ const msg = makeMsg();
782
+ (
783
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
784
+ ).mockResolvedValueOnce({
785
+ buffer: Buffer.from("bin"),
786
+ contentType: undefined,
787
+ kind: "file",
788
+ fileName: "x.bin",
789
+ });
790
+
791
+ await deliverWebReply({
792
+ replyResult: { text: "cap", mediaUrl: "http://example.com/x.bin" },
793
+ msg,
794
+ maxMediaBytes: 1024 * 1024,
795
+ textLimit: 200,
796
+ replyLogger,
797
+ skipLog: true,
798
+ });
799
+
800
+ expect(msg.sendMedia).toHaveBeenCalledWith(
801
+ expect.objectContaining({
802
+ document: expect.any(Buffer),
803
+ fileName: "x.bin",
804
+ caption: "cap",
805
+ mimetype: "application/octet-stream",
806
+ }),
807
+ undefined,
808
+ );
809
+ });
810
+
811
+ it("strips URL query and fragment data from derived document file names", async () => {
812
+ const msg = makeMsg();
813
+ (
814
+ loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
815
+ ).mockResolvedValueOnce({
816
+ buffer: Buffer.from("pdf"),
817
+ contentType: "application/pdf",
818
+ kind: "file",
819
+ });
820
+
821
+ await deliverWebReply({
822
+ replyResult: {
823
+ text: "cap",
824
+ mediaUrl: "https://example.com/report.pdf?X-Amz-Signature=secret#frag",
825
+ },
826
+ msg,
827
+ maxMediaBytes: 1024 * 1024,
828
+ textLimit: 200,
829
+ replyLogger,
830
+ skipLog: true,
831
+ });
832
+
833
+ expect(msg.sendMedia).toHaveBeenCalledWith(
834
+ expect.objectContaining({
835
+ document: expect.any(Buffer),
836
+ fileName: "report.pdf",
837
+ caption: "cap",
838
+ mimetype: "application/pdf",
839
+ }),
840
+ undefined,
841
+ );
842
+ });
843
+ });