@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,99 @@
1
+ import {
2
+ createAckReactionHandle,
3
+ shouldAckReactionForWhatsApp,
4
+ type AckReactionHandle,
5
+ } from "openclaw/plugin-sdk/channel-feedback";
6
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
7
+ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
8
+ import { getSenderIdentity } from "../../identity.js";
9
+ import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
10
+ import { sendReactionWhatsApp } from "../../send.js";
11
+ import { formatError } from "../../session.js";
12
+ import type { WebInboundMsg } from "../types.js";
13
+ import { resolveGroupActivationFor } from "./group-activation.js";
14
+
15
+ export async function maybeSendAckReaction(params: {
16
+ cfg: OpenClawConfig;
17
+ msg: WebInboundMsg;
18
+ agentId: string;
19
+ sessionKey: string;
20
+ conversationId: string;
21
+ verbose: boolean;
22
+ accountId?: string;
23
+ info: (obj: unknown, msg: string) => void;
24
+ warn: (obj: unknown, msg: string) => void;
25
+ }): Promise<AckReactionHandle | null> {
26
+ if (!params.msg.id) {
27
+ return null;
28
+ }
29
+
30
+ // Keep ackReaction as the emoji/scope control, while letting reactionLevel
31
+ // suppress all automatic reactions when it is explicitly set to "off".
32
+ const reactionLevel = resolveWhatsAppReactionLevel({
33
+ cfg: params.cfg,
34
+ accountId: params.accountId,
35
+ });
36
+ if (reactionLevel.level === "off") {
37
+ return null;
38
+ }
39
+
40
+ const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
41
+ const emoji = (ackConfig?.emoji ?? "").trim();
42
+ const directEnabled = ackConfig?.direct ?? true;
43
+ const groupMode = ackConfig?.group ?? "mentions";
44
+ const conversationIdForCheck = params.msg.conversationId ?? params.msg.from;
45
+
46
+ const activation =
47
+ params.msg.chatType === "group"
48
+ ? await resolveGroupActivationFor({
49
+ cfg: params.cfg,
50
+ accountId: params.accountId,
51
+ agentId: params.agentId,
52
+ sessionKey: params.sessionKey,
53
+ conversationId: conversationIdForCheck,
54
+ })
55
+ : null;
56
+ const shouldSendReaction = () =>
57
+ shouldAckReactionForWhatsApp({
58
+ emoji,
59
+ isDirect: params.msg.chatType === "direct",
60
+ isGroup: params.msg.chatType === "group",
61
+ directEnabled,
62
+ groupMode,
63
+ wasMentioned: params.msg.wasMentioned === true,
64
+ groupActivated: activation === "always",
65
+ });
66
+
67
+ if (!shouldSendReaction()) {
68
+ return null;
69
+ }
70
+
71
+ params.info(
72
+ { chatId: params.msg.chatId, messageId: params.msg.id, emoji },
73
+ "sending ack reaction",
74
+ );
75
+ const sender = getSenderIdentity(params.msg);
76
+ const reactionOptions = {
77
+ verbose: params.verbose,
78
+ fromMe: false,
79
+ ...(sender.jid ? { participant: sender.jid } : {}),
80
+ ...(params.accountId ? { accountId: params.accountId } : {}),
81
+ cfg: params.cfg,
82
+ };
83
+ return createAckReactionHandle({
84
+ ackReactionValue: emoji,
85
+ send: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, emoji, reactionOptions),
86
+ remove: () => sendReactionWhatsApp(params.msg.chatId, params.msg.id!, "", reactionOptions),
87
+ onSendError: (err) => {
88
+ params.warn(
89
+ {
90
+ error: formatError(err),
91
+ chatId: params.msg.chatId,
92
+ messageId: params.msg.id,
93
+ },
94
+ "failed to send ack reaction",
95
+ );
96
+ logVerbose(`WhatsApp ack reaction failed for chat ${params.msg.chatId}: ${formatError(err)}`);
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,9 @@
1
+ import { transcribeFirstAudio as transcribeFirstAudioImpl } from "openclaw/plugin-sdk/media-runtime";
2
+
3
+ type TranscribeFirstAudio = typeof import("openclaw/plugin-sdk/media-runtime").transcribeFirstAudio;
4
+
5
+ export async function transcribeFirstAudio(
6
+ ...args: Parameters<TranscribeFirstAudio>
7
+ ): ReturnType<TranscribeFirstAudio> {
8
+ return await transcribeFirstAudioImpl(...args);
9
+ }
@@ -0,0 +1,153 @@
1
+ import type { AckReactionHandle } from "openclaw/plugin-sdk/channel-feedback";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
3
+ import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
4
+ import { buildAgentSessionKey, deriveLastRoutePolicy } from "openclaw/plugin-sdk/routing";
5
+ import {
6
+ buildAgentMainSessionKey,
7
+ DEFAULT_MAIN_KEY,
8
+ normalizeAgentId,
9
+ } from "openclaw/plugin-sdk/routing";
10
+ import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
11
+ import { formatError } from "../../session.js";
12
+ import { whatsappInboundLog } from "../loggers.js";
13
+ import type { WebInboundMsg } from "../types.js";
14
+ import type { GroupHistoryEntry } from "./inbound-context.js";
15
+
16
+ function buildBroadcastRouteKeys(params: {
17
+ cfg: OpenClawConfig;
18
+ msg: WebInboundMsg;
19
+ route: ReturnType<typeof resolveAgentRoute>;
20
+ peerId: string;
21
+ agentId: string;
22
+ }) {
23
+ const sessionKey = buildAgentSessionKey({
24
+ agentId: params.agentId,
25
+ channel: "whatsapp",
26
+ accountId: params.route.accountId,
27
+ peer: {
28
+ kind: params.msg.chatType === "group" ? "group" : "direct",
29
+ id: params.peerId,
30
+ },
31
+ dmScope: params.cfg.session?.dmScope,
32
+ identityLinks: params.cfg.session?.identityLinks,
33
+ });
34
+ const mainSessionKey = buildAgentMainSessionKey({
35
+ agentId: params.agentId,
36
+ mainKey: DEFAULT_MAIN_KEY,
37
+ });
38
+
39
+ return {
40
+ sessionKey,
41
+ mainSessionKey,
42
+ lastRoutePolicy: deriveLastRoutePolicy({
43
+ sessionKey,
44
+ mainSessionKey,
45
+ }),
46
+ };
47
+ }
48
+
49
+ export async function maybeBroadcastMessage(params: {
50
+ cfg: OpenClawConfig;
51
+ msg: WebInboundMsg;
52
+ peerId: string;
53
+ route: ReturnType<typeof resolveAgentRoute>;
54
+ groupHistoryKey: string;
55
+ groupHistories: Map<string, GroupHistoryEntry[]>;
56
+ processMessage: (
57
+ msg: WebInboundMsg,
58
+ route: ReturnType<typeof resolveAgentRoute>,
59
+ groupHistoryKey: string,
60
+ opts?: {
61
+ groupHistory?: GroupHistoryEntry[];
62
+ suppressGroupHistoryClear?: boolean;
63
+ preflightAudioTranscript?: string | null;
64
+ ackAlreadySent?: boolean;
65
+ ackReaction?: AckReactionHandle | null;
66
+ },
67
+ ) => Promise<boolean>;
68
+ preflightAudioTranscript?: string | null;
69
+ ackAlreadySent?: boolean;
70
+ ackReaction?: AckReactionHandle | null;
71
+ }) {
72
+ const broadcastAgents = params.cfg.broadcast?.[params.peerId];
73
+ if (!broadcastAgents || !Array.isArray(broadcastAgents)) {
74
+ return false;
75
+ }
76
+ if (broadcastAgents.length === 0) {
77
+ return false;
78
+ }
79
+
80
+ const strategy = params.cfg.broadcast?.strategy || "parallel";
81
+ whatsappInboundLog.info(`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`);
82
+
83
+ const agentIds = params.cfg.agents?.list?.map((agent) => normalizeAgentId(agent.id));
84
+ const hasKnownAgents = (agentIds?.length ?? 0) > 0;
85
+ const groupHistorySnapshot =
86
+ params.msg.chatType === "group"
87
+ ? (params.groupHistories.get(params.groupHistoryKey) ?? [])
88
+ : undefined;
89
+
90
+ const processForAgent = async (agentId: string): Promise<boolean> => {
91
+ const normalizedAgentId = normalizeAgentId(agentId);
92
+ if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
93
+ whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`);
94
+ return false;
95
+ }
96
+ const routeKeys = buildBroadcastRouteKeys({
97
+ cfg: params.cfg,
98
+ msg: params.msg,
99
+ route: params.route,
100
+ peerId: params.peerId,
101
+ agentId: normalizedAgentId,
102
+ });
103
+ const baseAgentRoute = {
104
+ ...params.route,
105
+ agentId: normalizedAgentId,
106
+ ...routeKeys,
107
+ };
108
+ const agentRoute =
109
+ params.msg.chatType === "group"
110
+ ? resolveWhatsAppGroupSessionRoute(baseAgentRoute)
111
+ : baseAgentRoute;
112
+
113
+ try {
114
+ const opts: {
115
+ groupHistory?: GroupHistoryEntry[];
116
+ suppressGroupHistoryClear: true;
117
+ preflightAudioTranscript?: string | null;
118
+ ackAlreadySent?: boolean;
119
+ ackReaction?: AckReactionHandle | null;
120
+ } = {
121
+ groupHistory: groupHistorySnapshot,
122
+ suppressGroupHistoryClear: true,
123
+ };
124
+ if (params.preflightAudioTranscript !== undefined) {
125
+ opts.preflightAudioTranscript = params.preflightAudioTranscript;
126
+ }
127
+ if (params.ackAlreadySent === true) {
128
+ opts.ackAlreadySent = true;
129
+ }
130
+ if (params.ackReaction !== undefined) {
131
+ opts.ackReaction = params.ackReaction;
132
+ }
133
+ return await params.processMessage(params.msg, agentRoute, params.groupHistoryKey, opts);
134
+ } catch (err) {
135
+ whatsappInboundLog.error(`Broadcast agent ${agentId} failed: ${formatError(err)}`);
136
+ return false;
137
+ }
138
+ };
139
+
140
+ if (strategy === "sequential") {
141
+ for (const agentId of broadcastAgents) {
142
+ await processForAgent(agentId);
143
+ }
144
+ } else {
145
+ await Promise.allSettled(broadcastAgents.map(processForAgent));
146
+ }
147
+
148
+ if (params.msg.chatType === "group") {
149
+ params.groupHistories.set(params.groupHistoryKey, []);
150
+ }
151
+
152
+ return true;
153
+ }
@@ -0,0 +1,19 @@
1
+ export function stripMentionsForCommand(
2
+ text: string,
3
+ mentionRegexes: RegExp[],
4
+ selfE164?: string | null,
5
+ ) {
6
+ let result = text;
7
+ for (const re of mentionRegexes) {
8
+ result = result.replace(re, " ");
9
+ }
10
+ if (selfE164) {
11
+ // `selfE164` is usually like "+1234"; strip down to digits so we can match "+?1234" safely.
12
+ const digits = selfE164.replace(/\D/g, "");
13
+ if (digits) {
14
+ const pattern = new RegExp(`\\+?${digits}`, "g");
15
+ result = result.replace(pattern, " ");
16
+ }
17
+ }
18
+ return result.replace(/\s+/g, " ").trim();
19
+ }
@@ -0,0 +1,64 @@
1
+ export type EchoTracker = {
2
+ rememberText: (
3
+ text: string | undefined,
4
+ opts: {
5
+ combinedBody?: string;
6
+ combinedBodySessionKey?: string;
7
+ logVerboseMessage?: boolean;
8
+ },
9
+ ) => void;
10
+ has: (key: string) => boolean;
11
+ forget: (key: string) => void;
12
+ buildCombinedKey: (params: { sessionKey: string; combinedBody: string }) => string;
13
+ };
14
+
15
+ export function createEchoTracker(params: {
16
+ maxItems?: number;
17
+ logVerbose?: (msg: string) => void;
18
+ }): EchoTracker {
19
+ const recentlySent = new Set<string>();
20
+ const maxItems = Math.max(1, params.maxItems ?? 100);
21
+
22
+ const buildCombinedKey = (p: { sessionKey: string; combinedBody: string }) =>
23
+ `combined:${p.sessionKey}:${p.combinedBody}`;
24
+
25
+ const trim = () => {
26
+ while (recentlySent.size > maxItems) {
27
+ const firstKey = recentlySent.values().next().value;
28
+ if (!firstKey) {
29
+ break;
30
+ }
31
+ recentlySent.delete(firstKey);
32
+ }
33
+ };
34
+
35
+ const rememberText: EchoTracker["rememberText"] = (text, opts) => {
36
+ if (!text) {
37
+ return;
38
+ }
39
+ recentlySent.add(text);
40
+ if (opts.combinedBody && opts.combinedBodySessionKey) {
41
+ recentlySent.add(
42
+ buildCombinedKey({
43
+ sessionKey: opts.combinedBodySessionKey,
44
+ combinedBody: opts.combinedBody,
45
+ }),
46
+ );
47
+ }
48
+ if (opts.logVerboseMessage) {
49
+ params.logVerbose?.(
50
+ `Added to echo detection set (size now: ${recentlySent.size}): ${text.slice(0, 50)}...`,
51
+ );
52
+ }
53
+ trim();
54
+ };
55
+
56
+ return {
57
+ rememberText,
58
+ has: (key) => recentlySent.has(key),
59
+ forget: (key) => {
60
+ recentlySent.delete(key);
61
+ },
62
+ buildCombinedKey,
63
+ };
64
+ }
@@ -0,0 +1 @@
1
+ export { normalizeGroupActivation } from "openclaw/plugin-sdk/group-activation";
@@ -0,0 +1,189 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { loadSessionStore } from "../config.runtime.js";
6
+ import { resolveGroupActivationFor } from "./group-activation.js";
7
+
8
+ const GROUP_CONVERSATION_ID = "123@g.us";
9
+ const LEGACY_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us";
10
+ const WORK_GROUP_SESSION_KEY = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
11
+
12
+ type SessionStoreEntry = {
13
+ groupActivation?: unknown;
14
+ sessionId?: unknown;
15
+ updatedAt?: unknown;
16
+ };
17
+
18
+ async function makeSessionStore(
19
+ entries: Record<string, unknown> = {},
20
+ ): Promise<{ storePath: string; cleanup: () => Promise<void> }> {
21
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-"));
22
+ const storePath = path.join(dir, "sessions.json");
23
+ await fs.writeFile(storePath, JSON.stringify(entries));
24
+ return {
25
+ storePath,
26
+ cleanup: async () => {
27
+ await fs.rm(dir, { recursive: true, force: true });
28
+ },
29
+ };
30
+ }
31
+
32
+ const resolveWorkGroupActivation = (storePath: string) =>
33
+ resolveGroupActivationFor({
34
+ cfg: {
35
+ channels: {
36
+ whatsapp: {
37
+ accounts: {
38
+ work: {},
39
+ },
40
+ },
41
+ },
42
+ session: { store: storePath },
43
+ } as never,
44
+ accountId: "work",
45
+ agentId: "main",
46
+ sessionKey: WORK_GROUP_SESSION_KEY,
47
+ conversationId: GROUP_CONVERSATION_ID,
48
+ });
49
+
50
+ const expectWorkGroupActivationEntry = async (
51
+ storePath: string,
52
+ assertEntry?: (entry: SessionStoreEntry | undefined) => void,
53
+ ) => {
54
+ await vi.waitFor(() => {
55
+ const scopedEntry = loadSessionStore(storePath, { skipCache: true })[WORK_GROUP_SESSION_KEY];
56
+ expect(scopedEntry?.groupActivation).toBe("always");
57
+ assertEntry?.(scopedEntry);
58
+ });
59
+ };
60
+
61
+ const expectResolvedWorkGroupActivation = async (
62
+ storePath: string,
63
+ assertEntry?: (entry: SessionStoreEntry | undefined) => void,
64
+ ) => {
65
+ const activation = await resolveWorkGroupActivation(storePath);
66
+ expect(activation).toBe("always");
67
+ await expectWorkGroupActivationEntry(storePath, assertEntry);
68
+ };
69
+
70
+ describe("resolveGroupActivationFor", () => {
71
+ const cleanups: Array<() => Promise<void>> = [];
72
+
73
+ afterEach(async () => {
74
+ while (cleanups.length > 0) {
75
+ await cleanups.pop()?.();
76
+ }
77
+ });
78
+
79
+ it("reads legacy named-account group activation and backfills the scoped key", async () => {
80
+ const { storePath, cleanup } = await makeSessionStore({
81
+ [LEGACY_GROUP_SESSION_KEY]: {
82
+ groupActivation: "always",
83
+ sessionId: "legacy-session",
84
+ updatedAt: 123,
85
+ },
86
+ });
87
+ cleanups.push(cleanup);
88
+
89
+ await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
90
+ expect(scopedEntry?.sessionId).toBeUndefined();
91
+ expect(scopedEntry?.updatedAt).toBeUndefined();
92
+ });
93
+ });
94
+
95
+ it("preserves legacy group activation when the scoped entry already exists without activation", async () => {
96
+ const { storePath, cleanup } = await makeSessionStore({
97
+ [LEGACY_GROUP_SESSION_KEY]: {
98
+ groupActivation: "always",
99
+ },
100
+ [WORK_GROUP_SESSION_KEY]: {
101
+ sessionId: "scoped-session",
102
+ },
103
+ });
104
+ cleanups.push(cleanup);
105
+
106
+ await expectResolvedWorkGroupActivation(storePath, (scopedEntry) => {
107
+ expect(scopedEntry?.sessionId).toBe("scoped-session");
108
+ });
109
+ });
110
+
111
+ it("does not wake the default account from an activation-only legacy group entry in multi-account setups", async () => {
112
+ const { storePath, cleanup } = await makeSessionStore({
113
+ [LEGACY_GROUP_SESSION_KEY]: {
114
+ groupActivation: "always",
115
+ },
116
+ });
117
+ cleanups.push(cleanup);
118
+
119
+ const cfg = {
120
+ channels: {
121
+ whatsapp: {
122
+ groups: {
123
+ "*": {
124
+ requireMention: true,
125
+ },
126
+ },
127
+ accounts: {
128
+ work: {},
129
+ },
130
+ },
131
+ },
132
+ session: { store: storePath },
133
+ } as never;
134
+
135
+ const workActivation = await resolveGroupActivationFor({
136
+ cfg,
137
+ accountId: "work",
138
+ agentId: "main",
139
+ sessionKey: WORK_GROUP_SESSION_KEY,
140
+ conversationId: GROUP_CONVERSATION_ID,
141
+ });
142
+
143
+ expect(workActivation).toBe("always");
144
+
145
+ const defaultActivation = await resolveGroupActivationFor({
146
+ cfg,
147
+ accountId: "default",
148
+ agentId: "main",
149
+ sessionKey: LEGACY_GROUP_SESSION_KEY,
150
+ conversationId: GROUP_CONVERSATION_ID,
151
+ });
152
+
153
+ expect(defaultActivation).toBe("mention");
154
+ await expectWorkGroupActivationEntry(storePath);
155
+ });
156
+
157
+ it("does not treat mixed-case default account keys as named accounts", async () => {
158
+ const { storePath, cleanup } = await makeSessionStore({
159
+ [LEGACY_GROUP_SESSION_KEY]: {
160
+ groupActivation: "always",
161
+ },
162
+ });
163
+ cleanups.push(cleanup);
164
+
165
+ const activation = await resolveGroupActivationFor({
166
+ cfg: {
167
+ channels: {
168
+ whatsapp: {
169
+ groups: {
170
+ "*": {
171
+ requireMention: true,
172
+ },
173
+ },
174
+ accounts: {
175
+ Default: {},
176
+ },
177
+ },
178
+ },
179
+ session: { store: storePath },
180
+ } as never,
181
+ accountId: "default",
182
+ agentId: "main",
183
+ sessionKey: LEGACY_GROUP_SESSION_KEY,
184
+ conversationId: GROUP_CONVERSATION_ID,
185
+ });
186
+
187
+ expect(activation).toBe("always");
188
+ });
189
+ });
@@ -0,0 +1,73 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
3
+ import { updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
4
+ import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js";
5
+ import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
6
+ import { loadSessionStore, resolveStorePath } from "../config.runtime.js";
7
+ import { normalizeGroupActivation } from "./group-activation.runtime.js";
8
+
9
+ function hasNamedWhatsAppAccounts(cfg: OpenClawConfig) {
10
+ const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
11
+ return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID);
12
+ }
13
+
14
+ function isActivationOnlyEntry(
15
+ entry:
16
+ | {
17
+ groupActivation?: unknown;
18
+ sessionId?: unknown;
19
+ updatedAt?: unknown;
20
+ }
21
+ | undefined,
22
+ ) {
23
+ return (
24
+ entry?.groupActivation !== undefined &&
25
+ typeof entry?.sessionId !== "string" &&
26
+ typeof entry?.updatedAt !== "number"
27
+ );
28
+ }
29
+
30
+ export async function resolveGroupActivationFor(params: {
31
+ cfg: OpenClawConfig;
32
+ accountId?: string | null;
33
+ agentId: string;
34
+ sessionKey: string;
35
+ conversationId: string;
36
+ }) {
37
+ const storePath = resolveStorePath(params.cfg.session?.store, {
38
+ agentId: params.agentId,
39
+ });
40
+ const store = loadSessionStore(storePath);
41
+ const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({
42
+ sessionKey: params.sessionKey,
43
+ accountId: params.accountId,
44
+ });
45
+ const legacyEntry = legacySessionKey ? store[legacySessionKey] : undefined;
46
+ const scopedEntry = store[params.sessionKey];
47
+ const normalizedAccountId = normalizeAccountId(params.accountId);
48
+ const ignoreScopedActivation =
49
+ normalizedAccountId === DEFAULT_ACCOUNT_ID &&
50
+ hasNamedWhatsAppAccounts(params.cfg) &&
51
+ isActivationOnlyEntry(scopedEntry);
52
+ const activation =
53
+ (ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ??
54
+ legacyEntry?.groupActivation;
55
+ if (activation !== undefined && scopedEntry?.groupActivation === undefined) {
56
+ await updateSessionStore(storePath, (nextStore) => {
57
+ const nextScopedEntry = nextStore[params.sessionKey];
58
+ if (nextScopedEntry?.groupActivation !== undefined) {
59
+ return;
60
+ }
61
+ nextStore[params.sessionKey] = {
62
+ ...nextScopedEntry,
63
+ groupActivation: activation,
64
+ };
65
+ });
66
+ }
67
+ const requireMention = resolveWhatsAppInboundPolicy({
68
+ cfg: params.cfg,
69
+ accountId: params.accountId,
70
+ }).resolveConversationRequireMention(params.conversationId);
71
+ const defaultActivation = !requireMention ? "always" : "mention";
72
+ return normalizeGroupActivation(activation) ?? defaultActivation;
73
+ }