@diegoaltoworks/chatter 0.45.0 → 0.48.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 (120) hide show
  1. package/README.md +19 -5
  2. package/dist/auth/apikeys.d.ts.map +1 -1
  3. package/dist/auth/secretStrength.d.ts +8 -0
  4. package/dist/auth/secretStrength.d.ts.map +1 -0
  5. package/dist/bin/create-apikey.js +22 -3
  6. package/dist/bin/wa-pair.js +59 -11
  7. package/dist/channels/gates.d.ts +9 -0
  8. package/dist/channels/gates.d.ts.map +1 -1
  9. package/dist/channels/index.d.ts +1 -0
  10. package/dist/channels/index.d.ts.map +1 -1
  11. package/dist/channels/index.js +252 -8
  12. package/dist/channels/index.mjs +250 -8
  13. package/dist/channels/pipeline.d.ts +106 -0
  14. package/dist/channels/pipeline.d.ts.map +1 -0
  15. package/dist/channels/senders.d.ts +2 -1
  16. package/dist/channels/senders.d.ts.map +1 -1
  17. package/dist/channels/whatsapp/channel.d.ts +4 -0
  18. package/dist/channels/whatsapp/channel.d.ts.map +1 -1
  19. package/dist/channels/whatsapp/crypto.d.ts +17 -3
  20. package/dist/channels/whatsapp/crypto.d.ts.map +1 -1
  21. package/dist/channels/whatsapp/inbound.d.ts +34 -4
  22. package/dist/channels/whatsapp/inbound.d.ts.map +1 -1
  23. package/dist/channels/whatsapp/index.d.ts +2 -1
  24. package/dist/channels/whatsapp/index.d.ts.map +1 -1
  25. package/dist/channels/whatsapp/index.js +395 -159
  26. package/dist/channels/whatsapp/index.mjs +394 -160
  27. package/dist/channels/whatsapp/router.d.ts +107 -0
  28. package/dist/channels/whatsapp/router.d.ts.map +1 -0
  29. package/dist/core/answer.d.ts +5 -2
  30. package/dist/core/answer.d.ts.map +1 -1
  31. package/dist/core/logger.d.ts +23 -0
  32. package/dist/core/logger.d.ts.map +1 -0
  33. package/dist/core/messages.d.ts +8 -0
  34. package/dist/core/messages.d.ts.map +1 -1
  35. package/dist/core/pipeline.d.ts +2 -0
  36. package/dist/core/pipeline.d.ts.map +1 -1
  37. package/dist/core/retrieval.d.ts +4 -0
  38. package/dist/core/retrieval.d.ts.map +1 -1
  39. package/dist/core/serve-static.d.ts +38 -0
  40. package/dist/core/serve-static.d.ts.map +1 -0
  41. package/dist/flows/engine.d.ts +3 -0
  42. package/dist/flows/engine.d.ts.map +1 -1
  43. package/dist/flows/index.d.ts +1 -0
  44. package/dist/flows/index.d.ts.map +1 -1
  45. package/dist/flows/index.js +91 -25
  46. package/dist/flows/index.mjs +91 -26
  47. package/dist/flows/intent.d.ts +2 -1
  48. package/dist/flows/intent.d.ts.map +1 -1
  49. package/dist/flows/loader.d.ts +5 -3
  50. package/dist/flows/loader.d.ts.map +1 -1
  51. package/dist/flows/types.d.ts +9 -0
  52. package/dist/flows/types.d.ts.map +1 -1
  53. package/dist/history/index.d.ts +17 -0
  54. package/dist/history/index.d.ts.map +1 -0
  55. package/dist/history/index.js +100 -0
  56. package/dist/history/index.mjs +73 -0
  57. package/dist/history/tursoStore.d.ts +26 -0
  58. package/dist/history/tursoStore.d.ts.map +1 -0
  59. package/dist/history/types.d.ts +18 -0
  60. package/dist/history/types.d.ts.map +1 -0
  61. package/dist/index.d.ts +5 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +1202 -32462
  64. package/dist/index.mjs +1118 -32414
  65. package/dist/mcp-server/cost-tracker.d.ts +11 -6
  66. package/dist/mcp-server/cost-tracker.d.ts.map +1 -1
  67. package/dist/mcp-server/logger.d.ts +5 -2
  68. package/dist/mcp-server/logger.d.ts.map +1 -1
  69. package/dist/mcp-server/sdk.d.ts +23 -0
  70. package/dist/mcp-server/sdk.d.ts.map +1 -0
  71. package/dist/mcp-server/tool-input.d.ts +12 -0
  72. package/dist/mcp-server/tool-input.d.ts.map +1 -0
  73. package/dist/mcp-server/types.d.ts +10 -3
  74. package/dist/mcp-server/types.d.ts.map +1 -1
  75. package/dist/mcp-server.d.ts +1 -2
  76. package/dist/mcp-server.d.ts.map +1 -1
  77. package/dist/mcp-server.js +192 -111
  78. package/dist/mcp-server.mjs +179 -98
  79. package/dist/middleware/auth.d.ts.map +1 -1
  80. package/dist/middleware/bodyLimit.d.ts +11 -0
  81. package/dist/middleware/bodyLimit.d.ts.map +1 -0
  82. package/dist/middleware/clientKey.d.ts +15 -0
  83. package/dist/middleware/clientKey.d.ts.map +1 -0
  84. package/dist/middleware/cors.d.ts.map +1 -1
  85. package/dist/middleware/jwt.d.ts +0 -3
  86. package/dist/middleware/jwt.d.ts.map +1 -1
  87. package/dist/middleware/originMatch.d.ts +27 -0
  88. package/dist/middleware/originMatch.d.ts.map +1 -0
  89. package/dist/middleware/ratelimit.d.ts +0 -6
  90. package/dist/middleware/ratelimit.d.ts.map +1 -1
  91. package/dist/middleware/referrer.d.ts +5 -1
  92. package/dist/middleware/referrer.d.ts.map +1 -1
  93. package/dist/middleware/securityHeaders.d.ts +20 -0
  94. package/dist/middleware/securityHeaders.d.ts.map +1 -0
  95. package/dist/middleware/session.d.ts +2 -1
  96. package/dist/middleware/session.d.ts.map +1 -1
  97. package/dist/middleware/windowBucket.d.ts +19 -0
  98. package/dist/middleware/windowBucket.d.ts.map +1 -0
  99. package/dist/personas/index.js +20 -1
  100. package/dist/personas/index.mjs +20 -1
  101. package/dist/personas/resolver.d.ts +3 -0
  102. package/dist/personas/resolver.d.ts.map +1 -1
  103. package/dist/routes/demo.d.ts.map +1 -1
  104. package/dist/routes/openai.d.ts.map +1 -1
  105. package/dist/routes/private.d.ts.map +1 -1
  106. package/dist/routes/public.d.ts.map +1 -1
  107. package/dist/scheduler/compose.d.ts +2 -1
  108. package/dist/scheduler/compose.d.ts.map +1 -1
  109. package/dist/scheduler/index.js +29 -9
  110. package/dist/scheduler/index.mjs +29 -9
  111. package/dist/scheduler/scheduler.d.ts +3 -0
  112. package/dist/scheduler/scheduler.d.ts.map +1 -1
  113. package/dist/scheduler/tick.d.ts +2 -0
  114. package/dist/scheduler/tick.d.ts.map +1 -1
  115. package/dist/server.d.ts.map +1 -1
  116. package/dist/server.js +726 -538
  117. package/dist/server.mjs +708 -527
  118. package/dist/types.d.ts +87 -0
  119. package/dist/types.d.ts.map +1 -1
  120. package/package.json +30 -9
