@c4t4/heyamigo 0.10.1 → 0.10.3
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/README.md +17 -8
- package/config/access.example.json +12 -2
- package/config/config.example.json +17 -1
- package/config/memory-instructions.md +1 -1
- package/config/personalities/casual.md +1 -1
- package/config/personalities/professional.md +1 -1
- package/config/personalities/sharp.md +2 -2
- package/dist/ai/claude.js +1 -0
- package/dist/ai/codex.js +1 -0
- package/dist/ai/grok.js +310 -0
- package/dist/ai/provider.js +5 -5
- package/dist/ai/providers.js +2 -0
- package/dist/ai/sessions.js +5 -1
- package/dist/boot.js +15 -6
- package/dist/channels/index.js +2 -1
- package/dist/channels/runtime.js +1 -0
- package/dist/channels/telegram.js +393 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +168 -70
- package/dist/cli/start.js +25 -4
- package/dist/config.js +34 -1
- package/dist/db/address.js +13 -0
- package/dist/db/identity-sync.js +8 -0
- package/dist/gateway/bootstrap.js +15 -22
- package/dist/gateway/commands.js +13 -15
- package/dist/gateway/incoming.js +107 -254
- package/dist/gateway/ingest.js +240 -0
- package/dist/gateway/outgoing.js +3 -5
- package/dist/gateway/triggers.js +7 -40
- package/dist/memory/digest.js +5 -5
- package/dist/queue/async-tasks.js +11 -4
- package/dist/queue/browser-worker.js +5 -3
- package/dist/queue/cron-dispatch.js +6 -7
- package/dist/queue/job-address.js +4 -0
- package/dist/queue/outbound-postsend.js +4 -7
- package/dist/queue/worker.js +11 -5
- package/dist/wa/whitelist.js +40 -5
- package/package.json +3 -2
package/dist/gateway/incoming.js
CHANGED
|
@@ -1,23 +1,10 @@
|
|
|
1
|
-
import { unlink } from 'fs/promises';
|
|
2
1
|
import { getContentType, isJidGroup, jidDecode, jidNormalizedUser, } from 'baileys';
|
|
3
|
-
import { getProvider } from '../ai/providers.js';
|
|
4
|
-
import { getSession } from '../ai/sessions.js';
|
|
5
|
-
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
6
|
-
import { personIdForAddress } from '../db/identity-sync.js';
|
|
7
2
|
import { config } from '../config.js';
|
|
8
|
-
import {
|
|
3
|
+
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
9
4
|
import { logger } from '../logger.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { detectMediaType, downloadAndSave, getMediaSize, mediaPromptTag, } from '../store/media.js';
|
|
14
|
-
import { append } from '../store/messages.js';
|
|
15
|
-
import { getDailyTokens } from '../store/usage.js';
|
|
16
|
-
import { sendText } from '../wa/sender.js';
|
|
17
|
-
import { checkAccess, discoverGroupIfNew, getLimitsForUser, getRoleForContext, } from '../wa/whitelist.js';
|
|
18
|
-
import { buildInitPayload, buildRecentContext } from './bootstrap.js';
|
|
19
|
-
import { tryCommand } from './commands.js';
|
|
20
|
-
import { checkTrigger } from './triggers.js';
|
|
5
|
+
import { detectMediaType, downloadAndSave, getMediaSize, } from '../store/media.js';
|
|
6
|
+
import { discoverGroupIfNew } from '../wa/whitelist.js';
|
|
7
|
+
import { processIncomingMessage } from './ingest.js';
|
|
21
8
|
export function attachIncoming(sock) {
|
|
22
9
|
const ownerJid = sock.user?.id
|
|
23
10
|
? jidNormalizedUser(sock.user.id)
|
|
@@ -26,7 +13,7 @@ export function attachIncoming(sock) {
|
|
|
26
13
|
// Process ones within the age window through the normal pipeline.
|
|
27
14
|
sock.ev.on('messaging-history.set', ({ messages: historyMsgs }) => {
|
|
28
15
|
logger.info({ count: historyMsgs.length }, 'history sync received');
|
|
29
|
-
void processMessages(historyMsgs, sock, ownerJid);
|
|
16
|
+
void processMessages(historyMsgs, sock, ownerJid, true);
|
|
30
17
|
});
|
|
31
18
|
sock.ev.on('messages.upsert', async ({ messages, type }) => {
|
|
32
19
|
if (type !== 'notify' && type !== 'append')
|
|
@@ -37,230 +24,12 @@ export function attachIncoming(sock) {
|
|
|
37
24
|
async function processMessages(messages, sock, ownerJid, isHistorySync = false) {
|
|
38
25
|
for (const msg of messages) {
|
|
39
26
|
try {
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
42
|
-
continue;
|
|
43
|
-
// Age gate: skip messages older than maxMessageAgeMs
|
|
44
|
-
const ageMs = Date.now() - stored.timestamp * 1000;
|
|
45
|
-
if (ageMs > config.reply.maxMessageAgeMs) {
|
|
46
|
-
if (isHistorySync)
|
|
47
|
-
continue; // don't store ancient history
|
|
48
|
-
await append(stored);
|
|
49
|
-
logger.debug({ jid: stored.jid, ageMs: Math.floor(ageMs) }, 'message too old, stored silently');
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
const isGroup = stored.jid.endsWith('@g.us');
|
|
53
|
-
if (isGroup)
|
|
54
|
-
await discoverGroupIfNew(sock, stored.jid);
|
|
55
|
-
const decision = checkAccess({
|
|
56
|
-
jid: stored.jid,
|
|
57
|
-
isGroup,
|
|
58
|
-
senderNumber: stored.senderNumber,
|
|
59
|
-
fromMe: stored.fromMe,
|
|
60
|
-
});
|
|
61
|
-
const logCtx = {
|
|
62
|
-
jid: stored.jid,
|
|
63
|
-
from: stored.senderNumber || '(owner)',
|
|
64
|
-
fromMe: stored.fromMe,
|
|
65
|
-
type: stored.messageType,
|
|
66
|
-
text: stored.text.slice(0, 80),
|
|
67
|
-
decision: decision.reason,
|
|
68
|
-
};
|
|
69
|
-
if (!decision.store) {
|
|
70
|
-
logger.debug(logCtx, 'message dropped');
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
// File-size gate: refuse oversized media BEFORE downloading. Per-role
|
|
74
|
-
// cap; owner is always unlimited. If a file is too big we store the
|
|
75
|
-
// message (text/caption preserved for history), tell the user, and
|
|
76
|
-
// skip the Claude call — Claude would have no useful payload anyway.
|
|
77
|
-
const limits = getLimitsForUser(stored.senderNumber, isGroup);
|
|
78
|
-
const incomingMediaType = detectMediaType(msg);
|
|
79
|
-
if (incomingMediaType &&
|
|
80
|
-
limits.maxFileBytes !== null &&
|
|
81
|
-
decision.respond) {
|
|
82
|
-
const size = getMediaSize(msg);
|
|
83
|
-
if (size !== null && size > limits.maxFileBytes) {
|
|
84
|
-
await append(stored);
|
|
85
|
-
const quoted = isGroup && config.reply.quoteInGroups ? msg : undefined;
|
|
86
|
-
await sendText(sock, stored.jid, 'Could not process that, please try a smaller file.', quoted).catch((err) => logger.error({ err, jid: stored.jid }, 'failed to send oversized-file notice'));
|
|
87
|
-
logger.info({ ...logCtx, size, cap: limits.maxFileBytes }, 'oversized media rejected');
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
// Download media if present (image, video, audio, document)
|
|
92
|
-
const media = await downloadAndSave(msg, stored.jid);
|
|
93
|
-
// Post-download safety net: re-check against the real buffer size.
|
|
94
|
-
// Catches cases the pre-download gate missed — protobuf fileLength
|
|
95
|
-
// missing, nested in documentWithCaptionMessage, stickers, etc.
|
|
96
|
-
// Only enforced when we'd otherwise respond; silent groups keep the
|
|
97
|
-
// archive intact regardless of size.
|
|
98
|
-
if (media &&
|
|
99
|
-
limits.maxFileBytes !== null &&
|
|
100
|
-
decision.respond &&
|
|
101
|
-
media.bytes > limits.maxFileBytes) {
|
|
102
|
-
await unlink(media.mediaPath).catch(() => undefined);
|
|
103
|
-
await append(stored);
|
|
104
|
-
const quoted = isGroup && config.reply.quoteInGroups ? msg : undefined;
|
|
105
|
-
await sendText(sock, stored.jid, 'Could not process that, please try a smaller file.', quoted).catch((err) => logger.error({ err, jid: stored.jid }, 'failed to send oversized-file notice'));
|
|
106
|
-
logger.info({ ...logCtx, bytes: media.bytes, cap: limits.maxFileBytes }, 'oversized media rejected (post-download)');
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
if (media) {
|
|
110
|
-
stored.mediaType = media.mediaType;
|
|
111
|
-
stored.mediaPath = media.mediaPath;
|
|
112
|
-
stored.mediaMime = media.mediaMime;
|
|
113
|
-
}
|
|
114
|
-
await append(stored);
|
|
115
|
-
if (!decision.respond) {
|
|
116
|
-
logger.info(logCtx, 'message captured, silent');
|
|
27
|
+
const incoming = await toIncoming(msg, ownerJid, sock);
|
|
28
|
+
if (!incoming)
|
|
117
29
|
continue;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
logger.debug(logCtx, 'message captured, respond skipped (empty)');
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
// Commands short-circuit the AI pipeline (always, regardless of trigger mode)
|
|
125
|
-
const isCommand = await tryCommand({
|
|
126
|
-
sock,
|
|
127
|
-
jid: stored.jid,
|
|
128
|
-
text: stored.text,
|
|
129
|
-
senderNumber: stored.senderNumber,
|
|
130
|
-
quoted: isGroup && config.reply.quoteInGroups ? msg : undefined,
|
|
131
|
-
});
|
|
132
|
-
if (isCommand) {
|
|
133
|
-
logger.info(logCtx, 'command handled');
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
// Self-chat: owner messaging themselves — always trigger
|
|
137
|
-
const isSelfChat = stored.fromMe && !isGroup &&
|
|
138
|
-
jidDecode(stored.jid)?.user === config.owner.number;
|
|
139
|
-
// Trigger gate: alias / @mention / reply-to-bot depending on mode
|
|
140
|
-
let triggerReason = isSelfChat ? 'self-chat' : '';
|
|
141
|
-
if (!isSelfChat) {
|
|
142
|
-
const trigger = checkTrigger({
|
|
143
|
-
isGroup,
|
|
144
|
-
text: stored.text,
|
|
145
|
-
msg,
|
|
146
|
-
sock,
|
|
147
|
-
});
|
|
148
|
-
if (!trigger.triggered) {
|
|
149
|
-
logger.info({ ...logCtx, trigger: trigger.reason }, 'message captured, no trigger');
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
triggerReason = trigger.reason;
|
|
153
|
-
}
|
|
154
|
-
// Daily token cap: silent drop once the user has burned their budget
|
|
155
|
-
// for the day. Owner is exempt (limits.dailyTokenLimit is null).
|
|
156
|
-
if (limits.dailyTokenLimit !== null) {
|
|
157
|
-
const used = getDailyTokens(stored.senderNumber);
|
|
158
|
-
if (used >= limits.dailyTokenLimit) {
|
|
159
|
-
logger.info({
|
|
160
|
-
...logCtx,
|
|
161
|
-
used,
|
|
162
|
-
cap: limits.dailyTokenLimit,
|
|
163
|
-
trigger: triggerReason,
|
|
164
|
-
}, 'daily token quota exhausted, silent drop');
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
const { role } = getRoleForContext(stored.senderNumber, isGroup);
|
|
169
|
-
const existingSession = getSession(stored.jid, getProvider().name);
|
|
170
|
-
let userContent = stored.text;
|
|
171
|
-
if (media) {
|
|
172
|
-
const tag = mediaPromptTag(media, stored.text);
|
|
173
|
-
userContent = tag;
|
|
174
|
-
}
|
|
175
|
-
const recentText = stored.text;
|
|
176
|
-
const memoryPreamble = buildMemoryPreamble({
|
|
177
|
-
jid: stored.jid,
|
|
178
|
-
senderNumber: stored.senderNumber,
|
|
179
|
-
isGroup,
|
|
180
|
-
recentText,
|
|
181
|
-
});
|
|
182
|
-
let core;
|
|
183
|
-
if (existingSession) {
|
|
184
|
-
const recent = await buildRecentContext(stored.jid, config.bootstrap.recentContextDepth);
|
|
185
|
-
const current = `[Current message]\n${stored.senderNumber}: ${userContent}`;
|
|
186
|
-
core = recent ? `${recent}\n${current}` : userContent;
|
|
187
|
-
}
|
|
188
|
-
else {
|
|
189
|
-
core = await buildInitPayload({
|
|
190
|
-
jid: stored.jid,
|
|
191
|
-
sock,
|
|
192
|
-
userText: userContent,
|
|
193
|
-
userNumber: stored.senderNumber,
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
const input = `${memoryPreamble}\n\n---\n\n${core}`;
|
|
197
|
-
logger.info({ ...logCtx, resume: !!existingSession, trigger: triggerReason }, 'message captured, enqueuing');
|
|
198
|
-
const job = {
|
|
199
|
-
jid: stored.jid,
|
|
200
|
-
text: stored.text,
|
|
201
|
-
input,
|
|
202
|
-
sessionId: existingSession,
|
|
203
|
-
senderNumber: stored.senderNumber,
|
|
204
|
-
fromMe: stored.fromMe,
|
|
205
|
-
allowedTools: role.tools,
|
|
206
|
-
allowedTags: role.tags,
|
|
207
|
-
};
|
|
208
|
-
// Enqueue into the inbound table; chat worker pool drains and
|
|
209
|
-
// calls processJob + handleReply asynchronously. Typing indicator
|
|
210
|
-
// is temporarily dropped (was tied to the old synchronous flow);
|
|
211
|
-
// re-add via ChannelAdapter.sendTyping() in a follow-up commit.
|
|
212
|
-
const chatAddress = formatAddress(jidToAddress(stored.jid));
|
|
213
|
-
const senderAddress = stored.senderNumber
|
|
214
|
-
? formatAddress(jidToAddress(`${stored.senderNumber}@s.whatsapp.net`))
|
|
215
|
-
: null;
|
|
216
|
-
const personId = personIdForAddress(chatAddress);
|
|
217
|
-
const actorPersonId = senderAddress
|
|
218
|
-
? personIdForAddress(senderAddress)
|
|
219
|
-
: null;
|
|
220
|
-
// Estimator: classify this message and, when a kind matches,
|
|
221
|
-
// (a) tag the inbound row so future estimates of the same kind
|
|
222
|
-
// get a fresh sample, and (b) send the estimate text as an
|
|
223
|
-
// immediate ack so the user sees a timeline before the agent
|
|
224
|
-
// even starts.
|
|
225
|
-
const est = estimateJob({
|
|
226
|
-
description: stored.text,
|
|
227
|
-
attachments: media ? [{ kind: media.mediaType }] : undefined,
|
|
228
|
-
senderPersonId: actorPersonId ?? undefined,
|
|
229
|
-
});
|
|
230
|
-
const jobKind = est?.kind ?? null;
|
|
231
|
-
if (est) {
|
|
232
|
-
enqueueOutbound({
|
|
233
|
-
address: chatAddress,
|
|
234
|
-
kind: 'text',
|
|
235
|
-
text: est.text,
|
|
236
|
-
idempotencyKey: `estimate-${msg.key.id}`,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
else if (media && config.reply.ackOnMedia !== false) {
|
|
240
|
-
// Fallback media-ack when no estimator matched — keeps the
|
|
241
|
-
// pre-estimator behavior so image messages still get the
|
|
242
|
-
// "looking…" hint. A future MediaIncomingEstimator can replace
|
|
243
|
-
// this with a real average.
|
|
244
|
-
enqueueOutbound({
|
|
245
|
-
address: chatAddress,
|
|
246
|
-
kind: 'text',
|
|
247
|
-
text: config.reply.mediaAckText,
|
|
248
|
-
idempotencyKey: `media-ack-${msg.key.id}`,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
enqueueInbound({
|
|
252
|
-
address: chatAddress,
|
|
253
|
-
actorAddress: senderAddress,
|
|
254
|
-
personId,
|
|
255
|
-
actorPersonId,
|
|
256
|
-
externalMsgId: msg.key.id ?? null,
|
|
257
|
-
text: stored.text,
|
|
258
|
-
pushName: stored.pushName ?? null,
|
|
259
|
-
triggerReason,
|
|
260
|
-
kind: jobKind,
|
|
261
|
-
receivedAt: stored.timestamp,
|
|
262
|
-
payload: job,
|
|
263
|
-
});
|
|
30
|
+
if (incoming.isGroup)
|
|
31
|
+
await discoverGroupIfNew(sock, incoming.accessKey);
|
|
32
|
+
await processIncomingMessage(incoming, { isHistorySync });
|
|
264
33
|
}
|
|
265
34
|
catch (err) {
|
|
266
35
|
logger.error({ err, msgId: msg.key.id }, 'failed to process incoming message');
|
|
@@ -278,7 +47,7 @@ async function resolveToPn(sock, jid) {
|
|
|
278
47
|
return jid;
|
|
279
48
|
}
|
|
280
49
|
}
|
|
281
|
-
async function
|
|
50
|
+
async function toIncoming(msg, ownerJid, sock) {
|
|
282
51
|
const rawJid = msg.key.remoteJid;
|
|
283
52
|
if (!rawJid)
|
|
284
53
|
return null;
|
|
@@ -288,8 +57,8 @@ async function toStored(msg, ownerJid, sock) {
|
|
|
288
57
|
return null;
|
|
289
58
|
const fromMe = !!msg.key.fromMe;
|
|
290
59
|
const isGroup = isJidGroup(rawJid) === true;
|
|
291
|
-
//
|
|
292
|
-
// drop device
|
|
60
|
+
// Canonicalize chat jid: groups stay as @g.us, DMs preferred as
|
|
61
|
+
// @s.whatsapp.net, drop device suffixes so devices merge.
|
|
293
62
|
const jid = isGroup
|
|
294
63
|
? jidNormalizedUser(rawJid)
|
|
295
64
|
: jidNormalizedUser(await resolveToPn(sock, rawJid));
|
|
@@ -307,19 +76,103 @@ async function toStored(msg, ownerJid, sock) {
|
|
|
307
76
|
const senderNumber = jidDecode(sender)?.user ?? '';
|
|
308
77
|
const messageType = getContentType(msg.message) ?? 'unknown';
|
|
309
78
|
const text = extractText(msg.message);
|
|
79
|
+
const timestamp = typeof msg.messageTimestamp === 'number'
|
|
80
|
+
? msg.messageTimestamp
|
|
81
|
+
: Number(msg.messageTimestamp ?? 0);
|
|
82
|
+
const msgId = msg.key.id ?? `${jid}-${timestamp}`;
|
|
83
|
+
const mediaType = detectMediaType(msg);
|
|
310
84
|
return {
|
|
311
|
-
id:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
85
|
+
id: msgId,
|
|
86
|
+
externalMsgId: `wa:${msgId}`,
|
|
87
|
+
channel: 'wa',
|
|
88
|
+
address: formatAddress(jidToAddress(jid)),
|
|
89
|
+
chatKey: jid,
|
|
90
|
+
accessKey: jid,
|
|
91
|
+
actorAddress: senderNumber
|
|
92
|
+
? formatAddress(jidToAddress(`${senderNumber}@s.whatsapp.net`))
|
|
93
|
+
: null,
|
|
94
|
+
senderKey: senderNumber,
|
|
95
|
+
senderLabel: msg.pushName ?? undefined,
|
|
96
|
+
timestamp,
|
|
321
97
|
text,
|
|
98
|
+
fromMe,
|
|
99
|
+
isGroup,
|
|
322
100
|
messageType,
|
|
101
|
+
mediaType,
|
|
102
|
+
mediaBytes: mediaType ? getMediaSize(msg) : null,
|
|
103
|
+
downloadMedia: mediaType ? () => downloadAndSave(msg, jid) : undefined,
|
|
104
|
+
quoteMsgId: msg.key.id ?? null,
|
|
105
|
+
triggerHints: waTriggerHints(msg, sock),
|
|
106
|
+
selfChat: fromMe &&
|
|
107
|
+
!isGroup &&
|
|
108
|
+
jidDecode(jid)?.user === config.owner.number,
|
|
109
|
+
loadChatMetadata: () => loadWaChatMetadata(sock, jid, isGroup),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function contextInfo(message) {
|
|
113
|
+
return (message.extendedTextMessage?.contextInfo ??
|
|
114
|
+
message.imageMessage?.contextInfo ??
|
|
115
|
+
message.videoMessage?.contextInfo ??
|
|
116
|
+
message.audioMessage?.contextInfo ??
|
|
117
|
+
message.documentMessage?.contextInfo ??
|
|
118
|
+
message.documentWithCaptionMessage?.message?.documentMessage?.contextInfo ??
|
|
119
|
+
message.stickerMessage?.contextInfo);
|
|
120
|
+
}
|
|
121
|
+
function ownerNumbers(sock) {
|
|
122
|
+
const out = new Set();
|
|
123
|
+
if (config.owner.number)
|
|
124
|
+
out.add(config.owner.number);
|
|
125
|
+
const pn = sock.user?.id ? jidDecode(sock.user.id)?.user : undefined;
|
|
126
|
+
if (pn)
|
|
127
|
+
out.add(pn);
|
|
128
|
+
const lid = sock.user?.lid ? jidDecode(sock.user.lid)?.user : undefined;
|
|
129
|
+
if (lid)
|
|
130
|
+
out.add(lid);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
function waTriggerHints(msg, sock) {
|
|
134
|
+
const ci = msg.message ? contextInfo(msg.message) : undefined;
|
|
135
|
+
if (!ci)
|
|
136
|
+
return {};
|
|
137
|
+
const owners = ownerNumbers(sock);
|
|
138
|
+
let mentionedBot = false;
|
|
139
|
+
for (const m of ci.mentionedJid ?? []) {
|
|
140
|
+
const user = jidDecode(m)?.user;
|
|
141
|
+
if (user && owners.has(user)) {
|
|
142
|
+
mentionedBot = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
let replyToBot = false;
|
|
147
|
+
const quotedParticipant = ci.participant;
|
|
148
|
+
if (quotedParticipant) {
|
|
149
|
+
const user = jidDecode(quotedParticipant)?.user;
|
|
150
|
+
replyToBot = !!user && owners.has(user);
|
|
151
|
+
}
|
|
152
|
+
return { mentionedBot, replyToBot };
|
|
153
|
+
}
|
|
154
|
+
async function loadWaChatMetadata(sock, jid, isGroup) {
|
|
155
|
+
if (!isGroup) {
|
|
156
|
+
return { platform: 'WhatsApp', isGroup, externalId: jid };
|
|
157
|
+
}
|
|
158
|
+
let chatName = 'unknown';
|
|
159
|
+
let memberSummary = '';
|
|
160
|
+
try {
|
|
161
|
+
const meta = await sock.groupMetadata(jid);
|
|
162
|
+
chatName = meta.subject || chatName;
|
|
163
|
+
if (meta.participants?.length) {
|
|
164
|
+
memberSummary = `${meta.participants.length} participants`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
logger.warn({ err, jid }, 'group metadata fetch failed in bootstrap');
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
platform: 'WhatsApp',
|
|
172
|
+
isGroup,
|
|
173
|
+
chatName,
|
|
174
|
+
memberSummary,
|
|
175
|
+
externalId: jid,
|
|
323
176
|
};
|
|
324
177
|
}
|
|
325
178
|
function extractText(message) {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { unlink } from 'fs/promises';
|
|
2
|
+
import { getProvider } from '../ai/providers.js';
|
|
3
|
+
import { getSession } from '../ai/sessions.js';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { personIdForAddress } from '../db/identity-sync.js';
|
|
6
|
+
import { estimate as estimateJob } from '../estimates/index.js';
|
|
7
|
+
import { logger } from '../logger.js';
|
|
8
|
+
import { buildMemoryPreamble } from '../memory/preamble.js';
|
|
9
|
+
import { enqueueInbound } from '../queue/inbound.js';
|
|
10
|
+
import { enqueueOutbound } from '../queue/outbound.js';
|
|
11
|
+
import { mediaPromptTag } from '../store/media.js';
|
|
12
|
+
import { append } from '../store/messages.js';
|
|
13
|
+
import { getDailyTokens } from '../store/usage.js';
|
|
14
|
+
import { checkAccess, discoverAddressGroupIfNew, getLimitsForUser, getRoleForContext, } from '../wa/whitelist.js';
|
|
15
|
+
import { buildInitPayload, buildRecentContext } from './bootstrap.js';
|
|
16
|
+
import { tryCommand } from './commands.js';
|
|
17
|
+
import { checkTrigger } from './triggers.js';
|
|
18
|
+
function toStored(incoming) {
|
|
19
|
+
return {
|
|
20
|
+
id: incoming.id,
|
|
21
|
+
jid: incoming.chatKey,
|
|
22
|
+
direction: incoming.fromMe ? 'out' : 'in',
|
|
23
|
+
fromMe: incoming.fromMe,
|
|
24
|
+
sender: incoming.actorAddress ?? incoming.senderKey,
|
|
25
|
+
senderNumber: incoming.senderKey,
|
|
26
|
+
pushName: incoming.senderLabel,
|
|
27
|
+
timestamp: incoming.timestamp,
|
|
28
|
+
text: incoming.text,
|
|
29
|
+
messageType: incoming.messageType,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function enqueueTextReply(incoming, text, idempotencyKey) {
|
|
33
|
+
enqueueOutbound({
|
|
34
|
+
address: incoming.address,
|
|
35
|
+
kind: 'text',
|
|
36
|
+
text,
|
|
37
|
+
quoteMsgId: incoming.isGroup && config.reply.quoteInGroups
|
|
38
|
+
? incoming.quoteMsgId ?? undefined
|
|
39
|
+
: undefined,
|
|
40
|
+
idempotencyKey,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function processIncomingMessage(incoming, opts = {}) {
|
|
44
|
+
const stored = toStored(incoming);
|
|
45
|
+
const ageMs = Date.now() - stored.timestamp * 1000;
|
|
46
|
+
if (ageMs > config.reply.maxMessageAgeMs) {
|
|
47
|
+
if (opts.isHistorySync)
|
|
48
|
+
return;
|
|
49
|
+
await append(stored);
|
|
50
|
+
logger.debug({ jid: stored.jid, address: incoming.address, ageMs: Math.floor(ageMs) }, 'message too old, stored silently');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (incoming.isGroup && incoming.channel !== 'wa') {
|
|
54
|
+
await discoverAddressGroupIfNew({
|
|
55
|
+
address: incoming.address,
|
|
56
|
+
name: incoming.chat?.chatName,
|
|
57
|
+
ownerSender: stored.senderNumber || config.owner.number || undefined,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const decision = checkAccess({
|
|
61
|
+
jid: incoming.accessKey,
|
|
62
|
+
address: incoming.address,
|
|
63
|
+
isGroup: incoming.isGroup,
|
|
64
|
+
senderNumber: stored.senderNumber,
|
|
65
|
+
fromMe: stored.fromMe,
|
|
66
|
+
});
|
|
67
|
+
const logCtx = {
|
|
68
|
+
jid: stored.jid,
|
|
69
|
+
address: incoming.address,
|
|
70
|
+
from: stored.senderNumber || '(owner)',
|
|
71
|
+
fromMe: stored.fromMe,
|
|
72
|
+
type: stored.messageType,
|
|
73
|
+
text: stored.text.slice(0, 80),
|
|
74
|
+
decision: decision.reason,
|
|
75
|
+
};
|
|
76
|
+
if (!decision.store) {
|
|
77
|
+
logger.debug(logCtx, 'message dropped');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const limits = getLimitsForUser(stored.senderNumber, incoming.isGroup);
|
|
81
|
+
if (incoming.mediaType &&
|
|
82
|
+
limits.maxFileBytes !== null &&
|
|
83
|
+
decision.respond &&
|
|
84
|
+
incoming.mediaBytes !== null &&
|
|
85
|
+
incoming.mediaBytes !== undefined &&
|
|
86
|
+
incoming.mediaBytes > limits.maxFileBytes) {
|
|
87
|
+
await append(stored);
|
|
88
|
+
enqueueTextReply(incoming, 'Could not process that, please try a smaller file.', `oversized-${incoming.externalMsgId}`);
|
|
89
|
+
logger.info({ ...logCtx, size: incoming.mediaBytes, cap: limits.maxFileBytes }, 'oversized media rejected');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let media = null;
|
|
93
|
+
if (incoming.mediaType && incoming.downloadMedia) {
|
|
94
|
+
media = await incoming.downloadMedia();
|
|
95
|
+
}
|
|
96
|
+
if (media &&
|
|
97
|
+
limits.maxFileBytes !== null &&
|
|
98
|
+
decision.respond &&
|
|
99
|
+
media.bytes > limits.maxFileBytes) {
|
|
100
|
+
await unlink(media.mediaPath).catch(() => undefined);
|
|
101
|
+
await append(stored);
|
|
102
|
+
enqueueTextReply(incoming, 'Could not process that, please try a smaller file.', `oversized-downloaded-${incoming.externalMsgId}`);
|
|
103
|
+
logger.info({ ...logCtx, bytes: media.bytes, cap: limits.maxFileBytes }, 'oversized media rejected (post-download)');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (media) {
|
|
107
|
+
stored.mediaType = media.mediaType;
|
|
108
|
+
stored.mediaPath = media.mediaPath;
|
|
109
|
+
stored.mediaMime = media.mediaMime;
|
|
110
|
+
}
|
|
111
|
+
await append(stored);
|
|
112
|
+
if (!decision.respond) {
|
|
113
|
+
logger.info(logCtx, 'message captured, silent');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!stored.text.trim() && !media) {
|
|
117
|
+
logger.debug(logCtx, 'message captured, respond skipped (empty)');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const isCommand = await tryCommand({
|
|
121
|
+
jid: stored.jid,
|
|
122
|
+
address: incoming.address,
|
|
123
|
+
text: stored.text,
|
|
124
|
+
senderNumber: stored.senderNumber,
|
|
125
|
+
reply: async (text) => enqueueTextReply(incoming, text, `command-${incoming.externalMsgId}`),
|
|
126
|
+
});
|
|
127
|
+
if (isCommand) {
|
|
128
|
+
logger.info(logCtx, 'command handled');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let triggerReason = incoming.selfChat ? 'self-chat' : '';
|
|
132
|
+
if (!incoming.selfChat) {
|
|
133
|
+
const trigger = checkTrigger({
|
|
134
|
+
isGroup: incoming.isGroup,
|
|
135
|
+
text: stored.text,
|
|
136
|
+
mentionedBot: incoming.triggerHints?.mentionedBot,
|
|
137
|
+
replyToBot: incoming.triggerHints?.replyToBot,
|
|
138
|
+
});
|
|
139
|
+
if (!trigger.triggered) {
|
|
140
|
+
logger.info({ ...logCtx, trigger: trigger.reason }, 'message captured, no trigger');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
triggerReason = trigger.reason;
|
|
144
|
+
}
|
|
145
|
+
if (limits.dailyTokenLimit !== null) {
|
|
146
|
+
const used = getDailyTokens(stored.senderNumber);
|
|
147
|
+
if (used >= limits.dailyTokenLimit) {
|
|
148
|
+
logger.info({
|
|
149
|
+
...logCtx,
|
|
150
|
+
used,
|
|
151
|
+
cap: limits.dailyTokenLimit,
|
|
152
|
+
trigger: triggerReason,
|
|
153
|
+
}, 'daily token quota exhausted, silent drop');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const { role } = getRoleForContext(stored.senderNumber, incoming.isGroup);
|
|
158
|
+
const existingSession = getSession(stored.jid, getProvider().name);
|
|
159
|
+
let userContent = stored.text;
|
|
160
|
+
if (media) {
|
|
161
|
+
userContent = mediaPromptTag(media, stored.text);
|
|
162
|
+
}
|
|
163
|
+
const memoryPreamble = buildMemoryPreamble({
|
|
164
|
+
jid: stored.jid,
|
|
165
|
+
senderNumber: stored.senderNumber,
|
|
166
|
+
isGroup: incoming.isGroup,
|
|
167
|
+
recentText: stored.text,
|
|
168
|
+
});
|
|
169
|
+
let core;
|
|
170
|
+
if (existingSession) {
|
|
171
|
+
const recent = await buildRecentContext(stored.jid, config.bootstrap.recentContextDepth);
|
|
172
|
+
const current = `[Current message]\n${stored.senderNumber}: ${userContent}`;
|
|
173
|
+
core = recent ? `${recent}\n${current}` : userContent;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
const chat = incoming.chat ?? await incoming.loadChatMetadata?.();
|
|
177
|
+
core = await buildInitPayload({
|
|
178
|
+
jid: stored.jid,
|
|
179
|
+
userText: userContent,
|
|
180
|
+
userNumber: stored.senderNumber,
|
|
181
|
+
chat,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const input = `${memoryPreamble}\n\n---\n\n${core}`;
|
|
185
|
+
logger.info({ ...logCtx, resume: !!existingSession, trigger: triggerReason }, 'message captured, enqueuing');
|
|
186
|
+
const job = {
|
|
187
|
+
jid: stored.jid,
|
|
188
|
+
address: incoming.address,
|
|
189
|
+
actorAddress: incoming.actorAddress,
|
|
190
|
+
text: stored.text,
|
|
191
|
+
input,
|
|
192
|
+
sessionId: existingSession,
|
|
193
|
+
senderNumber: stored.senderNumber,
|
|
194
|
+
fromMe: stored.fromMe,
|
|
195
|
+
allowedTools: role.tools,
|
|
196
|
+
allowedTags: role.tags,
|
|
197
|
+
};
|
|
198
|
+
const personId = personIdForAddress(incoming.address);
|
|
199
|
+
const actorPersonId = incoming.actorAddress
|
|
200
|
+
? personIdForAddress(incoming.actorAddress)
|
|
201
|
+
: null;
|
|
202
|
+
const est = estimateJob({
|
|
203
|
+
description: stored.text,
|
|
204
|
+
attachments: media ? [{ kind: media.mediaType }] : undefined,
|
|
205
|
+
senderPersonId: actorPersonId ?? undefined,
|
|
206
|
+
});
|
|
207
|
+
const jobKind = est?.kind ?? null;
|
|
208
|
+
if (est) {
|
|
209
|
+
enqueueOutbound({
|
|
210
|
+
address: incoming.address,
|
|
211
|
+
kind: 'text',
|
|
212
|
+
text: est.text,
|
|
213
|
+
idempotencyKey: `estimate-${incoming.externalMsgId}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
else if (media && config.reply.ackOnMedia !== false) {
|
|
217
|
+
enqueueOutbound({
|
|
218
|
+
address: incoming.address,
|
|
219
|
+
kind: 'text',
|
|
220
|
+
text: config.reply.mediaAckText,
|
|
221
|
+
idempotencyKey: `media-ack-${incoming.externalMsgId}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
enqueueInbound({
|
|
225
|
+
address: incoming.address,
|
|
226
|
+
actorAddress: incoming.actorAddress,
|
|
227
|
+
personId,
|
|
228
|
+
actorPersonId,
|
|
229
|
+
externalMsgId: incoming.externalMsgId,
|
|
230
|
+
text: stored.text,
|
|
231
|
+
mediaPath: media?.mediaPath ?? null,
|
|
232
|
+
mediaMime: media?.mediaMime ?? null,
|
|
233
|
+
mediaBytes: media?.bytes ?? null,
|
|
234
|
+
pushName: stored.pushName ?? null,
|
|
235
|
+
triggerReason,
|
|
236
|
+
kind: jobKind,
|
|
237
|
+
receivedAt: stored.timestamp,
|
|
238
|
+
payload: job,
|
|
239
|
+
});
|
|
240
|
+
}
|
package/dist/gateway/outgoing.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'fs';
|
|
2
2
|
import { extname } from 'path';
|
|
3
|
-
import { isJidGroup } from 'baileys';
|
|
4
3
|
import { config } from '../config.js';
|
|
5
4
|
import { formatAddress, jidToAddress } from '../db/address.js';
|
|
6
5
|
import { logger } from '../logger.js';
|
|
6
|
+
import { addressForJob } from '../queue/job-address.js';
|
|
7
7
|
import { enqueueOutbound } from '../queue/outbound.js';
|
|
8
8
|
import { detectMediaType } from '../wa/sender.js';
|
|
9
9
|
// Matches [FILE: path], [IMAGE: path], [VIDEO: path], [AUDIO: path], [DOCUMENT: path]
|
|
@@ -60,9 +60,7 @@ export async function handleReply(job, result, _originalMsg) {
|
|
|
60
60
|
if (!raw)
|
|
61
61
|
return;
|
|
62
62
|
const { text, files } = extractFiles(raw);
|
|
63
|
-
const
|
|
64
|
-
void isGroup; // quoting deferred; see comment above
|
|
65
|
-
const address = formatAddress(jidToAddress(job.jid));
|
|
63
|
+
const address = addressForJob(job);
|
|
66
64
|
// Surface media tags in the footer too. Files already parsed above
|
|
67
65
|
// — just map each to its kind so the footer reads e.g. "+2 image".
|
|
68
66
|
const mediaKinds = files.map(kindForFile);
|
|
@@ -142,7 +140,7 @@ export async function initiate(params) {
|
|
|
142
140
|
const { text, files } = extractFiles(raw);
|
|
143
141
|
if (!text && files.length === 0)
|
|
144
142
|
return false;
|
|
145
|
-
const address = formatAddress(jidToAddress(params.jid));
|
|
143
|
+
const address = params.address ?? formatAddress(jidToAddress(params.jid));
|
|
146
144
|
let pieceIdx = 0;
|
|
147
145
|
const baseKey = `initiate-${params.jid}-${Date.now()}`;
|
|
148
146
|
const enqueuePiece = (input) => {
|