@gakr-gakr/whatsapp 0.1.0

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 (159) hide show
  1. package/action-runtime-api.ts +1 -0
  2. package/action-runtime.runtime.ts +1 -0
  3. package/api.ts +67 -0
  4. package/auth-presence.ts +80 -0
  5. package/autobot.plugin.json +23 -0
  6. package/channel-config-api.ts +1 -0
  7. package/channel-plugin-api.ts +3 -0
  8. package/config-api.ts +4 -0
  9. package/constants.ts +1 -0
  10. package/contract-api.ts +29 -0
  11. package/directory-contract-api.ts +4 -0
  12. package/doctor-contract-api.ts +8 -0
  13. package/index.ts +16 -0
  14. package/legacy-session-surface-api.ts +6 -0
  15. package/legacy-state-migrations-api.ts +1 -0
  16. package/light-runtime-api.ts +12 -0
  17. package/login-qr-api.ts +1 -0
  18. package/login-qr-runtime.ts +23 -0
  19. package/outbound-payload-test-api.ts +1 -0
  20. package/package.json +76 -0
  21. package/runtime-api.ts +84 -0
  22. package/secret-contract-api.ts +4 -0
  23. package/security-contract-api.ts +4 -0
  24. package/setup-entry.ts +21 -0
  25. package/setup-plugin-api.ts +3 -0
  26. package/src/account-config.ts +77 -0
  27. package/src/account-ids.ts +17 -0
  28. package/src/account-types.ts +5 -0
  29. package/src/accounts.ts +176 -0
  30. package/src/action-runtime-target-auth.ts +27 -0
  31. package/src/action-runtime.ts +76 -0
  32. package/src/active-listener.ts +17 -0
  33. package/src/agent-tools-login.ts +113 -0
  34. package/src/approval-auth.ts +27 -0
  35. package/src/auth-store.runtime.ts +1 -0
  36. package/src/auth-store.ts +494 -0
  37. package/src/auto-reply/config.runtime.ts +16 -0
  38. package/src/auto-reply/constants.ts +1 -0
  39. package/src/auto-reply/deliver-reply.ts +332 -0
  40. package/src/auto-reply/loggers.ts +6 -0
  41. package/src/auto-reply/mentions.ts +131 -0
  42. package/src/auto-reply/monitor/ack-reaction.ts +99 -0
  43. package/src/auto-reply/monitor/audio-preflight.runtime.ts +9 -0
  44. package/src/auto-reply/monitor/broadcast.ts +153 -0
  45. package/src/auto-reply/monitor/commands.ts +19 -0
  46. package/src/auto-reply/monitor/echo.ts +64 -0
  47. package/src/auto-reply/monitor/group-activation.runtime.ts +1 -0
  48. package/src/auto-reply/monitor/group-activation.ts +73 -0
  49. package/src/auto-reply/monitor/group-gating.runtime.ts +8 -0
  50. package/src/auto-reply/monitor/group-gating.ts +218 -0
  51. package/src/auto-reply/monitor/group-members.ts +65 -0
  52. package/src/auto-reply/monitor/inbound-context.ts +92 -0
  53. package/src/auto-reply/monitor/inbound-dispatch.runtime.ts +22 -0
  54. package/src/auto-reply/monitor/inbound-dispatch.ts +749 -0
  55. package/src/auto-reply/monitor/last-route.ts +61 -0
  56. package/src/auto-reply/monitor/listener-log.ts +28 -0
  57. package/src/auto-reply/monitor/message-line.runtime.ts +38 -0
  58. package/src/auto-reply/monitor/message-line.ts +54 -0
  59. package/src/auto-reply/monitor/on-message.ts +333 -0
  60. package/src/auto-reply/monitor/peer.ts +17 -0
  61. package/src/auto-reply/monitor/process-message.ts +584 -0
  62. package/src/auto-reply/monitor/runtime-api.ts +36 -0
  63. package/src/auto-reply/monitor/status-reaction.ts +108 -0
  64. package/src/auto-reply/monitor-state.ts +114 -0
  65. package/src/auto-reply/monitor.ts +720 -0
  66. package/src/auto-reply/reply-resolver.runtime.ts +1 -0
  67. package/src/auto-reply/types.ts +48 -0
  68. package/src/auto-reply/util.ts +62 -0
  69. package/src/auto-reply.impl.ts +6 -0
  70. package/src/auto-reply.ts +1 -0
  71. package/src/channel-actions.runtime.ts +7 -0
  72. package/src/channel-actions.ts +85 -0
  73. package/src/channel-outbound.ts +87 -0
  74. package/src/channel-react-action.runtime.ts +10 -0
  75. package/src/channel-react-action.ts +247 -0
  76. package/src/channel.runtime.ts +117 -0
  77. package/src/channel.setup.ts +32 -0
  78. package/src/channel.ts +356 -0
  79. package/src/command-policy.ts +7 -0
  80. package/src/config-accessors.ts +22 -0
  81. package/src/config-schema.ts +6 -0
  82. package/src/config-ui-hints.ts +24 -0
  83. package/src/connection-controller-registry.ts +49 -0
  84. package/src/connection-controller.ts +680 -0
  85. package/src/creds-files.ts +19 -0
  86. package/src/creds-persistence.ts +71 -0
  87. package/src/directory-config.ts +40 -0
  88. package/src/doctor-contract.ts +11 -0
  89. package/src/doctor.ts +56 -0
  90. package/src/document-filename.ts +17 -0
  91. package/src/group-intro.ts +15 -0
  92. package/src/group-policy.ts +40 -0
  93. package/src/group-session-contract.ts +20 -0
  94. package/src/group-session-key.ts +42 -0
  95. package/src/heartbeat.ts +34 -0
  96. package/src/identity.ts +164 -0
  97. package/src/inbound/access-control.ts +187 -0
  98. package/src/inbound/dedupe.ts +132 -0
  99. package/src/inbound/extract.ts +484 -0
  100. package/src/inbound/lifecycle.ts +39 -0
  101. package/src/inbound/media.ts +128 -0
  102. package/src/inbound/monitor.ts +1042 -0
  103. package/src/inbound/outbound-mentions.ts +260 -0
  104. package/src/inbound/runtime-api.ts +7 -0
  105. package/src/inbound/save-media.runtime.ts +1 -0
  106. package/src/inbound/send-api.ts +203 -0
  107. package/src/inbound/send-result.ts +109 -0
  108. package/src/inbound/types.ts +107 -0
  109. package/src/inbound-policy.ts +215 -0
  110. package/src/inbound.ts +9 -0
  111. package/src/login-qr.ts +542 -0
  112. package/src/login.ts +83 -0
  113. package/src/media.ts +10 -0
  114. package/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +417 -0
  115. package/src/monitor-inbox.append-upsert.test-support.ts +133 -0
  116. package/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +418 -0
  117. package/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +308 -0
  118. package/src/monitor-inbox.streams-inbound-messages.test-support.ts +824 -0
  119. package/src/normalize-target.ts +148 -0
  120. package/src/normalize.ts +8 -0
  121. package/src/outbound-adapter.ts +36 -0
  122. package/src/outbound-base.ts +256 -0
  123. package/src/outbound-media-contract.ts +307 -0
  124. package/src/outbound-media.runtime.ts +41 -0
  125. package/src/outbound-send-deps.ts +1 -0
  126. package/src/outbound-test-support.ts +16 -0
  127. package/src/qa-driver.runtime.ts +189 -0
  128. package/src/qr-image.ts +1 -0
  129. package/src/qr-terminal.ts +1 -0
  130. package/src/quoted-message.ts +184 -0
  131. package/src/reaction-level.ts +24 -0
  132. package/src/reconnect.ts +55 -0
  133. package/src/resolve-outbound-target.ts +58 -0
  134. package/src/runtime-api.ts +59 -0
  135. package/src/runtime-group-policy.ts +16 -0
  136. package/src/runtime.ts +9 -0
  137. package/src/security-contract.ts +47 -0
  138. package/src/security-fix.ts +71 -0
  139. package/src/send.ts +342 -0
  140. package/src/session-contract.ts +43 -0
  141. package/src/session-errors.ts +125 -0
  142. package/src/session-route.ts +32 -0
  143. package/src/session.runtime.ts +8 -0
  144. package/src/session.ts +327 -0
  145. package/src/setup-core.ts +52 -0
  146. package/src/setup-finalize.ts +450 -0
  147. package/src/setup-surface.ts +71 -0
  148. package/src/setup-test-helpers.ts +217 -0
  149. package/src/shared.ts +291 -0
  150. package/src/socket-timing.ts +38 -0
  151. package/src/state-migrations.ts +55 -0
  152. package/src/status-issues.ts +185 -0
  153. package/src/system-prompt.ts +31 -0
  154. package/src/targets-runtime.ts +221 -0
  155. package/src/text-runtime.ts +18 -0
  156. package/src/vcard.ts +84 -0
  157. package/targets.ts +5 -0
  158. package/test-api.ts +2 -0
  159. package/tsconfig.json +16 -0
