@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,279 @@
1
+ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-types";
2
+ import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
3
+ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-chunking";
4
+ import {
5
+ isReasoningReplyPayload,
6
+ sendMediaWithLeadingCaption,
7
+ } from "openclaw/plugin-sdk/reply-payload";
8
+ import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
9
+ import type { WhatsAppSendResult } from "../inbound/send-result.js";
10
+ import { loadWebMedia } from "../media.js";
11
+ import {
12
+ type DeliverableWhatsAppOutboundPayload,
13
+ normalizeWhatsAppOutboundPayload,
14
+ normalizeWhatsAppPayloadTextPreservingIndentation,
15
+ prepareWhatsAppOutboundMedia,
16
+ sendWhatsAppOutboundWithRetry,
17
+ } from "../outbound-media-contract.js";
18
+ import { buildQuotedMessageOptions, lookupInboundMessageMeta } from "../quoted-message.js";
19
+ import { newConnectionId } from "../reconnect.js";
20
+ import { formatError } from "../session.js";
21
+ import { convertMarkdownTables } from "../text-runtime.js";
22
+ import { markdownToWhatsApp } from "../text-runtime.js";
23
+ import { whatsappOutboundLog } from "./loggers.js";
24
+ import type { WebInboundMsg } from "./types.js";
25
+ import { elide } from "./util.js";
26
+
27
+ export type WhatsAppReplyDeliveryResult = {
28
+ results: WhatsAppSendResult[];
29
+ messageIds: string[];
30
+ providerAccepted: boolean;
31
+ };
32
+
33
+ export async function deliverWebReply(params: {
34
+ replyResult: ReplyPayload;
35
+ normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
36
+ msg: WebInboundMsg;
37
+ mediaLocalRoots?: readonly string[];
38
+ maxMediaBytes: number;
39
+ textLimit: number;
40
+ chunkMode?: ChunkMode;
41
+ replyLogger: {
42
+ info: (obj: unknown, msg: string) => void;
43
+ warn: (obj: unknown, msg: string) => void;
44
+ };
45
+ connectionId?: string;
46
+ skipLog?: boolean;
47
+ tableMode?: MarkdownTableMode;
48
+ }): Promise<WhatsAppReplyDeliveryResult> {
49
+ const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
50
+ const replyStarted = Date.now();
51
+ const sendResults: WhatsAppSendResult[] = [];
52
+ const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
53
+ if (result) {
54
+ sendResults.push(result);
55
+ }
56
+ };
57
+ const finishDelivery = (): WhatsAppReplyDeliveryResult => {
58
+ const messageIds = [...new Set(sendResults.flatMap((result) => result.messageIds))];
59
+ return {
60
+ results: sendResults,
61
+ messageIds,
62
+ providerAccepted: sendResults.some((result) => result.providerAccepted),
63
+ };
64
+ };
65
+ if (isReasoningReplyPayload(replyResult)) {
66
+ whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
67
+ return finishDelivery();
68
+ }
69
+ const tableMode = params.tableMode ?? "code";
70
+ const chunkMode = params.chunkMode ?? "length";
71
+ const normalizedReply =
72
+ params.normalizedReplyResult ??
73
+ normalizeWhatsAppOutboundPayload(replyResult, {
74
+ normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
75
+ });
76
+ const convertedText = markdownToWhatsApp(
77
+ convertMarkdownTables(normalizedReply.text ?? "", tableMode),
78
+ );
79
+ const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
80
+ const mediaList = normalizedReply.mediaUrls ?? [];
81
+
82
+ const getQuote = () => {
83
+ if (!replyResult.replyToId) {
84
+ return undefined;
85
+ }
86
+ // Use replyToId (not msg.id) so batched payloads quote the correct
87
+ // per-message target. Look up cached metadata for the specific
88
+ // message being quoted — msg.body may be a combined batch body.
89
+ const cached = lookupInboundMessageMeta(msg.accountId, msg.chatId, replyResult.replyToId);
90
+ return buildQuotedMessageOptions({
91
+ messageId: replyResult.replyToId,
92
+ remoteJid: msg.chatId,
93
+ fromMe: cached?.fromMe ?? false,
94
+ participant: cached?.participant ?? (msg.chatType === "group" ? msg.senderJid : undefined),
95
+ messageText: cached?.body ?? "",
96
+ });
97
+ };
98
+
99
+ const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
100
+ return await sendWhatsAppOutboundWithRetry({
101
+ send: fn,
102
+ maxAttempts,
103
+ onRetry: ({ attempt, maxAttempts: retryMaxAttempts, backoffMs, errorText }) => {
104
+ logVerbose(
105
+ `Retrying ${label} to ${msg.from} after failure (${attempt}/${retryMaxAttempts - 1}) in ${backoffMs}ms: ${errorText}`,
106
+ );
107
+ },
108
+ });
109
+ };
110
+
111
+ // Text-only replies
112
+ if (mediaList.length === 0 && textChunks.length) {
113
+ const totalChunks = textChunks.length;
114
+ for (const [index, chunk] of textChunks.entries()) {
115
+ const chunkStarted = Date.now();
116
+ const quote = getQuote();
117
+ rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text"));
118
+ if (!skipLog) {
119
+ const durationMs = Date.now() - chunkStarted;
120
+ whatsappOutboundLog.debug(
121
+ `Sent chunk ${index + 1}/${totalChunks} to ${msg.from} (${durationMs.toFixed(0)}ms)`,
122
+ );
123
+ }
124
+ }
125
+ const delivery = finishDelivery();
126
+ const logPayload = {
127
+ correlationId: msg.id ?? newConnectionId(),
128
+ connectionId: connectionId ?? null,
129
+ to: msg.from,
130
+ from: msg.to,
131
+ text: elide(replyResult.text, 240),
132
+ mediaUrl: null,
133
+ mediaSizeBytes: null,
134
+ mediaKind: null,
135
+ durationMs: Date.now() - replyStarted,
136
+ };
137
+ if (delivery.providerAccepted) {
138
+ replyLogger.info(logPayload, "auto-reply sent (text)");
139
+ } else {
140
+ replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
141
+ }
142
+ return delivery;
143
+ }
144
+
145
+ const remainingText = [...textChunks];
146
+
147
+ // Media (with optional caption on first item)
148
+ const leadingCaption = remainingText.shift() || "";
149
+ await sendMediaWithLeadingCaption({
150
+ mediaUrls: mediaList,
151
+ caption: leadingCaption,
152
+ send: async ({ mediaUrl, caption }) => {
153
+ const media = await prepareWhatsAppOutboundMedia(
154
+ await loadWebMedia(mediaUrl, {
155
+ maxBytes: maxMediaBytes,
156
+ localRoots: params.mediaLocalRoots,
157
+ }),
158
+ mediaUrl,
159
+ );
160
+ if (shouldLogVerbose()) {
161
+ logVerbose(
162
+ `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
163
+ );
164
+ logVerbose(`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`);
165
+ }
166
+ if (media.kind === "image") {
167
+ const quote = getQuote();
168
+ rememberSendResult(
169
+ await sendWithRetry(
170
+ () =>
171
+ msg.sendMedia(
172
+ {
173
+ image: media.buffer,
174
+ caption,
175
+ mimetype: media.mimetype,
176
+ },
177
+ quote,
178
+ ),
179
+ "media:image",
180
+ ),
181
+ );
182
+ } else if (media.kind === "audio") {
183
+ const quote = getQuote();
184
+ rememberSendResult(
185
+ await sendWithRetry(
186
+ () =>
187
+ msg.sendMedia(
188
+ {
189
+ audio: media.buffer,
190
+ ptt: true,
191
+ mimetype: media.mimetype,
192
+ },
193
+ quote,
194
+ ),
195
+ "media:audio",
196
+ ),
197
+ );
198
+ if (caption) {
199
+ rememberSendResult(
200
+ await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"),
201
+ );
202
+ }
203
+ } else if (media.kind === "video") {
204
+ const quote = getQuote();
205
+ rememberSendResult(
206
+ await sendWithRetry(
207
+ () =>
208
+ msg.sendMedia(
209
+ {
210
+ video: media.buffer,
211
+ caption,
212
+ mimetype: media.mimetype,
213
+ },
214
+ quote,
215
+ ),
216
+ "media:video",
217
+ ),
218
+ );
219
+ } else {
220
+ const quote = getQuote();
221
+ rememberSendResult(
222
+ await sendWithRetry(
223
+ () =>
224
+ msg.sendMedia(
225
+ {
226
+ document: media.buffer,
227
+ fileName: media.fileName,
228
+ caption,
229
+ mimetype: media.mimetype,
230
+ },
231
+ quote,
232
+ ),
233
+ "media:document",
234
+ ),
235
+ );
236
+ }
237
+ whatsappOutboundLog.info(
238
+ `Sent media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
239
+ );
240
+ replyLogger.info(
241
+ {
242
+ correlationId: msg.id ?? newConnectionId(),
243
+ connectionId: connectionId ?? null,
244
+ to: msg.from,
245
+ from: msg.to,
246
+ text: caption ?? null,
247
+ mediaUrl,
248
+ mediaSizeBytes: media.buffer.length,
249
+ mediaKind: media.kind,
250
+ durationMs: Date.now() - replyStarted,
251
+ },
252
+ "auto-reply sent (media)",
253
+ );
254
+ },
255
+ onError: async ({ error, mediaUrl, caption, isFirst }) => {
256
+ whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`);
257
+ replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
258
+ if (!isFirst) {
259
+ return;
260
+ }
261
+ const warning = "⚠️ Media failed.";
262
+ const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean);
263
+ const fallbackText = fallbackTextParts.join("\n");
264
+ if (!fallbackText) {
265
+ return;
266
+ }
267
+ whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
268
+ rememberSendResult(
269
+ await sendWithRetry(() => msg.reply(fallbackText, getQuote()), "media:fallback-text"),
270
+ );
271
+ },
272
+ });
273
+
274
+ // Remaining text chunks after media
275
+ for (const chunk of remainingText) {
276
+ rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text"));
277
+ }
278
+ return finishDelivery();
279
+ }
@@ -0,0 +1,33 @@
1
+ export { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime";
2
+ export {
3
+ canonicalizeMainSessionAlias,
4
+ loadSessionStore,
5
+ resolveSessionKey,
6
+ resolveStorePath,
7
+ updateSessionStore,
8
+ } from "openclaw/plugin-sdk/session-store-runtime";
9
+ export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
10
+ export {
11
+ emitHeartbeatEvent,
12
+ resolveHeartbeatVisibility,
13
+ resolveIndicatorType,
14
+ } from "openclaw/plugin-sdk/heartbeat-runtime";
15
+ export {
16
+ hasOutboundReplyContent,
17
+ resolveSendableOutboundReplyParts,
18
+ } from "openclaw/plugin-sdk/reply-payload";
19
+ export {
20
+ DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
21
+ HEARTBEAT_TOKEN,
22
+ getReplyFromConfig,
23
+ resolveHeartbeatPrompt,
24
+ resolveHeartbeatReplyPayload,
25
+ stripHeartbeatToken,
26
+ } from "openclaw/plugin-sdk/reply-runtime";
27
+ export { normalizeMainKey } from "openclaw/plugin-sdk/routing";
28
+ export { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
29
+ export { redactIdentifier } from "openclaw/plugin-sdk/text-runtime";
30
+ export { resolveWhatsAppHeartbeatRecipients } from "../runtime-api.js";
31
+ export { sendMessageWhatsApp } from "../send.js";
32
+ export { formatError } from "../session.js";
33
+ export { whatsappHeartbeatLog } from "./loggers.js";
@@ -0,0 +1,214 @@
1
+ import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { sendMessageWhatsApp } from "../send.js";
4
+ import type { getReplyFromConfig } from "./heartbeat-runner.runtime.js";
5
+
6
+ const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
7
+
8
+ const state = vi.hoisted(() => ({
9
+ visibility: { showAlerts: true, showOk: true, useIndicator: false },
10
+ store: {} as Record<string, { updatedAt?: number; sessionId?: string }>,
11
+ snapshot: {
12
+ key: "k",
13
+ entry: { sessionId: "s1", updatedAt: 123 },
14
+ fresh: false,
15
+ resetPolicy: { mode: "none", atHour: null, idleMinutes: null },
16
+ dailyResetAt: null as number | null,
17
+ idleExpiresAt: null as number | null,
18
+ },
19
+ events: [] as unknown[],
20
+ loggerInfoCalls: [] as unknown[][],
21
+ loggerWarnCalls: [] as unknown[][],
22
+ heartbeatInfoLogs: [] as string[],
23
+ heartbeatWarnLogs: [] as string[],
24
+ }));
25
+
26
+ vi.mock("./heartbeat-runner.runtime.js", () => {
27
+ const logger = {
28
+ child: () => logger,
29
+ info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
30
+ warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
31
+ error: vi.fn(),
32
+ debug: vi.fn(),
33
+ };
34
+ return {
35
+ DEFAULT_HEARTBEAT_ACK_MAX_CHARS: 32,
36
+ HEARTBEAT_TOKEN,
37
+ appendCronStyleCurrentTimeLine: (body: string) =>
38
+ `${body}\nCurrent time: 2026-02-15T00:00:00Z (mock)`,
39
+ canonicalizeMainSessionAlias: ({ sessionKey }: { sessionKey: string }) => sessionKey,
40
+ emitHeartbeatEvent: (event: unknown) => state.events.push(event),
41
+ formatError: (err: unknown) => `ERR:${String(err)}`,
42
+ getChildLogger: () => logger,
43
+ getReplyFromConfig: vi.fn(async () => undefined),
44
+ hasOutboundReplyContent: (payload: { text?: string } | undefined) =>
45
+ Boolean(payload?.text?.trim()),
46
+ loadConfig: () => ({ agents: { defaults: {} }, session: {} }),
47
+ loadSessionStore: () => state.store,
48
+ normalizeMainKey: () => null,
49
+ redactIdentifier,
50
+ resolveHeartbeatPrompt: (prompt?: string) => prompt || "Heartbeat",
51
+ resolveHeartbeatReplyPayload: (reply: unknown) => reply,
52
+ resolveHeartbeatVisibility: () => state.visibility,
53
+ resolveIndicatorType: (status: string) => `indicator:${status}`,
54
+ resolveSendableOutboundReplyParts: (payload: { text?: string }) => ({
55
+ text: payload.text ?? "",
56
+ hasMedia: false,
57
+ }),
58
+ resolveSessionKey: () => "k",
59
+ resolveStorePath: () => "/tmp/store.json",
60
+ resolveWhatsAppHeartbeatRecipients: () => [],
61
+ sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
62
+ stripHeartbeatToken: (text: string) => {
63
+ const trimmed = text.trim();
64
+ if (trimmed === HEARTBEAT_TOKEN) {
65
+ return { shouldSkip: true, text: "" };
66
+ }
67
+ return { shouldSkip: false, text: trimmed };
68
+ },
69
+ updateSessionStore: async (_path: string, updater: (store: typeof state.store) => void) => {
70
+ updater(state.store);
71
+ },
72
+ whatsappHeartbeatLog: {
73
+ info: (msg: string) => state.heartbeatInfoLogs.push(msg),
74
+ warn: (msg: string) => state.heartbeatWarnLogs.push(msg),
75
+ },
76
+ };
77
+ });
78
+
79
+ vi.mock("./session-snapshot.js", () => ({
80
+ getSessionSnapshot: () => state.snapshot,
81
+ }));
82
+
83
+ vi.mock("../reconnect.js", () => ({
84
+ newConnectionId: () => "run-1",
85
+ }));
86
+
87
+ describe("runWebHeartbeatOnce", () => {
88
+ let senderMock: ReturnType<typeof vi.fn>;
89
+ let sender: typeof sendMessageWhatsApp;
90
+ let replyResolverMock: ReturnType<typeof vi.fn>;
91
+ let replyResolver: typeof getReplyFromConfig;
92
+ let runWebHeartbeatOnce: typeof import("./heartbeat-runner.js").runWebHeartbeatOnce;
93
+
94
+ const buildRunArgs = (overrides: Record<string, unknown> = {}) => ({
95
+ cfg: { agents: { defaults: {} }, session: {} } as never,
96
+ to: "+123",
97
+ sender,
98
+ replyResolver,
99
+ ...overrides,
100
+ });
101
+
102
+ beforeAll(async () => {
103
+ ({ runWebHeartbeatOnce } = await import("./heartbeat-runner.js"));
104
+ });
105
+
106
+ beforeEach(() => {
107
+ state.visibility = { showAlerts: true, showOk: true, useIndicator: false };
108
+ state.store = { k: { updatedAt: 999, sessionId: "s1" } };
109
+ state.snapshot = {
110
+ key: "k",
111
+ entry: { sessionId: "s1", updatedAt: 123 },
112
+ fresh: false,
113
+ resetPolicy: { mode: "none", atHour: null, idleMinutes: null },
114
+ dailyResetAt: null,
115
+ idleExpiresAt: null,
116
+ };
117
+ state.events = [];
118
+ state.loggerInfoCalls = [];
119
+ state.loggerWarnCalls = [];
120
+ state.heartbeatInfoLogs = [];
121
+ state.heartbeatWarnLogs = [];
122
+
123
+ senderMock = vi.fn(async () => ({ messageId: "m1" }));
124
+ sender = senderMock as unknown as typeof sendMessageWhatsApp;
125
+ replyResolverMock = vi.fn(async () => undefined);
126
+ replyResolver = replyResolverMock as unknown as typeof getReplyFromConfig;
127
+ });
128
+
129
+ it("supports manual override body dry-run without sending", async () => {
130
+ await runWebHeartbeatOnce(buildRunArgs({ overrideBody: "hello", dryRun: true }));
131
+ expect(senderMock).not.toHaveBeenCalled();
132
+ expect(state.events).toHaveLength(0);
133
+ });
134
+
135
+ it("sends HEARTBEAT_OK when reply is empty and showOk is enabled", async () => {
136
+ await runWebHeartbeatOnce(buildRunArgs());
137
+ expect(senderMock).toHaveBeenCalledWith(
138
+ "+123",
139
+ HEARTBEAT_TOKEN,
140
+ expect.objectContaining({ verbose: false, cfg: expect.any(Object) }),
141
+ );
142
+ expect(state.events).toEqual(
143
+ expect.arrayContaining([expect.objectContaining({ status: "ok-empty", silent: false })]),
144
+ );
145
+ });
146
+
147
+ it("injects a cron-style Current time line into the heartbeat prompt", async () => {
148
+ await runWebHeartbeatOnce(
149
+ buildRunArgs({
150
+ cfg: { agents: { defaults: { heartbeat: { prompt: "Ops check" } } }, session: {} } as never,
151
+ dryRun: true,
152
+ }),
153
+ );
154
+ expect(replyResolver).toHaveBeenCalledTimes(1);
155
+ const ctx = replyResolverMock.mock.calls[0]?.[0];
156
+ expect(ctx?.Body).toContain("Ops check");
157
+ expect(ctx?.Body).toContain("Current time: 2026-02-15T00:00:00Z (mock)");
158
+ });
159
+
160
+ it("treats heartbeat token-only replies as ok-token and preserves session updatedAt", async () => {
161
+ replyResolverMock.mockResolvedValue({ text: HEARTBEAT_TOKEN });
162
+ await runWebHeartbeatOnce(buildRunArgs());
163
+ expect(state.store.k?.updatedAt).toBe(123);
164
+ expect(senderMock).toHaveBeenCalledWith(
165
+ "+123",
166
+ HEARTBEAT_TOKEN,
167
+ expect.objectContaining({ verbose: false, cfg: expect.any(Object) }),
168
+ );
169
+ expect(state.events).toEqual(
170
+ expect.arrayContaining([expect.objectContaining({ status: "ok-token", silent: false })]),
171
+ );
172
+ });
173
+
174
+ it("skips sending alerts when showAlerts is disabled but still emits a skipped event", async () => {
175
+ state.visibility = { showAlerts: false, showOk: true, useIndicator: true };
176
+ replyResolverMock.mockResolvedValue({ text: "ALERT" });
177
+ await runWebHeartbeatOnce(buildRunArgs());
178
+ expect(senderMock).not.toHaveBeenCalled();
179
+ expect(state.events).toEqual(
180
+ expect.arrayContaining([
181
+ expect.objectContaining({ status: "skipped", reason: "alerts-disabled", preview: "ALERT" }),
182
+ ]),
183
+ );
184
+ });
185
+
186
+ it("emits failed events when sending throws and rethrows the error", async () => {
187
+ replyResolverMock.mockResolvedValue({ text: "ALERT" });
188
+ senderMock.mockRejectedValueOnce(new Error("nope"));
189
+ await expect(runWebHeartbeatOnce(buildRunArgs())).rejects.toThrow("nope");
190
+ expect(state.events).toEqual(
191
+ expect.arrayContaining([
192
+ expect.objectContaining({ status: "failed", reason: "ERR:Error: nope" }),
193
+ ]),
194
+ );
195
+ });
196
+
197
+ it("redacts recipient and omits body preview in heartbeat logs", async () => {
198
+ replyResolverMock.mockResolvedValue({ text: "sensitive heartbeat body" });
199
+ await runWebHeartbeatOnce(buildRunArgs({ dryRun: true }));
200
+
201
+ const expected = redactIdentifier("+123");
202
+ const heartbeatLogs = state.heartbeatInfoLogs.join("\n");
203
+ const childLoggerLogs = state.loggerInfoCalls.map((entry) => JSON.stringify(entry)).join("\n");
204
+
205
+ expect(heartbeatLogs).toContain(expected);
206
+ expect(heartbeatLogs).not.toContain("+123");
207
+ expect(heartbeatLogs).not.toContain("sensitive heartbeat body");
208
+
209
+ expect(childLoggerLogs).toContain(expected);
210
+ expect(childLoggerLogs).not.toContain("+123");
211
+ expect(childLoggerLogs).not.toContain("sensitive heartbeat body");
212
+ expect(childLoggerLogs).not.toContain('"preview"');
213
+ });
214
+ });