@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,7 @@
1
+ export {
2
+ DisconnectReason,
3
+ downloadMediaMessage,
4
+ isJidGroup,
5
+ normalizeMessageContent,
6
+ } from "@whiskeysockets/baileys";
7
+ export { saveMediaBuffer } from "./save-media.runtime.js";
@@ -0,0 +1 @@
1
+ export { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
@@ -0,0 +1,307 @@
1
+ import type {
2
+ AnyMessageContent,
3
+ MiscMessageGenerationOptions,
4
+ WAMessage,
5
+ } from "@whiskeysockets/baileys";
6
+ import { beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { createWebSendApi } from "./send-api.js";
8
+
9
+ const recordChannelActivity = vi.hoisted(() => vi.fn());
10
+
11
+ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => {
12
+ const actual = await vi.importActual<
13
+ typeof import("openclaw/plugin-sdk/channel-activity-runtime")
14
+ >("openclaw/plugin-sdk/channel-activity-runtime");
15
+ return {
16
+ ...actual,
17
+ recordChannelActivity: (...args: unknown[]) => recordChannelActivity(...args),
18
+ };
19
+ });
20
+
21
+ describe("createWebSendApi", () => {
22
+ const sendMessage = vi.fn(
23
+ async (
24
+ _jid: string,
25
+ _content: AnyMessageContent,
26
+ _options?: MiscMessageGenerationOptions,
27
+ ): Promise<WAMessage | undefined> => ({ key: { id: "msg-1" } }) as WAMessage,
28
+ );
29
+ const sendPresenceUpdate = vi.fn(async () => {});
30
+ let api: ReturnType<typeof createWebSendApi>;
31
+
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ api = createWebSendApi({
35
+ sock: { sendMessage, sendPresenceUpdate },
36
+ defaultAccountId: "main",
37
+ });
38
+ });
39
+
40
+ it("uses sendOptions fileName for outbound documents", async () => {
41
+ const payload = Buffer.from("pdf");
42
+ await api.sendMessage("+1555", "doc", payload, "application/pdf", { fileName: "invoice.pdf" });
43
+ expect(sendMessage).toHaveBeenCalledWith(
44
+ "1555@s.whatsapp.net",
45
+ expect.objectContaining({
46
+ document: payload,
47
+ fileName: "invoice.pdf",
48
+ caption: "doc",
49
+ mimetype: "application/pdf",
50
+ }),
51
+ );
52
+ expect(recordChannelActivity).toHaveBeenCalledWith({
53
+ channel: "whatsapp",
54
+ accountId: "main",
55
+ direction: "outbound",
56
+ });
57
+ });
58
+
59
+ it("falls back to default document filename when fileName is absent", async () => {
60
+ const payload = Buffer.from("pdf");
61
+ await api.sendMessage("+1555", "doc", payload, "application/pdf");
62
+ expect(sendMessage).toHaveBeenCalledWith(
63
+ "1555@s.whatsapp.net",
64
+ expect.objectContaining({
65
+ document: payload,
66
+ fileName: "file",
67
+ caption: "doc",
68
+ mimetype: "application/pdf",
69
+ }),
70
+ );
71
+ });
72
+
73
+ it("sends plain text messages", async () => {
74
+ const res = await api.sendMessage("+1555", "hello");
75
+ expect(sendMessage).toHaveBeenCalledWith("1555@s.whatsapp.net", { text: "hello" });
76
+ expect(res).toMatchObject({
77
+ kind: "text",
78
+ messageId: "msg-1",
79
+ messageIds: ["msg-1"],
80
+ providerAccepted: true,
81
+ });
82
+ expect(recordChannelActivity).toHaveBeenCalledWith({
83
+ channel: "whatsapp",
84
+ accountId: "main",
85
+ direction: "outbound",
86
+ });
87
+ });
88
+
89
+ it("supports image media with caption", async () => {
90
+ const payload = Buffer.from("img");
91
+ await api.sendMessage("+1555", "cap", payload, "image/jpeg");
92
+ expect(sendMessage).toHaveBeenCalledWith(
93
+ "1555@s.whatsapp.net",
94
+ expect.objectContaining({
95
+ image: payload,
96
+ caption: "cap",
97
+ mimetype: "image/jpeg",
98
+ }),
99
+ );
100
+ });
101
+
102
+ it("supports audio as push-to-talk voice note", async () => {
103
+ const payload = Buffer.from("aud");
104
+ await api.sendMessage("+1555", "", payload, "audio/ogg", { accountId: "alt" });
105
+ expect(sendMessage).toHaveBeenCalledWith(
106
+ "1555@s.whatsapp.net",
107
+ expect.objectContaining({
108
+ audio: payload,
109
+ ptt: true,
110
+ mimetype: "audio/ogg",
111
+ }),
112
+ );
113
+ expect(recordChannelActivity).toHaveBeenCalledWith({
114
+ channel: "whatsapp",
115
+ accountId: "alt",
116
+ direction: "outbound",
117
+ });
118
+ });
119
+
120
+ it("sends visible text separately from push-to-talk voice notes", async () => {
121
+ const payload = Buffer.from("aud");
122
+ sendMessage
123
+ .mockResolvedValueOnce({ key: { id: "voice-1" } })
124
+ .mockResolvedValueOnce({ key: { id: "voice-text-1" } });
125
+ const res = await api.sendMessage("+1555", "voice text", payload, "audio/ogg");
126
+ expect(sendMessage).toHaveBeenNthCalledWith(
127
+ 1,
128
+ "1555@s.whatsapp.net",
129
+ expect.objectContaining({
130
+ audio: payload,
131
+ ptt: true,
132
+ mimetype: "audio/ogg",
133
+ }),
134
+ );
135
+ expect(sendMessage).toHaveBeenNthCalledWith(2, "1555@s.whatsapp.net", {
136
+ text: "voice text",
137
+ });
138
+ expect(res).toMatchObject({
139
+ kind: "media",
140
+ messageId: "voice-1",
141
+ messageIds: ["voice-1", "voice-text-1"],
142
+ providerAccepted: true,
143
+ });
144
+ });
145
+
146
+ it("supports video media and gifPlayback option", async () => {
147
+ const payload = Buffer.from("vid");
148
+ await api.sendMessage("+1555", "cap", payload, "video/mp4", { gifPlayback: true });
149
+ expect(sendMessage).toHaveBeenCalledWith(
150
+ "1555@s.whatsapp.net",
151
+ expect.objectContaining({
152
+ video: payload,
153
+ caption: "cap",
154
+ mimetype: "video/mp4",
155
+ gifPlayback: true,
156
+ }),
157
+ );
158
+ });
159
+
160
+ it("falls back to unknown messageId if Baileys result does not expose key.id", async () => {
161
+ sendMessage.mockResolvedValueOnce({ key: {} as { id: string } });
162
+ const res = await api.sendMessage("+1555", "hello");
163
+ expect(res.messageId).toBe("unknown");
164
+ });
165
+
166
+ it("sends polls and records outbound activity", async () => {
167
+ const res = await api.sendPoll("+1555", {
168
+ question: "Q?",
169
+ options: ["a", "b"],
170
+ maxSelections: 2,
171
+ });
172
+ expect(sendMessage).toHaveBeenCalledWith(
173
+ "1555@s.whatsapp.net",
174
+ expect.objectContaining({
175
+ poll: { name: "Q?", values: ["a", "b"], selectableCount: 2 },
176
+ }),
177
+ );
178
+ expect(res.messageId).toBe("msg-1");
179
+ expect(recordChannelActivity).toHaveBeenCalledWith({
180
+ channel: "whatsapp",
181
+ accountId: "main",
182
+ direction: "outbound",
183
+ });
184
+ });
185
+
186
+ it("sends reactions with participant JID normalization", async () => {
187
+ const res = await api.sendReaction("+1555", "msg-2", "👍", false, "+1999");
188
+ expect(sendMessage).toHaveBeenCalledWith(
189
+ "1555@s.whatsapp.net",
190
+ expect.objectContaining({
191
+ react: {
192
+ text: "👍",
193
+ key: expect.objectContaining({
194
+ remoteJid: "1555@s.whatsapp.net",
195
+ id: "msg-2",
196
+ fromMe: false,
197
+ participant: "1999@s.whatsapp.net",
198
+ }),
199
+ },
200
+ }),
201
+ );
202
+ expect(res).toMatchObject({
203
+ kind: "reaction",
204
+ messageId: "msg-1",
205
+ providerAccepted: true,
206
+ });
207
+ });
208
+
209
+ it("reports provider-unaccepted sends when Baileys returns no message", async () => {
210
+ sendMessage.mockResolvedValueOnce(undefined);
211
+
212
+ const res = await api.sendMessage("+1555", "hello");
213
+
214
+ expect(res).toMatchObject({
215
+ kind: "text",
216
+ messageId: "unknown",
217
+ messageIds: [],
218
+ providerAccepted: false,
219
+ });
220
+ });
221
+
222
+ it("keeps direct-chat reactions without a participant key", async () => {
223
+ await api.sendReaction("+1555", "msg-2", "👍", false);
224
+ expect(sendMessage).toHaveBeenCalledWith(
225
+ "1555@s.whatsapp.net",
226
+ expect.objectContaining({
227
+ react: {
228
+ text: "👍",
229
+ key: expect.objectContaining({
230
+ remoteJid: "1555@s.whatsapp.net",
231
+ id: "msg-2",
232
+ fromMe: false,
233
+ participant: undefined,
234
+ }),
235
+ },
236
+ }),
237
+ );
238
+ });
239
+
240
+ it("preserves LID participants in reaction keys", async () => {
241
+ await api.sendReaction("12345@g.us", "msg-2", "👍", false, "123@lid");
242
+ expect(sendMessage).toHaveBeenCalledWith(
243
+ "12345@g.us",
244
+ expect.objectContaining({
245
+ react: {
246
+ text: "👍",
247
+ key: expect.objectContaining({
248
+ remoteJid: "12345@g.us",
249
+ id: "msg-2",
250
+ fromMe: false,
251
+ participant: "123@lid",
252
+ }),
253
+ },
254
+ }),
255
+ );
256
+ });
257
+
258
+ it("sends composing presence updates to the recipient JID", async () => {
259
+ await api.sendComposingTo("+1555");
260
+ expect(sendPresenceUpdate).toHaveBeenCalledWith("composing", "1555@s.whatsapp.net");
261
+ });
262
+
263
+ it("sends media as document when mediaType is undefined", async () => {
264
+ const mediaBuffer = Buffer.from("test");
265
+
266
+ await api.sendMessage("123", "hello", mediaBuffer, undefined);
267
+
268
+ expect(sendMessage).toHaveBeenCalledWith(
269
+ "123@s.whatsapp.net",
270
+ expect.objectContaining({
271
+ document: mediaBuffer,
272
+ mimetype: "application/octet-stream",
273
+ }),
274
+ );
275
+ });
276
+
277
+ it("does not set mediaType when mediaBuffer is absent", async () => {
278
+ await api.sendMessage("123", "hello");
279
+
280
+ expect(sendMessage).toHaveBeenCalledWith("123@s.whatsapp.net", { text: "hello" });
281
+ });
282
+
283
+ it("preserves the quoted remoteJid provided by the outbound adapter", async () => {
284
+ await api.sendMessage("+1555", "hello", undefined, undefined, {
285
+ quotedMessageKey: {
286
+ id: "quoted-1",
287
+ remoteJid: "277038292303944@lid",
288
+ fromMe: false,
289
+ participant: "1234@s.whatsapp.net",
290
+ messageText: "quoted body",
291
+ },
292
+ });
293
+
294
+ expect(sendMessage).toHaveBeenCalledWith(
295
+ "1555@s.whatsapp.net",
296
+ { text: "hello" },
297
+ expect.objectContaining({
298
+ quoted: expect.objectContaining({
299
+ key: expect.objectContaining({
300
+ remoteJid: "277038292303944@lid",
301
+ id: "quoted-1",
302
+ }),
303
+ }),
304
+ }),
305
+ );
306
+ });
307
+ });
@@ -0,0 +1,141 @@
1
+ import type {
2
+ AnyMessageContent,
3
+ MiscMessageGenerationOptions,
4
+ WAMessage,
5
+ WAPresence,
6
+ } from "@whiskeysockets/baileys";
7
+ import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
8
+ import { buildQuotedMessageOptions } from "../quoted-message.js";
9
+ import { toWhatsappJid } from "../text-runtime.js";
10
+ import {
11
+ combineWhatsAppSendResults,
12
+ normalizeWhatsAppSendResult,
13
+ type WhatsAppSendResult,
14
+ } from "./send-result.js";
15
+ import type { ActiveWebSendOptions } from "./types.js";
16
+
17
+ function recordWhatsAppOutbound(accountId: string) {
18
+ recordChannelActivity({
19
+ channel: "whatsapp",
20
+ accountId,
21
+ direction: "outbound",
22
+ });
23
+ }
24
+
25
+ export function createWebSendApi(params: {
26
+ sock: {
27
+ sendMessage: (
28
+ jid: string,
29
+ content: AnyMessageContent,
30
+ options?: MiscMessageGenerationOptions,
31
+ ) => Promise<WAMessage | undefined>;
32
+ sendPresenceUpdate: (presence: WAPresence, jid?: string) => Promise<unknown>;
33
+ };
34
+ defaultAccountId: string;
35
+ }) {
36
+ return {
37
+ sendMessage: async (
38
+ to: string,
39
+ text: string,
40
+ mediaBuffer?: Buffer,
41
+ mediaType?: string,
42
+ sendOptions?: ActiveWebSendOptions,
43
+ ): Promise<WhatsAppSendResult> => {
44
+ const jid = toWhatsappJid(to);
45
+ let payload: AnyMessageContent;
46
+ if (mediaBuffer) {
47
+ mediaType ??= "application/octet-stream";
48
+ }
49
+ if (mediaBuffer && mediaType) {
50
+ if (mediaType.startsWith("image/")) {
51
+ payload = {
52
+ image: mediaBuffer,
53
+ caption: text || undefined,
54
+ mimetype: mediaType,
55
+ };
56
+ } else if (mediaType.startsWith("audio/")) {
57
+ payload = { audio: mediaBuffer, ptt: true, mimetype: mediaType };
58
+ } else if (mediaType.startsWith("video/")) {
59
+ const gifPlayback = sendOptions?.gifPlayback;
60
+ payload = {
61
+ video: mediaBuffer,
62
+ caption: text || undefined,
63
+ mimetype: mediaType,
64
+ ...(gifPlayback ? { gifPlayback: true } : {}),
65
+ };
66
+ } else {
67
+ const fileName = sendOptions?.fileName?.trim() || "file";
68
+ payload = {
69
+ document: mediaBuffer,
70
+ fileName,
71
+ caption: text || undefined,
72
+ mimetype: mediaType,
73
+ };
74
+ }
75
+ } else {
76
+ payload = { text };
77
+ }
78
+ const quotedOpts = buildQuotedMessageOptions({
79
+ messageId: sendOptions?.quotedMessageKey?.id,
80
+ remoteJid: sendOptions?.quotedMessageKey?.remoteJid,
81
+ fromMe: sendOptions?.quotedMessageKey?.fromMe,
82
+ participant: sendOptions?.quotedMessageKey?.participant,
83
+ messageText: sendOptions?.quotedMessageKey?.messageText,
84
+ });
85
+ const result = quotedOpts
86
+ ? await params.sock.sendMessage(jid, payload, quotedOpts)
87
+ : await params.sock.sendMessage(jid, payload);
88
+ const results = [normalizeWhatsAppSendResult(result, mediaBuffer ? "media" : "text")];
89
+ if (mediaBuffer && mediaType?.startsWith("audio/") && text.trim()) {
90
+ const textPayload: AnyMessageContent = { text };
91
+ const textResult = quotedOpts
92
+ ? await params.sock.sendMessage(jid, textPayload, quotedOpts)
93
+ : await params.sock.sendMessage(jid, textPayload);
94
+ results.push(normalizeWhatsAppSendResult(textResult, "text"));
95
+ }
96
+ const accountId = sendOptions?.accountId ?? params.defaultAccountId;
97
+ recordWhatsAppOutbound(accountId);
98
+ return combineWhatsAppSendResults(mediaBuffer ? "media" : "text", results);
99
+ },
100
+ sendPoll: async (
101
+ to: string,
102
+ poll: { question: string; options: string[]; maxSelections?: number },
103
+ ): Promise<WhatsAppSendResult> => {
104
+ const jid = toWhatsappJid(to);
105
+ const result = await params.sock.sendMessage(jid, {
106
+ poll: {
107
+ name: poll.question,
108
+ values: poll.options,
109
+ selectableCount: poll.maxSelections ?? 1,
110
+ },
111
+ } as AnyMessageContent);
112
+ recordWhatsAppOutbound(params.defaultAccountId);
113
+ return normalizeWhatsAppSendResult(result, "poll");
114
+ },
115
+ sendReaction: async (
116
+ chatJid: string,
117
+ messageId: string,
118
+ emoji: string,
119
+ fromMe: boolean,
120
+ participant?: string,
121
+ ): Promise<WhatsAppSendResult> => {
122
+ const jid = toWhatsappJid(chatJid);
123
+ const result = await params.sock.sendMessage(jid, {
124
+ react: {
125
+ text: emoji,
126
+ key: {
127
+ remoteJid: jid,
128
+ id: messageId,
129
+ fromMe,
130
+ participant: participant ? toWhatsappJid(participant) : undefined,
131
+ },
132
+ },
133
+ } as AnyMessageContent);
134
+ return normalizeWhatsAppSendResult(result, "reaction");
135
+ },
136
+ sendComposingTo: async (to: string): Promise<void> => {
137
+ const jid = toWhatsappJid(to);
138
+ await params.sock.sendPresenceUpdate("composing", jid);
139
+ },
140
+ } as const;
141
+ }
@@ -0,0 +1,61 @@
1
+ import type { WAMessage, WAMessageKey } from "@whiskeysockets/baileys";
2
+
3
+ export type WhatsAppSendKind = "media" | "poll" | "reaction" | "text";
4
+
5
+ type WhatsAppSendKey = Omit<
6
+ Pick<WAMessageKey, "fromMe" | "id" | "participant" | "remoteJid">,
7
+ "id"
8
+ > & {
9
+ id: string;
10
+ };
11
+
12
+ export type WhatsAppSendResult = {
13
+ kind: WhatsAppSendKind;
14
+ messageId: string;
15
+ messageIds: string[];
16
+ keys: WhatsAppSendKey[];
17
+ providerAccepted: boolean;
18
+ };
19
+
20
+ function normalizeKey(key: WAMessageKey | undefined): WhatsAppSendKey | undefined {
21
+ const id = typeof key?.id === "string" ? key.id.trim() : "";
22
+ if (!id) {
23
+ return undefined;
24
+ }
25
+ return {
26
+ id,
27
+ remoteJid: key?.remoteJid,
28
+ fromMe: key?.fromMe,
29
+ participant: key?.participant,
30
+ };
31
+ }
32
+
33
+ export function normalizeWhatsAppSendResult(
34
+ result: WAMessage | undefined,
35
+ kind: WhatsAppSendKind,
36
+ ): WhatsAppSendResult {
37
+ const key = normalizeKey(result?.key);
38
+ const messageId = key?.id ?? "unknown";
39
+ return {
40
+ kind,
41
+ messageId,
42
+ messageIds: key ? [key.id] : [],
43
+ keys: key ? [key] : [],
44
+ providerAccepted: Boolean(key),
45
+ };
46
+ }
47
+
48
+ export function combineWhatsAppSendResults(
49
+ kind: WhatsAppSendKind,
50
+ results: readonly WhatsAppSendResult[],
51
+ ): WhatsAppSendResult {
52
+ const messageIds = [...new Set(results.flatMap((result) => result.messageIds))];
53
+ const keys = results.flatMap((result) => result.keys);
54
+ return {
55
+ kind,
56
+ messageId: messageIds[0] ?? "unknown",
57
+ messageIds,
58
+ keys,
59
+ providerAccepted: results.some((result) => result.providerAccepted),
60
+ };
61
+ }
@@ -0,0 +1,106 @@
1
+ import type { AnyMessageContent, MiscMessageGenerationOptions } from "@whiskeysockets/baileys";
2
+ import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
3
+ import type { PollInput } from "openclaw/plugin-sdk/poll-runtime";
4
+ import type { WhatsAppIdentity, WhatsAppReplyContext, WhatsAppSelfIdentity } from "../identity.js";
5
+ import type { WhatsAppSendResult } from "./send-result.js";
6
+
7
+ export type WebListenerCloseReason = {
8
+ status?: number;
9
+ isLoggedOut: boolean;
10
+ error?: unknown;
11
+ };
12
+
13
+ export type ActiveWebSendOptions = {
14
+ quotedMessageKey?: {
15
+ id: string;
16
+ remoteJid: string;
17
+ fromMe: boolean;
18
+ participant?: string;
19
+ messageText?: string;
20
+ };
21
+ gifPlayback?: boolean;
22
+ accountId?: string;
23
+ fileName?: string;
24
+ };
25
+
26
+ export type ActiveWebListener = {
27
+ sendMessage: (
28
+ to: string,
29
+ text: string,
30
+ mediaBuffer?: Buffer,
31
+ mediaType?: string,
32
+ options?: ActiveWebSendOptions,
33
+ ) => Promise<WhatsAppSendResult>;
34
+ sendPoll: (to: string, poll: PollInput) => Promise<WhatsAppSendResult>;
35
+ sendReaction: (
36
+ chatJid: string,
37
+ messageId: string,
38
+ emoji: string,
39
+ fromMe: boolean,
40
+ participant?: string,
41
+ ) => Promise<WhatsAppSendResult>;
42
+ sendComposingTo: (to: string) => Promise<void>;
43
+ close?: () => Promise<void>;
44
+ };
45
+
46
+ export type WhatsAppStructuredContactContext = {
47
+ kind: "contact" | "contacts";
48
+ total: number;
49
+ contacts: Array<{
50
+ name?: string;
51
+ phones?: string[];
52
+ }>;
53
+ };
54
+
55
+ export type WebInboundMessage = {
56
+ id?: string;
57
+ from: string; // conversation id: E.164 for direct chats, group JID for groups
58
+ conversationId: string; // alias for clarity (same as from)
59
+ to: string;
60
+ accountId: string;
61
+ /** Set by the real inbound monitor after access-control / pairing checks pass. */
62
+ accessControlPassed?: boolean;
63
+ body: string;
64
+ pushName?: string;
65
+ timestamp?: number;
66
+ chatType: "direct" | "group";
67
+ chatId: string;
68
+ sender?: WhatsAppIdentity;
69
+ senderJid?: string;
70
+ senderE164?: string;
71
+ senderName?: string;
72
+ replyTo?: WhatsAppReplyContext;
73
+ replyToId?: string;
74
+ replyToBody?: string;
75
+ replyToSender?: string;
76
+ replyToSenderJid?: string;
77
+ replyToSenderE164?: string;
78
+ groupSubject?: string;
79
+ groupParticipants?: string[];
80
+ mentions?: string[];
81
+ mentionedJids?: string[];
82
+ self?: WhatsAppSelfIdentity;
83
+ selfJid?: string | null;
84
+ selfLid?: string | null;
85
+ selfE164?: string | null;
86
+ fromMe?: boolean;
87
+ location?: NormalizedLocation;
88
+ sendComposing: () => Promise<void>;
89
+ reply: (text: string, options?: MiscMessageGenerationOptions) => Promise<WhatsAppSendResult>;
90
+ sendMedia: (
91
+ payload: AnyMessageContent,
92
+ options?: MiscMessageGenerationOptions,
93
+ ) => Promise<WhatsAppSendResult>;
94
+ mediaPath?: string;
95
+ mediaType?: string;
96
+ mediaFileName?: string;
97
+ mediaUrl?: string;
98
+ untrustedStructuredContext?: Array<{
99
+ label: string;
100
+ source?: string;
101
+ type?: string;
102
+ payload: unknown;
103
+ }>;
104
+ wasMentioned?: boolean;
105
+ isBatched?: boolean;
106
+ };
@@ -0,0 +1,32 @@
1
+ import { expectChannelInboundContextContract } from "openclaw/plugin-sdk/channel-contract-testing";
2
+ import { describe, it } from "vitest";
3
+
4
+ describe("WhatsApp inbound context contract", () => {
5
+ it("keeps inbound context finalized", () => {
6
+ const ctx = {
7
+ Body: "Alice: hi",
8
+ BodyForAgent: "hi",
9
+ RawBody: "hi",
10
+ CommandBody: "hi",
11
+ BodyForCommands: "hi",
12
+ From: "123@g.us",
13
+ To: "+15550001111",
14
+ SessionKey: "agent:main:whatsapp:group:123",
15
+ AccountId: "default",
16
+ ChatType: "group",
17
+ ConversationLabel: "123@g.us",
18
+ GroupSubject: "Test Group",
19
+ SenderName: "Alice",
20
+ SenderId: "alice@s.whatsapp.net",
21
+ SenderE164: "+15550002222",
22
+ Provider: "whatsapp",
23
+ Surface: "whatsapp",
24
+ MessageSid: "msg1",
25
+ OriginatingChannel: "whatsapp",
26
+ OriginatingTo: "123@g.us",
27
+ CommandAuthorized: true,
28
+ };
29
+
30
+ expectChannelInboundContextContract(ctx);
31
+ });
32
+ });