@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,36 @@
1
+ import { type ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
2
+ import { chunkText } from "openclaw/plugin-sdk/reply-chunking";
3
+ import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
4
+ import { createWhatsAppOutboundBase } from "./outbound-base.js";
5
+ import { normalizeWhatsAppPayloadText } from "./outbound-media-contract.js";
6
+ import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
7
+
8
+ type WhatsAppSendModule = typeof import("./send.js");
9
+
10
+ let whatsAppSendModulePromise: Promise<WhatsAppSendModule> | undefined;
11
+
12
+ function loadWhatsAppSendModule(): Promise<WhatsAppSendModule> {
13
+ whatsAppSendModulePromise ??= import("./send.js");
14
+ return whatsAppSendModulePromise;
15
+ }
16
+
17
+ function normalizeOutboundText(text: string | undefined): string {
18
+ return normalizeWhatsAppPayloadText(text);
19
+ }
20
+
21
+ export const whatsappOutbound: ChannelOutboundAdapter = createWhatsAppOutboundBase({
22
+ chunker: chunkText,
23
+ sendMessageWhatsApp: async (to, text, options) =>
24
+ await (
25
+ await loadWhatsAppSendModule()
26
+ ).sendMessageWhatsApp(to, normalizeOutboundText(text), {
27
+ ...options,
28
+ }),
29
+ sendPollWhatsApp: async (to, poll, options) =>
30
+ await (await loadWhatsAppSendModule()).sendPollWhatsApp(to, poll, options),
31
+ shouldLogVerbose: () => shouldLogVerbose(),
32
+ resolveTarget: ({ to, allowFrom, mode }) =>
33
+ resolveWhatsAppOutboundTarget({ to, allowFrom, mode }),
34
+ normalizeText: normalizeOutboundText,
35
+ skipEmptyText: true,
36
+ });
@@ -0,0 +1,560 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createWhatsAppOutboundBase } from "./outbound-base.js";
3
+ import { createWhatsAppPollFixture } from "./outbound-test-support.js";
4
+ import { cacheInboundMessageMeta } from "./quoted-message.js";
5
+
6
+ describe("createWhatsAppOutboundBase", () => {
7
+ it("exposes the provided chunker", () => {
8
+ const outbound = createWhatsAppOutboundBase({
9
+ chunker: (text, limit) => [text.slice(0, limit)],
10
+ sendMessageWhatsApp: vi.fn(),
11
+ sendPollWhatsApp: vi.fn(),
12
+ shouldLogVerbose: () => false,
13
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
14
+ });
15
+
16
+ expect(outbound.chunker?.("alpha beta", 5)).toEqual(["alpha"]);
17
+ });
18
+
19
+ it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => {
20
+ const sendMessageWhatsApp = vi.fn(async () => ({
21
+ messageId: "msg-1",
22
+ toJid: "15551234567@s.whatsapp.net",
23
+ }));
24
+ const outbound = createWhatsAppOutboundBase({
25
+ chunker: (text) => [text],
26
+ sendMessageWhatsApp,
27
+ sendPollWhatsApp: vi.fn(),
28
+ shouldLogVerbose: () => false,
29
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
30
+ });
31
+ const mediaLocalRoots = ["/tmp/workspace"];
32
+
33
+ const result = await outbound.sendMedia!({
34
+ cfg: {} as never,
35
+ to: "whatsapp:+15551234567",
36
+ text: "photo",
37
+ mediaUrl: "/tmp/workspace/photo.png",
38
+ mediaLocalRoots,
39
+ accountId: "default",
40
+ deps: { sendWhatsApp: sendMessageWhatsApp },
41
+ gifPlayback: false,
42
+ });
43
+
44
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
45
+ "whatsapp:+15551234567",
46
+ "photo",
47
+ expect.objectContaining({
48
+ verbose: false,
49
+ mediaUrl: "/tmp/workspace/photo.png",
50
+ mediaLocalRoots,
51
+ accountId: "default",
52
+ gifPlayback: false,
53
+ }),
54
+ );
55
+ expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" });
56
+ });
57
+
58
+ it("forwards audioAsVoice to sendMessageWhatsApp", async () => {
59
+ const sendMessageWhatsApp = vi.fn(async () => ({
60
+ messageId: "msg-voice",
61
+ toJid: "15551234567@s.whatsapp.net",
62
+ }));
63
+ const outbound = createWhatsAppOutboundBase({
64
+ chunker: (text) => [text],
65
+ sendMessageWhatsApp,
66
+ sendPollWhatsApp: vi.fn(),
67
+ shouldLogVerbose: () => false,
68
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
69
+ });
70
+
71
+ await outbound.sendMedia!({
72
+ cfg: {} as never,
73
+ to: "whatsapp:+15551234567",
74
+ text: "voice",
75
+ mediaUrl: "/tmp/workspace/voice.ogg",
76
+ audioAsVoice: true,
77
+ accountId: "default",
78
+ deps: { sendWhatsApp: sendMessageWhatsApp },
79
+ });
80
+
81
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
82
+ "whatsapp:+15551234567",
83
+ "voice",
84
+ expect.objectContaining({
85
+ mediaUrl: "/tmp/workspace/voice.ogg",
86
+ audioAsVoice: true,
87
+ accountId: "default",
88
+ }),
89
+ );
90
+ });
91
+
92
+ it("uses the configured default account for quote metadata lookup when accountId is omitted", async () => {
93
+ cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-1", {
94
+ participant: "111@s.whatsapp.net",
95
+ body: "quoted body",
96
+ });
97
+ const sendMessageWhatsApp = vi.fn(async () => ({
98
+ messageId: "msg-1",
99
+ toJid: "15551234567@s.whatsapp.net",
100
+ }));
101
+ const outbound = createWhatsAppOutboundBase({
102
+ chunker: (text) => [text],
103
+ sendMessageWhatsApp,
104
+ sendPollWhatsApp: vi.fn(),
105
+ shouldLogVerbose: () => false,
106
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
107
+ });
108
+
109
+ await outbound.sendText!({
110
+ cfg: {
111
+ channels: {
112
+ whatsapp: {
113
+ defaultAccount: "work",
114
+ accounts: {
115
+ work: {},
116
+ },
117
+ },
118
+ },
119
+ } as never,
120
+ to: "whatsapp:+15551234567",
121
+ text: "reply",
122
+ deps: { sendWhatsApp: sendMessageWhatsApp },
123
+ replyToId: "reply-1",
124
+ });
125
+
126
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
127
+ "whatsapp:+15551234567",
128
+ "reply",
129
+ expect.objectContaining({
130
+ quotedMessageKey: {
131
+ id: "reply-1",
132
+ remoteJid: "15551234567@s.whatsapp.net",
133
+ fromMe: false,
134
+ participant: "111@s.whatsapp.net",
135
+ messageText: "quoted body",
136
+ },
137
+ }),
138
+ );
139
+ });
140
+
141
+ it("normalizes mixed-case defaultAccount before quote metadata lookup", async () => {
142
+ cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-case", {
143
+ participant: "333@s.whatsapp.net",
144
+ body: "case-normalized body",
145
+ });
146
+ const sendMessageWhatsApp = vi.fn(async () => ({
147
+ messageId: "msg-case",
148
+ toJid: "15551234567@s.whatsapp.net",
149
+ }));
150
+ const outbound = createWhatsAppOutboundBase({
151
+ chunker: (text) => [text],
152
+ sendMessageWhatsApp,
153
+ sendPollWhatsApp: vi.fn(),
154
+ shouldLogVerbose: () => false,
155
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
156
+ });
157
+
158
+ await outbound.sendText!({
159
+ cfg: {
160
+ channels: {
161
+ whatsapp: {
162
+ defaultAccount: "Work",
163
+ accounts: {
164
+ work: {},
165
+ other: {},
166
+ },
167
+ },
168
+ },
169
+ } as never,
170
+ to: "whatsapp:+15551234567",
171
+ text: "reply",
172
+ deps: { sendWhatsApp: sendMessageWhatsApp },
173
+ replyToId: "reply-case",
174
+ });
175
+
176
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
177
+ "whatsapp:+15551234567",
178
+ "reply",
179
+ expect.objectContaining({
180
+ quotedMessageKey: {
181
+ id: "reply-case",
182
+ remoteJid: "15551234567@s.whatsapp.net",
183
+ fromMe: false,
184
+ participant: "333@s.whatsapp.net",
185
+ messageText: "case-normalized body",
186
+ },
187
+ }),
188
+ );
189
+ });
190
+
191
+ it("matches sorted default-account fallback for quote metadata lookup when defaultAccount is unset", async () => {
192
+ cacheInboundMessageMeta("alpha", "15551234567@s.whatsapp.net", "reply-2", {
193
+ participant: "222@s.whatsapp.net",
194
+ body: "sorted default body",
195
+ });
196
+ const sendMessageWhatsApp = vi.fn(async () => ({
197
+ messageId: "msg-2",
198
+ toJid: "15551234567@s.whatsapp.net",
199
+ }));
200
+ const outbound = createWhatsAppOutboundBase({
201
+ chunker: (text) => [text],
202
+ sendMessageWhatsApp,
203
+ sendPollWhatsApp: vi.fn(),
204
+ shouldLogVerbose: () => false,
205
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
206
+ });
207
+
208
+ await outbound.sendText!({
209
+ cfg: {
210
+ channels: {
211
+ whatsapp: {
212
+ accounts: {
213
+ zeta: {},
214
+ alpha: {},
215
+ },
216
+ },
217
+ },
218
+ } as never,
219
+ to: "whatsapp:+15551234567",
220
+ text: "reply",
221
+ deps: { sendWhatsApp: sendMessageWhatsApp },
222
+ replyToId: "reply-2",
223
+ });
224
+
225
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
226
+ "whatsapp:+15551234567",
227
+ "reply",
228
+ expect.objectContaining({
229
+ quotedMessageKey: {
230
+ id: "reply-2",
231
+ remoteJid: "15551234567@s.whatsapp.net",
232
+ fromMe: false,
233
+ participant: "222@s.whatsapp.net",
234
+ messageText: "sorted default body",
235
+ },
236
+ }),
237
+ );
238
+ });
239
+
240
+ it("reuses the cached inbound remoteJid when the outbound target normalizes differently", async () => {
241
+ cacheInboundMessageMeta("default", "277038292303944@lid", "reply-lid", {
242
+ participant: "5511976136970@s.whatsapp.net",
243
+ body: "quoted from lid chat",
244
+ fromMe: true,
245
+ });
246
+ const sendMessageWhatsApp = vi.fn(async () => ({
247
+ messageId: "msg-lid",
248
+ toJid: "5511976136970@s.whatsapp.net",
249
+ }));
250
+ const outbound = createWhatsAppOutboundBase({
251
+ chunker: (text) => [text],
252
+ sendMessageWhatsApp,
253
+ sendPollWhatsApp: vi.fn(),
254
+ shouldLogVerbose: () => false,
255
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
256
+ });
257
+
258
+ await outbound.sendText!({
259
+ cfg: {
260
+ channels: {
261
+ whatsapp: {
262
+ accounts: {
263
+ default: {},
264
+ },
265
+ },
266
+ },
267
+ } as never,
268
+ to: "whatsapp:+5511976136970",
269
+ text: "reply",
270
+ accountId: "default",
271
+ deps: { sendWhatsApp: sendMessageWhatsApp },
272
+ replyToId: "reply-lid",
273
+ });
274
+
275
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
276
+ "whatsapp:+5511976136970",
277
+ "reply",
278
+ expect.objectContaining({
279
+ quotedMessageKey: {
280
+ id: "reply-lid",
281
+ remoteJid: "277038292303944@lid",
282
+ fromMe: true,
283
+ participant: "5511976136970@s.whatsapp.net",
284
+ messageText: "quoted from lid chat",
285
+ },
286
+ }),
287
+ );
288
+ });
289
+
290
+ it("normalizes explicit accountId before quote metadata lookup", async () => {
291
+ cacheInboundMessageMeta("work", "15551234567@s.whatsapp.net", "reply-explicit", {
292
+ participant: "333@s.whatsapp.net",
293
+ body: "explicit account body",
294
+ });
295
+ const sendMessageWhatsApp = vi.fn(async () => ({
296
+ messageId: "msg-explicit",
297
+ toJid: "15551234567@s.whatsapp.net",
298
+ }));
299
+ const outbound = createWhatsAppOutboundBase({
300
+ chunker: (text) => [text],
301
+ sendMessageWhatsApp,
302
+ sendPollWhatsApp: vi.fn(),
303
+ shouldLogVerbose: () => false,
304
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
305
+ });
306
+
307
+ await outbound.sendText!({
308
+ cfg: {
309
+ channels: {
310
+ whatsapp: {
311
+ accounts: {
312
+ work: {},
313
+ },
314
+ },
315
+ },
316
+ } as never,
317
+ to: "whatsapp:+15551234567",
318
+ text: "reply",
319
+ accountId: "Work",
320
+ deps: { sendWhatsApp: sendMessageWhatsApp },
321
+ replyToId: "reply-explicit",
322
+ });
323
+
324
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
325
+ "whatsapp:+15551234567",
326
+ "reply",
327
+ expect.objectContaining({
328
+ quotedMessageKey: {
329
+ id: "reply-explicit",
330
+ remoteJid: "15551234567@s.whatsapp.net",
331
+ fromMe: false,
332
+ participant: "333@s.whatsapp.net",
333
+ messageText: "explicit account body",
334
+ },
335
+ }),
336
+ );
337
+ });
338
+
339
+ it("falls back to the target JID when quote metadata only exists in a different conversation", async () => {
340
+ cacheInboundMessageMeta("default", "120363400000000000@g.us", "reply-group", {
341
+ participant: "5511976136970@s.whatsapp.net",
342
+ body: "group-only body",
343
+ });
344
+ const sendMessageWhatsApp = vi.fn(async () => ({
345
+ messageId: "msg-group-miss",
346
+ toJid: "5511976136970@s.whatsapp.net",
347
+ }));
348
+ const outbound = createWhatsAppOutboundBase({
349
+ chunker: (text) => [text],
350
+ sendMessageWhatsApp,
351
+ sendPollWhatsApp: vi.fn(),
352
+ shouldLogVerbose: () => false,
353
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
354
+ });
355
+
356
+ await outbound.sendText!({
357
+ cfg: {
358
+ channels: {
359
+ whatsapp: {
360
+ accounts: {
361
+ default: {},
362
+ },
363
+ },
364
+ },
365
+ } as never,
366
+ to: "whatsapp:+5511976136970",
367
+ text: "reply",
368
+ accountId: "default",
369
+ deps: { sendWhatsApp: sendMessageWhatsApp },
370
+ replyToId: "reply-group",
371
+ });
372
+
373
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
374
+ "whatsapp:+5511976136970",
375
+ "reply",
376
+ expect.objectContaining({
377
+ quotedMessageKey: {
378
+ id: "reply-group",
379
+ remoteJid: "5511976136970@s.whatsapp.net",
380
+ fromMe: false,
381
+ participant: undefined,
382
+ messageText: undefined,
383
+ },
384
+ }),
385
+ );
386
+ });
387
+
388
+ it("normalizes mediaUrls before payload delivery", async () => {
389
+ const sendMessageWhatsApp = vi.fn(async () => ({
390
+ messageId: "msg-1",
391
+ toJid: "15551234567@s.whatsapp.net",
392
+ }));
393
+ const outbound = createWhatsAppOutboundBase({
394
+ chunker: (text) => [text],
395
+ sendMessageWhatsApp,
396
+ sendPollWhatsApp: vi.fn(),
397
+ shouldLogVerbose: () => false,
398
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
399
+ });
400
+
401
+ await outbound.sendPayload!({
402
+ cfg: {} as never,
403
+ to: "whatsapp:+15551234567",
404
+ text: "",
405
+ payload: {
406
+ text: "\n\ncaption",
407
+ mediaUrls: [" ", " /tmp/voice.ogg "],
408
+ },
409
+ deps: { sendWhatsApp: sendMessageWhatsApp },
410
+ });
411
+
412
+ expect(sendMessageWhatsApp).toHaveBeenCalledTimes(1);
413
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
414
+ "whatsapp:+15551234567",
415
+ "caption",
416
+ expect.objectContaining({
417
+ verbose: false,
418
+ mediaUrl: "/tmp/voice.ogg",
419
+ }),
420
+ );
421
+ });
422
+
423
+ it("keeps explicit mediaUrl first when payload also includes mediaUrls", async () => {
424
+ const sendMessageWhatsApp = vi.fn(async () => ({
425
+ messageId: "msg-1",
426
+ toJid: "15551234567@s.whatsapp.net",
427
+ }));
428
+ const outbound = createWhatsAppOutboundBase({
429
+ chunker: (text) => [text],
430
+ sendMessageWhatsApp,
431
+ sendPollWhatsApp: vi.fn(),
432
+ shouldLogVerbose: () => false,
433
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
434
+ });
435
+
436
+ await outbound.sendPayload!({
437
+ cfg: {} as never,
438
+ to: "whatsapp:+15551234567",
439
+ text: "",
440
+ payload: {
441
+ text: "\n\ncaption",
442
+ mediaUrl: "/tmp/primary.ogg",
443
+ mediaUrls: [" /tmp/secondary.ogg "],
444
+ },
445
+ deps: { sendWhatsApp: sendMessageWhatsApp },
446
+ });
447
+
448
+ expect(sendMessageWhatsApp).toHaveBeenNthCalledWith(
449
+ 1,
450
+ "whatsapp:+15551234567",
451
+ "caption",
452
+ expect.objectContaining({
453
+ mediaUrl: "/tmp/primary.ogg",
454
+ }),
455
+ );
456
+ expect(sendMessageWhatsApp).toHaveBeenNthCalledWith(
457
+ 2,
458
+ "whatsapp:+15551234567",
459
+ "",
460
+ expect.objectContaining({
461
+ mediaUrl: "/tmp/secondary.ogg",
462
+ }),
463
+ );
464
+ });
465
+
466
+ it("uses the caller-provided text normalization for payload delivery", async () => {
467
+ const sendMessageWhatsApp = vi.fn(async () => ({
468
+ messageId: "msg-1",
469
+ toJid: "15551234567@s.whatsapp.net",
470
+ }));
471
+ const outbound = createWhatsAppOutboundBase({
472
+ chunker: (text) => [text],
473
+ sendMessageWhatsApp,
474
+ sendPollWhatsApp: vi.fn(),
475
+ shouldLogVerbose: () => false,
476
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
477
+ normalizeText: (text) => (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""),
478
+ });
479
+
480
+ await outbound.sendPayload!({
481
+ cfg: {} as never,
482
+ to: "whatsapp:+15551234567",
483
+ text: "",
484
+ payload: {
485
+ text: "\n \n indented",
486
+ },
487
+ deps: { sendWhatsApp: sendMessageWhatsApp },
488
+ });
489
+
490
+ expect(sendMessageWhatsApp).toHaveBeenCalledWith(
491
+ "whatsapp:+15551234567",
492
+ " indented",
493
+ expect.objectContaining({
494
+ verbose: false,
495
+ }),
496
+ );
497
+ });
498
+
499
+ it("rejects structured-only payloads instead of reporting an empty successful send", async () => {
500
+ const sendMessageWhatsApp = vi.fn(async () => ({
501
+ messageId: "msg-1",
502
+ toJid: "15551234567@s.whatsapp.net",
503
+ }));
504
+ const outbound = createWhatsAppOutboundBase({
505
+ chunker: (text) => [text],
506
+ sendMessageWhatsApp,
507
+ sendPollWhatsApp: vi.fn(),
508
+ shouldLogVerbose: () => false,
509
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
510
+ });
511
+
512
+ await expect(
513
+ outbound.sendPayload!({
514
+ cfg: {} as never,
515
+ to: "whatsapp:+15551234567",
516
+ text: "",
517
+ payload: {
518
+ channelData: { kind: "structured-only" },
519
+ },
520
+ deps: { sendWhatsApp: sendMessageWhatsApp },
521
+ }),
522
+ ).rejects.toThrow(
523
+ "WhatsApp sendPayload does not support structured-only payloads without text or media.",
524
+ );
525
+ expect(sendMessageWhatsApp).not.toHaveBeenCalled();
526
+ });
527
+
528
+ it("threads cfg into sendPollWhatsApp call", async () => {
529
+ const sendPollWhatsApp = vi.fn(async () => ({
530
+ messageId: "wa-poll-1",
531
+ toJid: "1555@s.whatsapp.net",
532
+ }));
533
+ const outbound = createWhatsAppOutboundBase({
534
+ chunker: (text) => [text],
535
+ sendMessageWhatsApp: vi.fn(),
536
+ sendPollWhatsApp,
537
+ shouldLogVerbose: () => false,
538
+ resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }),
539
+ });
540
+ const { cfg, poll, to, accountId } = createWhatsAppPollFixture();
541
+
542
+ const result = await outbound.sendPoll!({
543
+ cfg,
544
+ to,
545
+ poll,
546
+ accountId,
547
+ });
548
+
549
+ expect(sendPollWhatsApp).toHaveBeenCalledWith(to, poll, {
550
+ verbose: false,
551
+ accountId,
552
+ cfg,
553
+ });
554
+ expect(result).toEqual({
555
+ channel: "whatsapp",
556
+ messageId: "wa-poll-1",
557
+ toJid: "1555@s.whatsapp.net",
558
+ });
559
+ });
560
+ });