@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.
@@ -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 { estimate as estimateJob } from '../estimates/index.js';
3
+ import { formatAddress, jidToAddress } from '../db/address.js';
9
4
  import { logger } from '../logger.js';
10
- import { buildMemoryPreamble } from '../memory/preamble.js';
11
- import { enqueueInbound } from '../queue/inbound.js';
12
- import { enqueueOutbound } from '../queue/outbound.js';
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 stored = await toStored(msg, ownerJid, sock);
41
- if (!stored)
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
- // Need either text or media to respond
120
- if (!stored.text.trim() && !media) {
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 toStored(msg, ownerJid, sock) {
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
- // canonicalize chat jid: groups stay as @g.us, DMs preferred as @s.whatsapp.net,
292
- // drop device suffix (e.g. ":19") so chats from different devices merge
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: msg.key.id ?? '',
312
- jid,
313
- direction: fromMe ? 'out' : 'in',
314
- fromMe,
315
- sender,
316
- senderNumber,
317
- pushName: msg.pushName ?? undefined,
318
- timestamp: typeof msg.messageTimestamp === 'number'
319
- ? msg.messageTimestamp
320
- : Number(msg.messageTimestamp ?? 0),
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
+ }
@@ -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 isGroup = isJidGroup(job.jid) === true;
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) => {