@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.
- package/action-runtime-api.ts +1 -0
- package/action-runtime.runtime.ts +1 -0
- package/api.ts +67 -0
- package/auth-presence.ts +80 -0
- package/autobot.plugin.json +23 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +3 -0
- package/config-api.ts +4 -0
- package/constants.ts +1 -0
- package/contract-api.ts +29 -0
- package/directory-contract-api.ts +4 -0
- package/doctor-contract-api.ts +8 -0
- package/index.ts +16 -0
- package/legacy-session-surface-api.ts +6 -0
- package/legacy-state-migrations-api.ts +1 -0
- package/light-runtime-api.ts +12 -0
- package/login-qr-api.ts +1 -0
- package/login-qr-runtime.ts +23 -0
- package/outbound-payload-test-api.ts +1 -0
- package/package.json +76 -0
- package/runtime-api.ts +84 -0
- package/secret-contract-api.ts +4 -0
- package/security-contract-api.ts +4 -0
- package/setup-entry.ts +21 -0
- package/setup-plugin-api.ts +3 -0
- package/src/account-config.ts +77 -0
- package/src/account-ids.ts +17 -0
- package/src/account-types.ts +5 -0
- package/src/accounts.ts +176 -0
- package/src/action-runtime-target-auth.ts +27 -0
- package/src/action-runtime.ts +76 -0
- package/src/active-listener.ts +17 -0
- package/src/agent-tools-login.ts +113 -0
- package/src/approval-auth.ts +27 -0
- package/src/auth-store.runtime.ts +1 -0
- package/src/auth-store.ts +494 -0
- package/src/auto-reply/config.runtime.ts +16 -0
- package/src/auto-reply/constants.ts +1 -0
- package/src/auto-reply/deliver-reply.ts +332 -0
- package/src/auto-reply/loggers.ts +6 -0
- package/src/auto-reply/mentions.ts +131 -0
- package/src/auto-reply/monitor/ack-reaction.ts +99 -0
- package/src/auto-reply/monitor/audio-preflight.runtime.ts +9 -0
- package/src/auto-reply/monitor/broadcast.ts +153 -0
- package/src/auto-reply/monitor/commands.ts +19 -0
- package/src/auto-reply/monitor/echo.ts +64 -0
- package/src/auto-reply/monitor/group-activation.runtime.ts +1 -0
- package/src/auto-reply/monitor/group-activation.ts +73 -0
- package/src/auto-reply/monitor/group-gating.runtime.ts +8 -0
- package/src/auto-reply/monitor/group-gating.ts +218 -0
- package/src/auto-reply/monitor/group-members.ts +65 -0
- package/src/auto-reply/monitor/inbound-context.ts +92 -0
- package/src/auto-reply/monitor/inbound-dispatch.runtime.ts +22 -0
- package/src/auto-reply/monitor/inbound-dispatch.ts +749 -0
- package/src/auto-reply/monitor/last-route.ts +61 -0
- package/src/auto-reply/monitor/listener-log.ts +28 -0
- package/src/auto-reply/monitor/message-line.runtime.ts +38 -0
- package/src/auto-reply/monitor/message-line.ts +54 -0
- package/src/auto-reply/monitor/on-message.ts +333 -0
- package/src/auto-reply/monitor/peer.ts +17 -0
- package/src/auto-reply/monitor/process-message.ts +584 -0
- package/src/auto-reply/monitor/runtime-api.ts +36 -0
- package/src/auto-reply/monitor/status-reaction.ts +108 -0
- package/src/auto-reply/monitor-state.ts +114 -0
- package/src/auto-reply/monitor.ts +720 -0
- package/src/auto-reply/reply-resolver.runtime.ts +1 -0
- package/src/auto-reply/types.ts +48 -0
- package/src/auto-reply/util.ts +62 -0
- package/src/auto-reply.impl.ts +6 -0
- package/src/auto-reply.ts +1 -0
- package/src/channel-actions.runtime.ts +7 -0
- package/src/channel-actions.ts +85 -0
- package/src/channel-outbound.ts +87 -0
- package/src/channel-react-action.runtime.ts +10 -0
- package/src/channel-react-action.ts +247 -0
- package/src/channel.runtime.ts +117 -0
- package/src/channel.setup.ts +32 -0
- package/src/channel.ts +356 -0
- package/src/command-policy.ts +7 -0
- package/src/config-accessors.ts +22 -0
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +24 -0
- package/src/connection-controller-registry.ts +49 -0
- package/src/connection-controller.ts +680 -0
- package/src/creds-files.ts +19 -0
- package/src/creds-persistence.ts +71 -0
- package/src/directory-config.ts +40 -0
- package/src/doctor-contract.ts +11 -0
- package/src/doctor.ts +56 -0
- package/src/document-filename.ts +17 -0
- package/src/group-intro.ts +15 -0
- package/src/group-policy.ts +40 -0
- package/src/group-session-contract.ts +20 -0
- package/src/group-session-key.ts +42 -0
- package/src/heartbeat.ts +34 -0
- package/src/identity.ts +164 -0
- package/src/inbound/access-control.ts +187 -0
- package/src/inbound/dedupe.ts +132 -0
- package/src/inbound/extract.ts +484 -0
- package/src/inbound/lifecycle.ts +39 -0
- package/src/inbound/media.ts +128 -0
- package/src/inbound/monitor.ts +1042 -0
- package/src/inbound/outbound-mentions.ts +260 -0
- package/src/inbound/runtime-api.ts +7 -0
- package/src/inbound/save-media.runtime.ts +1 -0
- package/src/inbound/send-api.ts +203 -0
- package/src/inbound/send-result.ts +109 -0
- package/src/inbound/types.ts +107 -0
- package/src/inbound-policy.ts +215 -0
- package/src/inbound.ts +9 -0
- package/src/login-qr.ts +542 -0
- package/src/login.ts +83 -0
- package/src/media.ts +10 -0
- package/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.ts +417 -0
- package/src/monitor-inbox.append-upsert.test-support.ts +133 -0
- package/src/monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.ts +418 -0
- package/src/monitor-inbox.captures-media-path-image-messages.test-support.ts +308 -0
- package/src/monitor-inbox.streams-inbound-messages.test-support.ts +824 -0
- package/src/normalize-target.ts +148 -0
- package/src/normalize.ts +8 -0
- package/src/outbound-adapter.ts +36 -0
- package/src/outbound-base.ts +256 -0
- package/src/outbound-media-contract.ts +307 -0
- package/src/outbound-media.runtime.ts +41 -0
- package/src/outbound-send-deps.ts +1 -0
- package/src/outbound-test-support.ts +16 -0
- package/src/qa-driver.runtime.ts +189 -0
- package/src/qr-image.ts +1 -0
- package/src/qr-terminal.ts +1 -0
- package/src/quoted-message.ts +184 -0
- package/src/reaction-level.ts +24 -0
- package/src/reconnect.ts +55 -0
- package/src/resolve-outbound-target.ts +58 -0
- package/src/runtime-api.ts +59 -0
- package/src/runtime-group-policy.ts +16 -0
- package/src/runtime.ts +9 -0
- package/src/security-contract.ts +47 -0
- package/src/security-fix.ts +71 -0
- package/src/send.ts +342 -0
- package/src/session-contract.ts +43 -0
- package/src/session-errors.ts +125 -0
- package/src/session-route.ts +32 -0
- package/src/session.runtime.ts +8 -0
- package/src/session.ts +327 -0
- package/src/setup-core.ts +52 -0
- package/src/setup-finalize.ts +450 -0
- package/src/setup-surface.ts +71 -0
- package/src/setup-test-helpers.ts +217 -0
- package/src/shared.ts +291 -0
- package/src/socket-timing.ts +38 -0
- package/src/state-migrations.ts +55 -0
- package/src/status-issues.ts +185 -0
- package/src/system-prompt.ts +31 -0
- package/src/targets-runtime.ts +221 -0
- package/src/text-runtime.ts +18 -0
- package/src/vcard.ts +84 -0
- package/targets.ts +5 -0
- package/test-api.ts +2 -0
- 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
|
+
}
|