@@ -0,0 +1,332 @@
1
+ import {
2
+ createMessageReceiptFromOutboundResults,
3
+ type MessageReceipt,
4
+ type MessageReceiptSourceResult,
5
+ } from "autobot/plugin-sdk/channel-message";
6
+ import type { MarkdownTableMode } from "autobot/plugin-sdk/config-contracts";
7
+ import { chunkMarkdownTextWithMode, type ChunkMode } from "autobot/plugin-sdk/reply-chunking";
8
+ import type { ReplyPayload } from "autobot/plugin-sdk/reply-chunking";
9
+ import {
10
+ isReasoningReplyPayload,
11
+ sendMediaWithLeadingCaption,
12
+ } from "autobot/plugin-sdk/reply-payload";
13
+ import { logVerbose, shouldLogVerbose } from "autobot/plugin-sdk/runtime-env";
14
+ import type { WhatsAppSendResult } from "../inbound/send-result.js";
15
+ import { listWhatsAppSendResultMessageIds } from "../inbound/send-result.js";
16
+ import { loadWebMedia } from "../media.js";
17
+ import {
18
+ type DeliverableWhatsAppOutboundPayload,
19
+ normalizeWhatsAppOutboundPayload,
20
+ normalizeWhatsAppPayloadTextPreservingIndentation,
21
+ prepareWhatsAppOutboundMedia,
22
+ sendWhatsAppOutboundWithRetry,
23
+ } from "../outbound-media-contract.js";
24
+ import { buildQuotedMessageOptions, lookupInboundMessageMeta } from "../quoted-message.js";
25
+ import { newConnectionId } from "../reconnect.js";
26
+ import { formatError } from "../session.js";
27
+ import { convertMarkdownTables } from "../text-runtime.js";
28
+ import { markdownToWhatsApp } from "../text-runtime.js";
29
+ import { whatsappOutboundLog } from "./loggers.js";
30
+ import type { WebInboundMsg } from "./types.js";
31
+ import { elide } from "./util.js";
32
+
33
+ export type WhatsAppReplyDeliveryResult = {
34
+ results: WhatsAppSendResult[];
35
+ receipt: MessageReceipt;
36
+ providerAccepted: boolean;
37
+ };
38
+
39
+ function resolveWhatsAppReceiptKind(
40
+ results: readonly WhatsAppSendResult[],
41
+ ): Parameters<typeof createMessageReceiptFromOutboundResults>[0]["kind"] {
42
+ if (results.length > 0 && results.every((result) => result.kind === "text")) {
43
+ return "text";
44
+ }
45
+ if (results.length > 0 && results.every((result) => result.kind === "media")) {
46
+ return "media";
47
+ }
48
+ return "unknown";
49
+ }
50
+
51
+ function createWhatsAppReplyDeliveryReceipt(
52
+ results: readonly WhatsAppSendResult[],
53
+ ): MessageReceipt {
54
+ const receiptResultsById = new Map<string, MessageReceiptSourceResult>();
55
+ for (const result of results) {
56
+ if (result.receipt?.parts.length) {
57
+ for (const part of result.receipt.parts) {
58
+ receiptResultsById.set(part.platformMessageId, {
59
+ ...(part.raw ?? { channel: "whatsapp", messageId: part.platformMessageId }),
60
+ meta: {
61
+ ...part.raw?.meta,
62
+ kind: result.kind,
63
+ providerAccepted: result.providerAccepted,
64
+ },
65
+ });
66
+ }
67
+ continue;
68
+ }
69
+ for (const messageId of listWhatsAppSendResultMessageIds(result)) {
70
+ receiptResultsById.set(messageId, {
71
+ channel: "whatsapp",
72
+ messageId,
73
+ meta: {
74
+ kind: result.kind,
75
+ providerAccepted: result.providerAccepted,
76
+ },
77
+ });
78
+ }
79
+ }
80
+ return createMessageReceiptFromOutboundResults({
81
+ results: [...receiptResultsById.values()],
82
+ kind: resolveWhatsAppReceiptKind(results),
83
+ });
84
+ }
85
+
86
+ export async function deliverWebReply(params: {
87
+ replyResult: ReplyPayload;
88
+ normalizedReplyResult?: DeliverableWhatsAppOutboundPayload<ReplyPayload>;
89
+ msg: WebInboundMsg;
90
+ mediaLocalRoots?: readonly string[];
91
+ maxMediaBytes: number;
92
+ textLimit: number;
93
+ chunkMode?: ChunkMode;
94
+ replyLogger: {
95
+ info: (obj: unknown, msg: string) => void;
96
+ warn: (obj: unknown, msg: string) => void;
97
+ };
98
+ connectionId?: string;
99
+ skipLog?: boolean;
100
+ tableMode?: MarkdownTableMode;
101
+ }): Promise<WhatsAppReplyDeliveryResult> {
102
+ const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params;
103
+ const replyStarted = Date.now();
104
+ const sendResults: WhatsAppSendResult[] = [];
105
+ const rememberSendResult = (result: WhatsAppSendResult | undefined) => {
106
+ if (result) {
107
+ sendResults.push(result);
108
+ }
109
+ };
110
+ const finishDelivery = (): WhatsAppReplyDeliveryResult => {
111
+ const receipt = createWhatsAppReplyDeliveryReceipt(sendResults);
112
+ return {
113
+ results: sendResults,
114
+ receipt,
115
+ providerAccepted: sendResults.some((result) => result.providerAccepted),
116
+ };
117
+ };
118
+ if (isReasoningReplyPayload(replyResult)) {
119
+ whatsappOutboundLog.debug(`Suppressed reasoning payload to ${msg.from}`);
120
+ return finishDelivery();
121
+ }
122
+ const tableMode = params.tableMode ?? "code";
123
+ const chunkMode = params.chunkMode ?? "length";
124
+ const normalizedReply =
125
+ params.normalizedReplyResult ??
126
+ normalizeWhatsAppOutboundPayload(replyResult, {
127
+ normalizeText: normalizeWhatsAppPayloadTextPreservingIndentation,
128
+ });
129
+ const convertedText = markdownToWhatsApp(
130
+ convertMarkdownTables(normalizedReply.text ?? "", tableMode),
131
+ );
132
+ const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode);
133
+ const mediaList = normalizedReply.mediaUrls ?? [];
134
+
135
+ const getQuote = () => {
136
+ if (!replyResult.replyToId) {
137
+ return undefined;
138
+ }
139
+ // Use replyToId (not msg.id) so batched payloads quote the correct
140
+ // per-message target. Look up cached metadata for the specific
141
+ // message being quoted — msg.body may be a combined batch body.
142
+ const cached = lookupInboundMessageMeta(msg.accountId, msg.chatId, replyResult.replyToId);
143
+ return buildQuotedMessageOptions({
144
+ messageId: replyResult.replyToId,
145
+ remoteJid: msg.chatId,
146
+ fromMe: cached?.fromMe ?? false,
147
+ participant: cached?.participant ?? (msg.chatType === "group" ? msg.senderJid : undefined),
148
+ messageText: cached?.body ?? "",
149
+ });
150
+ };
151
+
152
+ const sendWithRetry = async <T>(fn: () => Promise<T>, label: string, maxAttempts = 3) => {
153
+ return await sendWhatsAppOutboundWithRetry({
154
+ send: fn,
155
+ maxAttempts,
156
+ onRetry: ({ attempt, maxAttempts: retryMaxAttempts, backoffMs, errorText }) => {
157
+ logVerbose(
158
+ `Retrying ${label} to ${msg.from} after failure (${attempt}/${retryMaxAttempts - 1}) in ${backoffMs}ms: ${errorText}`,
159
+ );
160
+ },
161
+ });
162
+ };
163
+
164
+ // Text-only replies
165
+ if (mediaList.length === 0 && textChunks.length) {
166
+ const totalChunks = textChunks.length;
167
+ for (const [index, chunk] of textChunks.entries()) {
168
+ const chunkStarted = Date.now();
169
+ const quote = getQuote();
170
+ rememberSendResult(await sendWithRetry(() => msg.reply(chunk, quote), "text"));
171
+ if (!skipLog) {
172
+ const durationMs = Date.now() - chunkStarted;
173
+ whatsappOutboundLog.debug(
174
+ `Sent chunk ${index + 1}/${totalChunks} to ${msg.from} (${durationMs.toFixed(0)}ms)`,
175
+ );
176
+ }
177
+ }
178
+ const delivery = finishDelivery();
179
+ const logPayload = {
180
+ correlationId: msg.id ?? newConnectionId(),
181
+ connectionId: connectionId ?? null,
182
+ to: msg.from,
183
+ from: msg.to,
184
+ text: elide(replyResult.text, 240),
185
+ mediaUrl: null,
186
+ mediaSizeBytes: null,
187
+ mediaKind: null,
188
+ durationMs: Date.now() - replyStarted,
189
+ };
190
+ if (delivery.providerAccepted) {
191
+ replyLogger.info(logPayload, "auto-reply sent (text)");
192
+ } else {
193
+ replyLogger.warn(logPayload, "auto-reply text was not accepted by WhatsApp provider");
194
+ }
195
+ return delivery;
196
+ }
197
+
198
+ const remainingText = [...textChunks];
199
+
200
+ // Media (with optional caption on first item)
201
+ const leadingCaption = remainingText.shift() || "";
202
+ await sendMediaWithLeadingCaption({
203
+ mediaUrls: mediaList,
204
+ caption: leadingCaption,
205
+ send: async ({ mediaUrl, caption }) => {
206
+ const media = await prepareWhatsAppOutboundMedia(
207
+ await loadWebMedia(mediaUrl, {
208
+ maxBytes: maxMediaBytes,
209
+ localRoots: params.mediaLocalRoots,
210
+ }),
211
+ mediaUrl,
212
+ );
213
+ if (shouldLogVerbose()) {
214
+ logVerbose(
215
+ `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
216
+ );
217
+ logVerbose(`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`);
218
+ }
219
+ if (media.kind === "image") {
220
+ const quote = getQuote();
221
+ rememberSendResult(
222
+ await sendWithRetry(
223
+ () =>
224
+ msg.sendMedia(
225
+ {
226
+ image: media.buffer,
227
+ caption,
228
+ mimetype: media.mimetype,
229
+ },
230
+ quote,
231
+ ),
232
+ "media:image",
233
+ ),
234
+ );
235
+ } else if (media.kind === "audio") {
236
+ const quote = getQuote();
237
+ rememberSendResult(
238
+ await sendWithRetry(
239
+ () =>
240
+ msg.sendMedia(
241
+ {
242
+ audio: media.buffer,
243
+ ptt: true,
244
+ mimetype: media.mimetype,
245
+ },
246
+ quote,
247
+ ),
248
+ "media:audio",
249
+ ),
250
+ );
251
+ if (caption) {
252
+ rememberSendResult(
253
+ await sendWithRetry(() => msg.reply(caption, quote), "media:audio-text"),
254
+ );
255
+ }
256
+ } else if (media.kind === "video") {
257
+ const quote = getQuote();
258
+ rememberSendResult(
259
+ await sendWithRetry(
260
+ () =>
261
+ msg.sendMedia(
262
+ {
263
+ video: media.buffer,
264
+ caption,
265
+ mimetype: media.mimetype,
266
+ },
267
+ quote,
268
+ ),
269
+ "media:video",
270
+ ),
271
+ );
272
+ } else {
273
+ const quote = getQuote();
274
+ rememberSendResult(
275
+ await sendWithRetry(
276
+ () =>
277
+ msg.sendMedia(
278
+ {
279
+ document: media.buffer,
280
+ fileName: media.fileName,
281
+ caption,
282
+ mimetype: media.mimetype,
283
+ },
284
+ quote,
285
+ ),
286
+ "media:document",
287
+ ),
288
+ );
289
+ }
290
+ whatsappOutboundLog.info(
291
+ `Sent media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
292
+ );
293
+ replyLogger.info(
294
+ {
295
+ correlationId: msg.id ?? newConnectionId(),
296
+ connectionId: connectionId ?? null,
297
+ to: msg.from,
298
+ from: msg.to,
299
+ text: caption ?? null,
300
+ mediaUrl,
301
+ mediaSizeBytes: media.buffer.length,
302
+ mediaKind: media.kind,
303
+ durationMs: Date.now() - replyStarted,
304
+ },
305
+ "auto-reply sent (media)",
306
+ );
307
+ },
308
+ onError: async ({ error, mediaUrl, caption, isFirst }) => {
309
+ whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`);
310
+ replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply");
311
+ if (!isFirst) {
312
+ return;
313
+ }
314
+ const warning = "⚠️ Media failed.";
315
+ const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean);
316
+ const fallbackText = fallbackTextParts.join("\n");
317
+ if (!fallbackText) {
318
+ return;
319
+ }
320
+ whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`);
321
+ rememberSendResult(
322
+ await sendWithRetry(() => msg.reply(fallbackText, getQuote()), "media:fallback-text"),
323
+ );
324
+ },
325
+ });
326
+
327
+ // Remaining text chunks after media
328
+ for (const chunk of remainingText) {
329
+ rememberSendResult(await sendWithRetry(() => msg.reply(chunk, getQuote()), "media:text"));
330
+ }
331
+ return finishDelivery();
332
+ }
@@ -0,0 +1,6 @@
1
+ import { createSubsystemLogger } from "autobot/plugin-sdk/runtime-env";
2
+
3
+ export const whatsappLog = createSubsystemLogger("gateway/channels/whatsapp");
4
+ export const whatsappInboundLog = whatsappLog.child("inbound");
5
+ export const whatsappOutboundLog = whatsappLog.child("outbound");
6
+ export const whatsappHeartbeatLog = whatsappLog.child("heartbeat");
@@ -0,0 +1,131 @@
1
+ import {
2
+ buildMentionRegexes,
3
+ normalizeMentionText,
4
+ } from "autobot/plugin-sdk/channel-mention-gating";
5
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
6
+ import {
7
+ getComparableIdentityValues,
8
+ getMentionIdentities,
9
+ getSelfIdentity,
10
+ identitiesOverlap,
11
+ type WhatsAppIdentity,
12
+ } from "../identity.js";
13
+ import { isWhatsAppGroupJid } from "../normalize-target.js";
14
+ import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
15
+ import type { WebInboundMsg } from "./types.js";
16
+
17
+ export type MentionConfig = {
18
+ mentionRegexes: RegExp[];
19
+ allowFrom?: Array<string | number>;
20
+ isSelfChat?: boolean;
21
+ };
22
+
23
+ export type MentionTargets = {
24
+ normalizedMentions: WhatsAppIdentity[];
25
+ self: WhatsAppIdentity;
26
+ };
27
+
28
+ export function buildMentionConfig(cfg: AutoBotConfig, agentId?: string): MentionConfig {
29
+ const mentionRegexes = buildMentionRegexes(cfg, agentId);
30
+ return { mentionRegexes, allowFrom: cfg.channels?.whatsapp?.allowFrom };
31
+ }
32
+
33
+ export function resolveMentionTargets(msg: WebInboundMsg, authDir?: string): MentionTargets {
34
+ const normalizedMentions = getMentionIdentities(msg, authDir);
35
+ const self = getSelfIdentity(msg, authDir);
36
+ return { normalizedMentions, self };
37
+ }
38
+
39
+ export function isBotMentionedFromTargets(
40
+ msg: WebInboundMsg,
41
+ mentionCfg: MentionConfig,
42
+ targets: MentionTargets,
43
+ ): boolean {
44
+ const clean = (text: string) =>
45
+ // Remove zero-width and directionality markers WhatsApp injects around display names
46
+ normalizeMentionText(text);
47
+
48
+ const explicitSelfChatOverride = typeof mentionCfg.isSelfChat === "boolean";
49
+ // `isSelfChatMode` is a config-shaped check ("is the bot's own E.164 in
50
+ // allowFrom?"), not a conversation-shaped check, so it returns true even
51
+ // for group conversations whenever the operator put their own number in
52
+ // allowFrom — which is the common config. The original mention-skip path
53
+ // was designed to prevent owner-mentioning-self in a true 1:1 self DM
54
+ // from falsely triggering the bot, so when we derive the flag implicitly
55
+ // from `allowFrom`, confine the suppression to non-group conversations
56
+ // and let real group @mentions go through the identity-overlap check
57
+ // (#49317). Explicit `mentionCfg.isSelfChat` overrides from the caller
58
+ // are honored as-is so multi-account / precomputed paths keep working.
59
+ const isGroupConversation = isWhatsAppGroupJid(msg.from);
60
+ const isSelfChat = explicitSelfChatOverride
61
+ ? Boolean(mentionCfg.isSelfChat)
62
+ : isSelfChatMode(targets.self.e164, mentionCfg.allowFrom) && !isGroupConversation;
63
+
64
+ const hasMentions = targets.normalizedMentions.length > 0;
65
+ if (hasMentions && !isSelfChat) {
66
+ for (const mention of targets.normalizedMentions) {
67
+ if (identitiesOverlap(targets.self, mention)) {
68
+ return true;
69
+ }
70
+ }
71
+ // If the message explicitly mentions someone else, do not fall back to regex matches.
72
+ return false;
73
+ } else if (hasMentions && isSelfChat) {
74
+ // Self-chat mode: ignore WhatsApp @mention JIDs, otherwise @mentioning the owner in self-chat triggers the bot.
75
+ }
76
+ const bodyClean = clean(msg.body);
77
+ if (mentionCfg.mentionRegexes.some((re) => re.test(bodyClean))) {
78
+ return true;
79
+ }
80
+
81
+ // Fallback: detect body containing our own number (with or without +, spacing)
82
+ if (targets.self.e164) {
83
+ const selfDigits = targets.self.e164.replace(/\D/g, "");
84
+ if (selfDigits) {
85
+ const bodyDigits = bodyClean.replace(/[^\d]/g, "");
86
+ if (bodyDigits.includes(selfDigits)) {
87
+ return true;
88
+ }
89
+ const bodyNoSpace = msg.body.replace(/[\s-]/g, "");
90
+ const pattern = new RegExp(`\\+?${selfDigits}`, "i");
91
+ if (pattern.test(bodyNoSpace)) {
92
+ return true;
93
+ }
94
+ }
95
+ }
96
+
97
+ return false;
98
+ }
99
+
100
+ export function debugMention(
101
+ msg: WebInboundMsg,
102
+ mentionCfg: MentionConfig,
103
+ authDir?: string,
104
+ ): { wasMentioned: boolean; details: Record<string, unknown> } {
105
+ const mentionTargets = resolveMentionTargets(msg, authDir);
106
+ const result = isBotMentionedFromTargets(msg, mentionCfg, mentionTargets);
107
+ const details = {
108
+ from: msg.from,
109
+ body: msg.body,
110
+ bodyClean: normalizeMentionText(msg.body),
111
+ mentionedJids: msg.mentions ?? msg.mentionedJids ?? null,
112
+ normalizedMentionedJids: mentionTargets.normalizedMentions.length
113
+ ? mentionTargets.normalizedMentions.map((identity) => getComparableIdentityValues(identity))
114
+ : null,
115
+ selfJid: msg.self?.jid ?? msg.selfJid ?? null,
116
+ selfLid: msg.self?.lid ?? msg.selfLid ?? null,
117
+ selfE164: msg.self?.e164 ?? msg.selfE164 ?? null,
118
+ resolvedSelf: mentionTargets.self,
119
+ };
120
+ return { wasMentioned: result, details };
121
+ }
122
+
123
+ export function resolveOwnerList(mentionCfg: MentionConfig, selfE164?: string | null) {
124
+ const allowFrom = mentionCfg.allowFrom;
125
+ const raw =
126
+ Array.isArray(allowFrom) && allowFrom.length > 0 ? allowFrom : selfE164 ? [selfE164] : [];
127
+ return raw
128
+ .filter((entry): entry is string => Boolean(entry && entry !== "*"))
129
+ .map((entry) => normalizeE164(entry))
130
+ .filter((entry): entry is string => Boolean(entry));
131
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ createAckReactionHandle,
3
+ shouldAckReactionForWhatsApp,
4
+ type AckReactionHandle,
5
+ } from "autobot/plugin-sdk/channel-feedback";
6
+ import type { AutoBotConfig } from "autobot/plugin-sdk/config-contracts";
7
+ import { logVerbose } from "autobot/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: AutoBotConfig;
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 "autobot/plugin-sdk/media-runtime";
2
+
3
+ type TranscribeFirstAudio = typeof import("autobot/plugin-sdk/media-runtime").transcribeFirstAudio;
4
+
5
+ export async function transcribeFirstAudio(
6
+ ...args: Parameters<TranscribeFirstAudio>
7
+ ): ReturnType<TranscribeFirstAudio> {
8
+ return await transcribeFirstAudioImpl(...args);
9
+ }