@@ -1,25 +1,58 @@
1
1
  // src/channels/whatsapp/crypto.ts
2
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt } from "node:crypto";
3
+ import { promisify } from "node:util";
4
+ var scryptAsync = promisify(scrypt);
3
5
  var IV_LENGTH = 12;
4
6
  var TAG_LENGTH = 16;
5
- function deriveKey(secret) {
7
+ var SALT_LENGTH = 16;
8
+ var KEY_LENGTH = 32;
9
+ var VERSION = 1;
10
+ function deriveLegacyKey(secret) {
6
11
  return createHash("sha256").update(secret, "utf-8").digest();
7
12
  }
8
- function encrypt(plaintext, secret) {
13
+ async function deriveKey(secret, salt) {
14
+ return await scryptAsync(secret, salt, KEY_LENGTH);
15
+ }
16
+ async function encrypt(plaintext, secret) {
17
+ const salt = randomBytes(SALT_LENGTH);
9
18
  const iv = randomBytes(IV_LENGTH);
10
- const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
19
+ const cipher = createCipheriv("aes-256-gcm", await deriveKey(secret, salt), iv);
11
20
  const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
12
- return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64");
21
+ return Buffer.concat([
22
+ Buffer.from([VERSION]),
23
+ salt,
24
+ iv,
25
+ cipher.getAuthTag(),
26
+ ciphertext
27
+ ]).toString("base64");
13
28
  }
14
- function decrypt(payload, secret) {
15
- const raw = Buffer.from(payload, "base64");
29
+ async function decryptVersioned(raw, secret) {
30
+ const salt = raw.subarray(1, 1 + SALT_LENGTH);
31
+ const iv = raw.subarray(1 + SALT_LENGTH, 1 + SALT_LENGTH + IV_LENGTH);
32
+ const tag = raw.subarray(1 + SALT_LENGTH + IV_LENGTH, 1 + SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
33
+ const ciphertext = raw.subarray(1 + SALT_LENGTH + IV_LENGTH + TAG_LENGTH);
34
+ const decipher = createDecipheriv("aes-256-gcm", await deriveKey(secret, salt), iv);
35
+ decipher.setAuthTag(tag);
36
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
37
+ }
38
+ function decryptLegacy(raw, secret) {
16
39
  const iv = raw.subarray(0, IV_LENGTH);
17
40
  const tag = raw.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
18
41
  const ciphertext = raw.subarray(IV_LENGTH + TAG_LENGTH);
19
- const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
42
+ const decipher = createDecipheriv("aes-256-gcm", deriveLegacyKey(secret), iv);
20
43
  decipher.setAuthTag(tag);
21
44
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
22
45
  }
46
+ async function decrypt(payload, secret) {
47
+ const raw = Buffer.from(payload, "base64");
48
+ if (raw[0] === VERSION) {
49
+ try {
50
+ return await decryptVersioned(raw, secret);
51
+ } catch {
52
+ }
53
+ }
54
+ return decryptLegacy(raw, secret);
55
+ }
23
56
 
24
57
  // src/channels/whatsapp/authState.ts
25
58
  var schemaReady = /* @__PURE__ */ new WeakMap();
@@ -44,10 +77,10 @@ async function useTursoAuthState(db, secret, sessionId, runtime) {
44
77
  });
45
78
  const row = result.rows[0];
46
79
  if (!row) return null;
47
- return JSON.parse(decrypt(row.value, secret), runtime.bufferJSON.reviver);
80
+ return JSON.parse(await decrypt(row.value, secret), runtime.bufferJSON.reviver);
48
81
  }
49
82
  async function write(id, value) {
50
- const payload = encrypt(JSON.stringify(value, runtime.bufferJSON.replacer), secret);
83
+ const payload = await encrypt(JSON.stringify(value, runtime.bufferJSON.replacer), secret);
51
84
  await db.execute({
52
85
  sql: "INSERT OR REPLACE INTO wa_auth (id, value) VALUES (?, ?)",
53
86
  args: [rowId(id), payload]
@@ -136,6 +169,35 @@ function silentLogger() {
136
169
  return logger;
137
170
  }
138
171
 
172
+ // src/auth/secretStrength.ts
173
+ var MIN_SECRET_LENGTH = 16;
174
+ function assertStrongSecret(secret, label) {
175
+ if (!secret) {
176
+ throw new Error(`${label} is required and cannot be empty.`);
177
+ }
178
+ if (secret.length < MIN_SECRET_LENGTH) {
179
+ throw new Error(
180
+ `${label} is too weak: must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). Generate one with \`openssl rand -base64 32\`.`
181
+ );
182
+ }
183
+ }
184
+
185
+ // src/core/logger.ts
186
+ var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
187
+ function createConsoleLogger(level = "info") {
188
+ const threshold = LEVEL_ORDER[level];
189
+ const write = (at) => (...args) => {
190
+ if (LEVEL_ORDER[at] < threshold) return;
191
+ console.error(`[${at}]`, ...args);
192
+ };
193
+ return {
194
+ debug: write("debug"),
195
+ info: write("info"),
196
+ warn: write("warn"),
197
+ error: write("error")
198
+ };
199
+ }
200
+
139
201
  // src/channels/whatsapp/lease.ts
140
202
  var LEASE_STALE_MS = 9e4;
141
203
  var LEASE_HEARTBEAT_MS = 3e4;
@@ -218,11 +280,12 @@ async function acquireSessionLease(sessionId, deps) {
218
280
  const heartbeatMs = deps.heartbeatMs ?? LEASE_HEARTBEAT_MS;
219
281
  const waitMs = deps.waitMs ?? LEASE_WAIT_MS;
220
282
  const schedule = deps.schedule ?? ((fn, ms) => void setTimeout(fn, ms));
283
+ const logger = deps.logger ?? createConsoleLogger();
221
284
  if (isStopped()) return;
222
285
  const retry = () => {
223
286
  schedule(
224
287
  () => void acquireSessionLease(sessionId, deps).catch(
225
- (error) => console.error(`WhatsApp[${sessionId}]: lease re-check failed:`, error)
288
+ (error) => logger.error(`WhatsApp[${sessionId}]: lease re-check failed:`, error)
226
289
  ),
227
290
  waitMs
228
291
  );
@@ -231,21 +294,21 @@ async function acquireSessionLease(sessionId, deps) {
231
294
  try {
232
295
  acquired = await leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs);
233
296
  } catch (error) {
234
- console.warn(`WhatsApp[${sessionId}]: lease acquire failed, retrying:`, error);
297
+ logger.warn(`WhatsApp[${sessionId}]: lease acquire failed, retrying:`, error);
235
298
  retry();
236
299
  return;
237
300
  }
238
301
  if (!acquired) {
239
- console.warn(`WhatsApp[${sessionId}]: lease held by another instance, re-checking later`);
302
+ logger.warn(`WhatsApp[${sessionId}]: lease held by another instance, re-checking later`);
240
303
  retry();
241
304
  return;
242
305
  }
243
306
  const heartbeat = setInterval(() => {
244
307
  void leaseStore.tryAcquire(sessionId, instanceId, now(), staleMs).then((stillHeld) => {
245
308
  if (stillHeld || isStopped()) return;
246
- console.error(`WhatsApp[${sessionId}]: lost the lease to another instance \u2014 disconnecting`);
309
+ logger.error(`WhatsApp[${sessionId}]: lost the lease to another instance \u2014 disconnecting`);
247
310
  void loseSession(sessionId, sessions).then(retry);
248
- }).catch((error) => console.warn(`WhatsApp[${sessionId}]: lease heartbeat failed:`, error));
311
+ }).catch((error) => logger.warn(`WhatsApp[${sessionId}]: lease heartbeat failed:`, error));
249
312
  }, heartbeatMs);
250
313
  if (typeof heartbeat === "object" && "unref" in heartbeat) heartbeat.unref();
251
314
  sessions.set(sessionId, {
@@ -256,7 +319,7 @@ async function acquireSessionLease(sessionId, deps) {
256
319
  try {
257
320
  await connect(sessionId);
258
321
  } catch (error) {
259
- console.error(`WhatsApp[${sessionId}]: connect failed right after acquiring the lease:`, error);
322
+ logger.error(`WhatsApp[${sessionId}]: connect failed right after acquiring the lease:`, error);
260
323
  await loseSession(sessionId, sessions);
261
324
  retry();
262
325
  }
@@ -265,6 +328,7 @@ function senderNameFor(channelName, sessionId) {
265
328
  return sessionId === "default" ? channelName : `${channelName}:${sessionId}`;
266
329
  }
267
330
  function createWhatsAppChannel(config) {
331
+ assertStrongSecret(config.sessionSecret, "WhatsApp sessionSecret (e.g. WA_SESSION_SECRET)");
268
332
  const channelName = "whatsapp";
269
333
  const sessionIds = config.sessionIds && config.sessionIds.length > 0 ? config.sessionIds : ["default"];
270
334
  const now = config.now ?? Date.now;
@@ -282,6 +346,7 @@ function createWhatsAppChannel(config) {
282
346
  return {
283
347
  name: channelName,
284
348
  async start(deps) {
349
+ const log = config.logger ?? deps.logger ?? createConsoleLogger();
285
350
  const db = deps.db;
286
351
  const leaseStore = createTursoWaLeaseStore(db);
287
352
  senders = deps.senders;
@@ -301,7 +366,7 @@ function createWhatsAppChannel(config) {
301
366
  runtime
302
367
  );
303
368
  if (!state.creds.registered) {
304
- console.warn(
369
+ log.warn(
305
370
  `WhatsApp[${sessionId}]: no paired session found \u2014 pair it first (see docs/channels.md). Re-checking in 60s.`
306
371
  );
307
372
  schedule(() => void connect(sessionId).catch(() => void 0), 6e4);
@@ -326,14 +391,14 @@ function createWhatsAppChannel(config) {
326
391
  sock.ev.on(
327
392
  "creds.update",
328
393
  () => void saveCreds().catch(
329
- (error) => console.warn(`WhatsApp[${sessionId}]: creds save failed:`, error)
394
+ (error) => log.warn(`WhatsApp[${sessionId}]: creds save failed:`, error)
330
395
  )
331
396
  );
332
397
  sock.ev.on("connection.update", (update) => {
333
398
  const { connection, lastDisconnect } = update;
334
399
  if (connection === "open") {
335
400
  failureCounts.set(sessionId, 0);
336
- console.log(`WhatsApp[${sessionId}]: connected as ${sock.user?.id ?? "unknown"}`);
401
+ log.info(`WhatsApp[${sessionId}]: connected as ${sock.user?.id ?? "unknown"}`);
337
402
  const senderName = senderNameFor(channelName, sessionId);
338
403
  const sender = {
339
404
  sendText: async (chatId, text) => {
@@ -351,7 +416,7 @@ function createWhatsAppChannel(config) {
351
416
  }
352
417
  const statusCode = lastDisconnect?.error?.output?.statusCode;
353
418
  if (statusCode === baileys.DisconnectReason.loggedOut) {
354
- console.error(
419
+ log.error(
355
420
  `WhatsApp[${sessionId}]: session logged out \u2014 re-pair to reconnect (see docs/channels.md).`
356
421
  );
357
422
  void loseSession(sessionId, sessions);
@@ -360,12 +425,12 @@ function createWhatsAppChannel(config) {
360
425
  if (!stopped && sessions.has(sessionId)) {
361
426
  const delay = reconnectDelayMs(failureCounts.get(sessionId) ?? 0);
362
427
  failureCounts.set(sessionId, (failureCounts.get(sessionId) ?? 0) + 1);
363
- console.warn(
428
+ log.warn(
364
429
  `WhatsApp[${sessionId}]: connection closed (${statusCode ?? "?"}), reconnecting in ${Math.round(delay / 1e3)}s...`
365
430
  );
366
431
  schedule(
367
432
  () => void connect(sessionId).catch(
368
- (error) => console.error(`WhatsApp[${sessionId}]: reconnect failed:`, error)
433
+ (error) => log.error(`WhatsApp[${sessionId}]: reconnect failed:`, error)
369
434
  ),
370
435
  delay
371
436
  );
@@ -379,7 +444,7 @@ function createWhatsAppChannel(config) {
379
444
  try {
380
445
  await config.onMessage?.({ sessionId, sock, message });
381
446
  } catch (error) {
382
- console.warn(`WhatsApp[${sessionId}]: onMessage handler failed:`, error);
447
+ log.warn(`WhatsApp[${sessionId}]: onMessage handler failed:`, error);
383
448
  }
384
449
  }
385
450
  });
@@ -396,8 +461,9 @@ function createWhatsAppChannel(config) {
396
461
  staleMs,
397
462
  heartbeatMs,
398
463
  waitMs,
399
- schedule
400
- }).catch((error) => console.error(`WhatsApp[${sessionId}]: failed to start:`, error));
464
+ schedule,
465
+ logger: log
466
+ }).catch((error) => log.error(`WhatsApp[${sessionId}]: failed to start:`, error));
401
467
  }
402
468
  },
403
469
  async stop() {
@@ -476,6 +542,53 @@ function createWhatsAppImageHandler(deps) {
476
542
  };
477
543
  }
478
544
 
545
+ // src/channels/gates.ts
546
+ function matches(re, text) {
547
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
548
+ }
549
+ function isBlockedByAllowlist(msg, config) {
550
+ return !msg.fromBot && msg.text.trim().length > 0 && !msg.isDirectMessage && config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId);
551
+ }
552
+ function decideChannelAction(msg, config) {
553
+ if (msg.fromBot || !msg.text.trim()) return "ignore";
554
+ if (!msg.isDirectMessage) {
555
+ if (isBlockedByAllowlist(msg, config)) return "ignore";
556
+ if (config.muteRegex && matches(config.muteRegex, msg.text)) return "mute";
557
+ if (config.unmuteRegex && matches(config.unmuteRegex, msg.text)) return "unmute";
558
+ if (config.mutedChats.has(msg.chatId)) return "ignore";
559
+ return msg.mentionsBot || msg.isReplyToBot ? "reply" : "ignore";
560
+ }
561
+ return "reply";
562
+ }
563
+ function createSlidingWindowRateLimiter(maxPerWindow, windowMs, now = Date.now) {
564
+ const log = /* @__PURE__ */ new Map();
565
+ return function allow(key) {
566
+ const cutoff = now() - windowMs;
567
+ const entries = (log.get(key) ?? []).filter((t) => t > cutoff);
568
+ if (entries.length >= maxPerWindow) {
569
+ if (entries.length > 0) log.set(key, entries);
570
+ else log.delete(key);
571
+ return false;
572
+ }
573
+ entries.push(now());
574
+ log.set(key, entries);
575
+ return true;
576
+ };
577
+ }
578
+ function underReplyRateLimit(msg, limiters) {
579
+ return msg.isDirectMessage ? limiters.dm(msg.chatId) : limiters.group(msg.chatId);
580
+ }
581
+ function isFromAnySession(id, registry) {
582
+ if (!id) return false;
583
+ for (const ids of registry.values()) {
584
+ if (ids.includes(id)) return true;
585
+ }
586
+ return false;
587
+ }
588
+ function isEffectivelyFromSelf(msg, registry) {
589
+ return msg.fromBot || isFromAnySession(msg.senderId, registry);
590
+ }
591
+
479
592
  // src/core/guardrails.ts
480
593
  function detectLeakage(text) {
481
594
  const flags = [
@@ -520,8 +633,8 @@ async function completeOnce({
520
633
 
521
634
  // src/core/answer.ts
522
635
  var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
523
- async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
524
- const result = await answerFn({ system, messages, mode, sender });
636
+ async function runAnswerFn(answerFn, { system, messages, mode, sender, conversationId }) {
637
+ const result = await answerFn({ system, messages, mode, sender, conversationId });
525
638
  const raw = typeof result === "string" ? result : result?.content;
526
639
  const usage = typeof result === "string" ? void 0 : result?.usage;
527
640
  return {
@@ -536,11 +649,12 @@ async function answerOnce({
536
649
  messages,
537
650
  mode,
538
651
  sender,
652
+ conversationId,
539
653
  temperature,
540
654
  model
541
655
  }) {
542
656
  if (answerFn) {
543
- return runAnswerFn(answerFn, { system, messages, mode, sender });
657
+ return runAnswerFn(answerFn, { system, messages, mode, sender, conversationId });
544
658
  }
545
659
  return completeOnce({ client, system, messages, temperature, model });
546
660
  }
@@ -576,6 +690,11 @@ async function resolveBuckets({
576
690
  return cleaned.filter((bucket) => ceiling.has(bucket));
577
691
  }
578
692
 
693
+ // src/core/messages.ts
694
+ function lastUserMessage(messages) {
695
+ return [...messages].reverse().find((m) => m.role === "user");
696
+ }
697
+
579
698
  // src/core/pipeline.ts
580
699
  var MODE_SETTINGS = {
581
700
  public: { topK: 6, label: "Context" },
@@ -590,7 +709,7 @@ async function prepareChat({
590
709
  channelHint,
591
710
  buckets
592
711
  }) {
593
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
712
+ const lastUserMsg = lastUserMessage(messages);
594
713
  if (!lastUserMsg) {
595
714
  throw new Error("no user message found in conversation");
596
715
  }
@@ -605,54 +724,93 @@ async function prepareChat({
605
724
  `${label}:
606
725
  ${ctx.join("\n\n")}`
607
726
  ].join("\n\n");
608
- return { system, messages };
727
+ return { system, messages, context: ctx };
609
728
  }
610
729
 
611
- // src/channels/gates.ts
612
- function matches(re, text) {
613
- return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
614
- }
615
- function decideChannelAction(msg, config) {
616
- if (msg.fromBot || !msg.text.trim()) return "ignore";
617
- if (!msg.isDirectMessage) {
618
- if (config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId)) {
619
- return "ignore";
620
- }
621
- if (config.muteRegex && matches(config.muteRegex, msg.text)) return "mute";
622
- if (config.unmuteRegex && matches(config.unmuteRegex, msg.text)) return "unmute";
623
- if (config.mutedChats.has(msg.chatId)) return "ignore";
624
- return msg.mentionsBot || msg.isReplyToBot ? "reply" : "ignore";
730
+ // src/channels/pipeline.ts
731
+ var DEFAULT_HISTORY_LIMIT = 20;
732
+ var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
733
+ var DEFAULT_DM_RATE_LIMIT = { max: 20, windowMs: 60 * 60 * 1e3 };
734
+ async function resolvePersonaLayer(config, ctx) {
735
+ if (!config.personaResolver) return void 0;
736
+ try {
737
+ return await config.personaResolver(ctx);
738
+ } catch {
739
+ return void 0;
625
740
  }
626
- return "reply";
627
741
  }
628
- function createSlidingWindowRateLimiter(maxPerWindow, windowMs, now = Date.now) {
629
- const log = /* @__PURE__ */ new Map();
630
- return function allow(key) {
631
- const cutoff = now() - windowMs;
632
- const entries = (log.get(key) ?? []).filter((t) => t > cutoff);
633
- if (entries.length >= maxPerWindow) {
634
- if (entries.length > 0) log.set(key, entries);
635
- else log.delete(key);
636
- return false;
742
+ function createInboundPipeline(deps, config) {
743
+ const mutedChats = /* @__PURE__ */ new Set();
744
+ const now = config.now ?? Date.now;
745
+ const group = config.groupRateLimit ?? DEFAULT_GROUP_RATE_LIMIT;
746
+ const dm = config.dmRateLimit ?? DEFAULT_DM_RATE_LIMIT;
747
+ const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
748
+ const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
749
+ const mode = "public";
750
+ return async function handleInboundMessage(msg, turn) {
751
+ const allowedChats = config.allowedChats ?? [];
752
+ const action = decideChannelAction(msg, {
753
+ allowedChats,
754
+ mutedChats,
755
+ muteRegex: config.muteRegex,
756
+ unmuteRegex: config.unmuteRegex
757
+ });
758
+ if (action === "mute") {
759
+ mutedChats.add(msg.chatId);
760
+ if (config.muteReply) await turn.reply.sendGateReply(msg.chatId, config.muteReply);
761
+ return { action: "mute" };
637
762
  }
638
- entries.push(now());
639
- log.set(key, entries);
640
- return true;
763
+ if (action === "unmute") {
764
+ mutedChats.delete(msg.chatId);
765
+ if (config.unmuteReply) await turn.reply.sendGateReply(msg.chatId, config.unmuteReply);
766
+ return { action: "unmute" };
767
+ }
768
+ if (action === "ignore") {
769
+ return { action: "ignore" };
770
+ }
771
+ if (!underReplyRateLimit(msg, { dm: underDmRateLimit, group: underGroupRateLimit })) {
772
+ return { action: "rate-limited" };
773
+ }
774
+ const sender = typeof turn.sender === "function" ? await turn.sender() : turn.sender ?? msg.senderId;
775
+ const conversationId = turn.conversationId ?? msg.chatId;
776
+ if (turn.intercept && await turn.intercept(sender)) {
777
+ return { action: "intercepted" };
778
+ }
779
+ const personaLayer = await resolvePersonaLayer(config, { sender, text: msg.text });
780
+ const buckets = await resolveBuckets({ mode, sender, bucketsFor: config.bucketsFor });
781
+ const priorTurns = config.history ? await config.history.store.load(
782
+ conversationId,
783
+ config.history.limit ?? DEFAULT_HISTORY_LIMIT
784
+ ) : [];
785
+ const messages = [...priorTurns, { role: "user", content: msg.text }];
786
+ await config.history?.store.append(conversationId, { role: "user", content: msg.text });
787
+ const { system } = await prepareChat({
788
+ store: deps.store,
789
+ prompts: deps.prompts,
790
+ mode,
791
+ messages,
792
+ personaLayer,
793
+ channelHint: config.channelHint,
794
+ buckets
795
+ });
796
+ const { content } = await answerOnce({
797
+ answerFn: config.answerFn,
798
+ client: deps.client,
799
+ system,
800
+ messages,
801
+ mode,
802
+ sender,
803
+ conversationId,
804
+ model: config.model
805
+ });
806
+ if (!content) {
807
+ return { action: "ignore" };
808
+ }
809
+ await config.history?.store.append(conversationId, { role: "assistant", content });
810
+ await turn.reply.sendAnswer(msg.chatId, content);
811
+ return { action: "reply", content };
641
812
  };
642
813
  }
643
- function underReplyRateLimit(msg, limiters) {
644
- return msg.isDirectMessage ? limiters.dm(msg.chatId) : limiters.group(msg.chatId);
645
- }
646
- function isFromAnySession(id, registry) {
647
- if (!id) return false;
648
- for (const ids of registry.values()) {
649
- if (ids.includes(id)) return true;
650
- }
651
- return false;
652
- }
653
- function isEffectivelyFromSelf(msg, registry) {
654
- return msg.fromBot || isFromAnySession(msg.senderId, registry);
655
- }
656
814
 
657
815
  // src/channels/whatsapp/inbound.ts
658
816
  function isGroupJid(jid) {
@@ -669,11 +827,6 @@ function jidToPhoneNumber(jid) {
669
827
  const bare = bareUser(jid);
670
828
  return bare.startsWith("+") ? bare : `+${bare}`;
671
829
  }
672
- function normalizeJid(jid) {
673
- const [user = "", domain] = jid.split("@");
674
- const withoutDevice = user.split(":")[0] ?? user;
675
- return domain ? `${withoutDevice}@${domain}` : withoutDevice;
676
- }
677
830
  async function senderPhoneFor(sock, message, chatJid) {
678
831
  const key = message.key;
679
832
  let jid = [key.participantPn, key.senderPn, key.participant, key.remoteJidAlt, chatJid].find(
@@ -701,6 +854,11 @@ function extractText(message) {
701
854
  if (!content) return "";
702
855
  return content.conversation ?? content.extendedTextMessage?.text ?? content.imageMessage?.caption ?? content.videoMessage?.caption ?? content.documentMessage?.caption ?? "";
703
856
  }
857
+ function normalizeJid(jid) {
858
+ const [user = "", domain] = jid.split("@");
859
+ const withoutDevice = user.split(":")[0] ?? user;
860
+ return domain ? `${withoutDevice}@${domain}` : withoutDevice;
861
+ }
704
862
  function stripOwnMentions(text, ownIds) {
705
863
  const ownUsers = new Set(ownIds.map(bareUser).filter(Boolean));
706
864
  if (!text || ownUsers.size === 0) return text;
@@ -714,109 +872,183 @@ function stripOwnMentions(text, ownIds) {
714
872
  const cleaned = stripped.trim();
715
873
  return cleaned ? cleaned : text;
716
874
  }
717
- var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
718
- var DEFAULT_DM_RATE_LIMIT = { max: 20, windowMs: 60 * 60 * 1e3 };
719
- async function resolvePersonaLayer(config, ctx) {
720
- if (!config.personaResolver) return void 0;
721
- try {
722
- return await config.personaResolver(ctx);
723
- } catch {
724
- return void 0;
725
- }
875
+ function resolveWaMessage(event, registry) {
876
+ const { sessionId, sock, message } = event;
877
+ const chatId = message.key.remoteJid ?? "";
878
+ const rawText = extractText(message);
879
+ const context = messageContext(message);
880
+ const senderId = message.key.participant ?? chatId;
881
+ const ownIds = [sock.user?.id, sock.user?.lid].filter((id) => Boolean(id)).map(normalizeJid);
882
+ registry.set(sessionId, ownIds);
883
+ const text = stripOwnMentions(rawText, ownIds);
884
+ const fromBot = isEffectivelyFromSelf(
885
+ { fromBot: message.key.fromMe ?? false, senderId: normalizeJid(senderId) },
886
+ registry
887
+ );
888
+ const msg = {
889
+ chatId,
890
+ senderId,
891
+ text,
892
+ isDirectMessage: !isGroupJid(chatId),
893
+ mentionsBot: context.mentionedJids.some((jid) => ownIds.some((own) => jidsMatch(jid, own))),
894
+ isReplyToBot: ownIds.some((own) => jidsMatch(context.quotedParticipantJid, own)),
895
+ fromBot
896
+ };
897
+ return { msg, ownIds };
726
898
  }
727
899
  function createWhatsAppInboundHandler(config) {
728
- const mutedChats = /* @__PURE__ */ new Set();
729
- const now = config.now ?? Date.now;
730
- const group = config.groupRateLimit ?? DEFAULT_GROUP_RATE_LIMIT;
731
- const dm = config.dmRateLimit ?? DEFAULT_DM_RATE_LIMIT;
732
- const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
733
- const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
900
+ const loggedUnallowedChats = /* @__PURE__ */ new Set();
901
+ const allowedChats = config.allowedChats ?? [];
902
+ const personaResolver = config.personaResolver;
903
+ const images = config.images;
904
+ const logger = config.logger ?? createConsoleLogger();
905
+ const pipeline = createInboundPipeline(
906
+ { client: config.client, store: config.store, prompts: config.prompts },
907
+ {
908
+ answerFn: config.answerFn,
909
+ bucketsFor: config.bucketsFor,
910
+ model: config.model,
911
+ channelHint: config.channelHint,
912
+ personaResolver: personaResolver ? ({ sender, text }) => personaResolver({ senderPhone: sender, text }) : void 0,
913
+ history: config.history,
914
+ allowedChats,
915
+ muteRegex: config.muteRegex,
916
+ unmuteRegex: config.unmuteRegex,
917
+ muteReply: config.muteReply,
918
+ unmuteReply: config.unmuteReply,
919
+ dmRateLimit: config.dmRateLimit,
920
+ groupRateLimit: config.groupRateLimit,
921
+ now: config.now
922
+ }
923
+ );
734
924
  return async function handleWhatsAppMessage(event) {
735
925
  const { sessionId, sock, message } = event;
736
926
  try {
737
927
  const chatId = message.key.remoteJid ?? "";
738
928
  if (!chatId || chatId === "status@broadcast") return;
739
- const rawText = extractText(message);
740
- const context = messageContext(message);
741
- const senderId = message.key.participant ?? chatId;
742
- const ownIds = [sock.user?.id, sock.user?.lid].filter((id) => Boolean(id)).map(normalizeJid);
743
- config.registry.set(sessionId, ownIds);
744
- const text = stripOwnMentions(rawText, ownIds);
745
- const fromBot = isEffectivelyFromSelf(
746
- { fromBot: message.key.fromMe ?? false, senderId: normalizeJid(senderId) },
747
- config.registry
748
- );
749
- const msg = {
750
- chatId,
751
- senderId,
752
- text,
753
- isDirectMessage: !isGroupJid(chatId),
754
- mentionsBot: context.mentionedJids.some((jid) => ownIds.some((own) => jidsMatch(jid, own))),
755
- isReplyToBot: ownIds.some((own) => jidsMatch(context.quotedParticipantJid, own)),
756
- fromBot
757
- };
758
- const action = decideChannelAction(msg, {
759
- allowedChats: config.allowedChats ?? [],
760
- mutedChats,
761
- muteRegex: config.muteRegex,
762
- unmuteRegex: config.unmuteRegex
763
- });
764
- if (action === "mute") {
765
- mutedChats.add(chatId);
766
- if (config.muteReply) await sock.sendMessage(chatId, { text: config.muteReply });
767
- return;
768
- }
769
- if (action === "unmute") {
770
- mutedChats.delete(chatId);
771
- if (config.unmuteReply) await sock.sendMessage(chatId, { text: config.unmuteReply });
772
- return;
773
- }
774
- if (action !== "reply") return;
775
- if (!underReplyRateLimit(msg, { dm: underDmRateLimit, group: underGroupRateLimit })) {
776
- return;
929
+ const { msg } = resolveWaMessage(event, config.registry);
930
+ const text = msg.text;
931
+ if (isBlockedByAllowlist(msg, { allowedChats })) {
932
+ const dedupKey = `${sessionId}:${chatId}`;
933
+ if (!loggedUnallowedChats.has(dedupKey)) {
934
+ loggedUnallowedChats.add(dedupKey);
935
+ logger.warn(`WhatsApp[${sessionId}]: skipped group ${chatId} - not in allowedChats`);
936
+ }
777
937
  }
778
- const senderPhone = await senderPhoneFor(sock, message, chatId);
779
- if (config.images) {
780
- const handled = await config.images({
938
+ const reply = {
939
+ sendAnswer: async (targetChatId, replyText2) => {
940
+ await sock.sendMessage(targetChatId, { text: replyText2 }, { quoted: message });
941
+ },
942
+ sendGateReply: async (targetChatId, replyText2) => {
943
+ await sock.sendMessage(targetChatId, { text: replyText2 });
944
+ }
945
+ };
946
+ await pipeline(msg, {
947
+ reply,
948
+ conversationId: chatId,
949
+ sender: () => senderPhoneFor(sock, message, chatId),
950
+ intercept: images ? (senderPhone) => images({
781
951
  sock,
782
952
  message,
783
953
  chatId,
784
954
  senderId: senderPhone,
785
955
  text,
786
956
  hasPhoto: Boolean(message.message?.imageMessage)
787
- });
788
- if (handled) return;
789
- }
790
- const personaLayer = await resolvePersonaLayer(config, { senderPhone, text });
791
- const buckets = await resolveBuckets({
792
- mode: "public",
793
- sender: senderPhone,
794
- bucketsFor: config.bucketsFor
795
- });
796
- const messages = [{ role: "user", content: text }];
797
- const { system } = await prepareChat({
798
- store: config.store,
799
- prompts: config.prompts,
800
- mode: "public",
801
- messages,
802
- personaLayer,
803
- channelHint: config.channelHint,
804
- buckets
957
+ }) : void 0
805
958
  });
806
- const { content } = await answerOnce({
807
- answerFn: config.answerFn,
808
- client: config.client,
809
- system,
810
- messages,
811
- mode: "public",
812
- sender: senderPhone,
813
- model: config.model
959
+ } catch (error) {
960
+ logger.warn(`WhatsApp[${sessionId}]: inbound message handling failed:`, error);
961
+ }
962
+ };
963
+ }
964
+
965
+ // src/channels/whatsapp/router.ts
966
+ async function logDetectorError(config, logger, name, error) {
967
+ if (!config.onDetectorError) {
968
+ logger.warn(`WhatsApp router: detector "${name}" failed:`, error);
969
+ return;
970
+ }
971
+ try {
972
+ await config.onDetectorError(name, error);
973
+ } catch (callbackError) {
974
+ logger.warn(`WhatsApp router: onDetectorError itself failed:`, callbackError);
975
+ }
976
+ }
977
+ function isChatIneligible(msg, allowedChats) {
978
+ return !msg.isDirectMessage && allowedChats.length > 0 && !allowedChats.includes(msg.chatId);
979
+ }
980
+ function fireParallelDetector(config, logger, detector, ctx) {
981
+ void (async () => {
982
+ try {
983
+ if (await detector.test(ctx)) {
984
+ await detector.handle(ctx);
985
+ }
986
+ } catch (error) {
987
+ await logDetectorError(config, logger, detector.name, error);
988
+ }
989
+ })();
990
+ }
991
+ function createWhatsAppMessageRouter(config) {
992
+ const allowedChats = config.allowedChats ?? [];
993
+ const mutedChats = config.mutedChats ?? /* @__PURE__ */ new Set();
994
+ const logger = config.logger ?? createConsoleLogger();
995
+ const replaceDetectors = config.detectors.filter(
996
+ (d) => d.mode === "replace"
997
+ );
998
+ const parallelDetectors = config.detectors.filter(
999
+ (d) => d.mode === "parallel"
1000
+ );
1001
+ return async function routeWhatsAppMessage(event) {
1002
+ const { sock, message } = event;
1003
+ let ctx;
1004
+ try {
1005
+ const chatId = message.key.remoteJid ?? "";
1006
+ if (!chatId || chatId === "status@broadcast") return;
1007
+ const { msg, ownIds } = resolveWaMessage(event, config.registry);
1008
+ if (msg.fromBot || isChatIneligible(msg, allowedChats)) return;
1009
+ const action = decideChannelAction(msg, {
1010
+ allowedChats,
1011
+ mutedChats,
1012
+ muteRegex: config.muteRegex,
1013
+ unmuteRegex: config.unmuteRegex
814
1014
  });
815
- if (content) {
816
- await sock.sendMessage(chatId, { text: content }, { quoted: message });
1015
+ ctx = {
1016
+ event,
1017
+ sock,
1018
+ message,
1019
+ msg,
1020
+ ownIds,
1021
+ senderId: msg.senderId,
1022
+ text: msg.text,
1023
+ action
1024
+ };
1025
+ } catch (error) {
1026
+ await logDetectorError(config, logger, "resolve", error);
1027
+ return;
1028
+ }
1029
+ for (const detector of parallelDetectors) {
1030
+ fireParallelDetector(config, logger, detector, ctx);
1031
+ }
1032
+ for (const detector of replaceDetectors) {
1033
+ let matched;
1034
+ try {
1035
+ matched = await detector.test(ctx);
1036
+ } catch (error) {
1037
+ await logDetectorError(config, logger, detector.name, error);
1038
+ continue;
817
1039
  }
1040
+ if (!matched) continue;
1041
+ try {
1042
+ await detector.handle(ctx);
1043
+ } catch (error) {
1044
+ await logDetectorError(config, logger, detector.name, error);
1045
+ }
1046
+ return;
1047
+ }
1048
+ try {
1049
+ await config.fallback(event);
818
1050
  } catch (error) {
819
- console.warn(`WhatsApp[${sessionId}]: inbound message handling failed:`, error);
1051
+ await logDetectorError(config, logger, "fallback", error);
820
1052
  }
821
1053
  };
822
1054
  }
@@ -830,6 +1062,7 @@ export {
830
1062
  createWhatsAppChannel,
831
1063
  createWhatsAppImageHandler,
832
1064
  createWhatsAppInboundHandler,
1065
+ createWhatsAppMessageRouter,
833
1066
  decrypt,
834
1067
  drawSubject,
835
1068
  encrypt,
@@ -841,6 +1074,7 @@ export {
841
1074
  loadBaileys,
842
1075
  messageContext,
843
1076
  reconnectDelayMs,
1077
+ resolveWaMessage,
844
1078
  senderNameFor,
845
1079
  senderPhoneFor,
846
1080
  shutdownWaSessions,