@diegoaltoworks/chatter 0.29.0 → 0.30.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * WhatsApp inbound: turns a raw Baileys message into a reply through
3
+ * chatter's own pipeline (`prepareChat` + `answerOnce`), gated by
4
+ * `./channels`' reply gates and loop guard. `./channel` hands every raw
5
+ * message to `config.onMessage` uninterpreted — this module is that
6
+ * interpretation.
7
+ *
8
+ * Message-shape parsing (text/caption extraction, contextInfo mentions and
9
+ * quoted-reply resolution) is genericized from the reference implementation:
10
+ * text AND contextInfo live in a DIFFERENT place per message shape — plain
11
+ * text carries both on `extendedTextMessage`, but a photo/video/document
12
+ * carries its caption on its own message type and its contextInfo there too.
13
+ * Missing that meant mentions in photo captions were invisible and group
14
+ * photo requests were silently ignored.
15
+ *
16
+ * `createWhatsAppInboundHandler` needs `ServerDependencies`
17
+ * (`client`/`store`/`prompts`), which only exists once `createServer` builds
18
+ * it — after `channels` (and therefore `onMessage`) must already be
19
+ * configured. Wire it up from `ChatterConfig.customRoutes`, which runs
20
+ * before channels start and receives the same deps:
21
+ *
22
+ * ```ts
23
+ * let handleInbound: ((event: WhatsAppMessageEvent) => Promise<void>) | undefined;
24
+ * const whatsapp = createWhatsAppChannel({
25
+ * sessionSecret: process.env.WA_SESSION_SECRET as string,
26
+ * onMessage: (event) => handleInbound?.(event),
27
+ * });
28
+ *
29
+ * await createServer({
30
+ * ...,
31
+ * channels: [whatsapp],
32
+ * customRoutes: async (app, deps) => {
33
+ * handleInbound = createWhatsAppInboundHandler({
34
+ * client: deps.client,
35
+ * store: deps.store,
36
+ * prompts: deps.prompts,
37
+ * answerFn: deps.config.answerFn,
38
+ * bucketsFor: deps.config.bucketsFor,
39
+ * registry: new Map(),
40
+ * });
41
+ * },
42
+ * });
43
+ * ```
44
+ */
45
+ import type { WAMessage, WASocket } from "@whiskeysockets/baileys";
46
+ import type OpenAI from "openai";
47
+ import { type AnswerFn } from "../../core/answer";
48
+ import { type BucketsFor } from "../../core/buckets";
49
+ import type { PromptLoader } from "../../core/prompts";
50
+ import type { VectorStore } from "../../core/retrieval";
51
+ import { type SessionIdentityRegistry } from "../gates";
52
+ import type { WhatsAppMessageEvent } from "./channel";
53
+ export declare function isGroupJid(jid: string): boolean;
54
+ /** "447700900123:17@s.whatsapp.net" (device-suffixed) and "447700900123@s.whatsapp.net" match. */
55
+ export declare function jidsMatch(a: string | undefined, b: string | undefined): boolean;
56
+ export declare function jidToPhoneNumber(jid: string): string;
57
+ /**
58
+ * Resolve the sender's real phone number. Modern WhatsApp identifies group
59
+ * senders (and some DM chats) by LID (anonymized `"12345@lid"`), which
60
+ * carries no relationship to the phone number — treating it as one silently
61
+ * keys per-person state on garbage. Prefer the `*Pn` fields Baileys attaches
62
+ * when available, then fall back to the socket's own LID mapping store.
63
+ *
64
+ * The returned string is a stable identity key, not guaranteed to be a
65
+ * dialable phone number: an unresolved LID returns an explicit `"lid:<id>"`
66
+ * marker rather than a phone-shaped fabrication (`jidToPhoneNumber("12345@lid")`
67
+ * would otherwise yield `"+12345"`, indistinguishable from a real number) —
68
+ * callers keying security or spend decisions off `sender` (`bucketsFor`,
69
+ * usage metering) must not mistake a LID numeral for a phone number.
70
+ */
71
+ export declare function senderPhoneFor(sock: WASocket, message: WAMessage, chatJid: string): Promise<string>;
72
+ export interface WaMessageContext {
73
+ mentionedJids: string[];
74
+ quotedParticipantJid?: string;
75
+ }
76
+ /**
77
+ * contextInfo (mentions, quoted message) lives in a DIFFERENT place per
78
+ * message shape: a plain text message carries it on `extendedTextMessage`,
79
+ * but a photo/video/document message carries it on its own type.
80
+ */
81
+ export declare function messageContext(message: WAMessage): WaMessageContext;
82
+ /** Text/caption across every shape that can carry one — plain text, a reply, or a photo/video/document caption. */
83
+ export declare function extractText(message: WAMessage): string;
84
+ export interface WhatsAppInboundConfig {
85
+ client: OpenAI;
86
+ store: VectorStore;
87
+ prompts: PromptLoader;
88
+ answerFn?: AnswerFn;
89
+ bucketsFor?: BucketsFor;
90
+ model?: string;
91
+ /**
92
+ * Own identities per session — share ONE registry across every session
93
+ * this process runs so a message from one session's own number never
94
+ * looks like a stranger to another (the loop-guard case a single
95
+ * session's `fromMe` flag can't catch). Kept in sync from every message,
96
+ * not just at connection open, because a session's LID identity can
97
+ * populate late.
98
+ */
99
+ registry: SessionIdentityRegistry;
100
+ /** Group chats eligible for a reply. Empty (default) = every group. Has no effect on DMs, which always reply regardless. */
101
+ allowedChats?: string[];
102
+ muteRegex?: RegExp;
103
+ unmuteRegex?: RegExp;
104
+ /** Neutral, overridable acknowledgements — this module ships no bot personality; unset = silent mute/unmute. */
105
+ muteReply?: string;
106
+ unmuteReply?: string;
107
+ dmRateLimit?: {
108
+ max: number;
109
+ windowMs: number;
110
+ };
111
+ groupRateLimit?: {
112
+ max: number;
113
+ windowMs: number;
114
+ };
115
+ /** Extra system-prompt section describing the delivery channel; passed through to `prepareChat`. */
116
+ channelHint?: string;
117
+ /**
118
+ * Optional persona layer for a sender/text pair — the resolution
119
+ * mechanism (registry, windowed rolls, prompt files) lives in its own
120
+ * module; this only plugs the result into `prepareChat`'s `personaLayer`.
121
+ * A throw/rejection is treated as "no persona" for that turn.
122
+ */
123
+ personaResolver?: (ctx: {
124
+ senderPhone: string;
125
+ text: string;
126
+ }) => string | undefined | Promise<string | undefined>;
127
+ now?: () => number;
128
+ }
129
+ /** Builds the `onMessage` handler for `createWhatsAppChannel` (see `./channel`) — see the module docstring for how to wire `ServerDependencies` into it. */
130
+ export declare function createWhatsAppInboundHandler(config: WhatsAppInboundConfig): (event: WhatsAppMessageEvent) => Promise<void>;
131
+ //# sourceMappingURL=inbound.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbound.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/inbound.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnE,OAAO,KAAK,MAAM,MAAM,QAAQ,CAAC;AACjC,OAAO,EAAE,KAAK,QAAQ,EAAc,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EAAE,KAAK,UAAU,EAAkB,MAAM,oBAAoB,CAAC;AAErE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,EAKL,KAAK,uBAAuB,EAE7B,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC;AAItD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/C;AAED,kGAAkG;AAClG,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI/E;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGpD;AAeD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,QAAQ,EACd,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,MAAM,CAAC,CA+BjB;AAID,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,SAAS,GAAG,gBAAgB,CAWnE;AAED,mHAAmH;AACnH,wBAAgB,WAAW,CAAC,OAAO,EAAE,SAAS,GAAG,MAAM,CAWtD;AAID,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,YAAY,CAAC;IACtB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,QAAQ,EAAE,uBAAuB,CAAC;IAClC,4HAA4H;IAC5H,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gHAAgH;IAChH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAChD,cAAc,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,oGAAoG;IACpG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE;QACtB,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;KACd,KAAK,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACvD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAqBD,4JAA4J;AAC5J,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,qBAAqB,GAC5B,CAAC,KAAK,EAAE,oBAAoB,KAAK,OAAO,CAAC,IAAI,CAAC,CA0GhD"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=inbound.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inbound.test.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/inbound.test.ts"],"names":[],"mappings":""}
@@ -29,5 +29,6 @@ export type { Baileys } from "./baileys";
29
29
  export { loadBaileys } from "./baileys";
30
30
  export { acquireSessionLease, createWhatsAppChannel, type LeaseGatedConnectDeps, reconnectDelayMs, senderNameFor, shutdownWaSessions, type WaSessionHandle, type WhatsAppChannelConfig, type WhatsAppMessageEvent, } from "./channel";
31
31
  export { decrypt, encrypt } from "./crypto";
32
+ export { createWhatsAppInboundHandler, extractText, isGroupJid, jidsMatch, jidToPhoneNumber, messageContext, senderPhoneFor, type WaMessageContext, type WhatsAppInboundConfig, } from "./inbound";
32
33
  export { canAcquireLease, createTursoWaLeaseStore, LEASE_HEARTBEAT_MS, LEASE_STALE_MS, LEASE_WAIT_MS, type WaLeaseRow, type WaLeaseStore, } from "./lease";
33
34
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,qBAAqB,EAC1B,gBAAgB,EAChB,aAAa,EACb,kBAAkB,EAClB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,GAC1B,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,KAAK,UAAU,EACf,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/whatsapp/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,YAAY,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,OAAO,EACL,mBAAmB,EACnB,qBAAqB,EACrB,KAAK,qBAAqB,EAC1B,gBAAgB,EAChB,aAAa,EACb,kBAAkB,EAClB,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,GAC1B,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,EACL,4BAA4B,EAC5B,WAAW,EACX,UAAU,EACV,SAAS,EACT,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,eAAe,EACf,uBAAuB,EACvB,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,KAAK,UAAU,EACf,KAAK,YAAY,GAClB,MAAM,SAAS,CAAC"}
@@ -37,11 +37,18 @@ __export(index_exports, {
37
37
  canAcquireLease: () => canAcquireLease,
38
38
  createTursoWaLeaseStore: () => createTursoWaLeaseStore,
39
39
  createWhatsAppChannel: () => createWhatsAppChannel,
40
+ createWhatsAppInboundHandler: () => createWhatsAppInboundHandler,
40
41
  decrypt: () => decrypt,
41
42
  encrypt: () => encrypt,
43
+ extractText: () => extractText,
44
+ isGroupJid: () => isGroupJid,
45
+ jidToPhoneNumber: () => jidToPhoneNumber,
46
+ jidsMatch: () => jidsMatch,
42
47
  loadBaileys: () => loadBaileys,
48
+ messageContext: () => messageContext,
43
49
  reconnectDelayMs: () => reconnectDelayMs,
44
50
  senderNameFor: () => senderNameFor,
51
+ senderPhoneFor: () => senderPhoneFor,
45
52
  shutdownWaSessions: () => shutdownWaSessions,
46
53
  useTursoAuthState: () => useTursoAuthState
47
54
  });
@@ -459,6 +466,324 @@ function createWhatsAppChannel(config) {
459
466
  }
460
467
  };
461
468
  }
469
+
470
+ // src/core/guardrails.ts
471
+ function detectLeakage(text) {
472
+ const flags = [
473
+ /system prompt/i,
474
+ /hidden instruction/i,
475
+ /here (are|is) (my|the) rules/i,
476
+ /BEGIN SYSTEM PROMPT/i,
477
+ /tool instructions/i
478
+ ];
479
+ return flags.some((rx) => rx.test(text));
480
+ }
481
+ function scrubOutput(text) {
482
+ return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
483
+ }
484
+ function guardOutput(text) {
485
+ if (detectLeakage(text)) {
486
+ return "Sorry, I can't share internal instructions. How else can I help?";
487
+ }
488
+ return scrubOutput(text);
489
+ }
490
+
491
+ // src/core/llm.ts
492
+ var DEFAULT_MODEL = "gpt-4o";
493
+ async function completeOnce({
494
+ client,
495
+ system,
496
+ messages,
497
+ temperature = 0.2,
498
+ model = DEFAULT_MODEL
499
+ }) {
500
+ const res = await client.chat.completions.create({
501
+ model,
502
+ temperature,
503
+ messages: [{ role: "system", content: system }, ...messages]
504
+ });
505
+ const text = res.choices[0]?.message?.content ?? "";
506
+ return {
507
+ content: guardOutput(text),
508
+ usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
509
+ };
510
+ }
511
+
512
+ // src/core/answer.ts
513
+ var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
514
+ async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
515
+ const result = await answerFn({ system, messages, mode, sender });
516
+ const raw = typeof result === "string" ? result : result?.content;
517
+ const usage = typeof result === "string" ? void 0 : result?.usage;
518
+ return {
519
+ content: guardOutput(typeof raw === "string" ? raw : ""),
520
+ usage: usage ?? noUsage()
521
+ };
522
+ }
523
+ async function answerOnce({
524
+ answerFn,
525
+ client,
526
+ system,
527
+ messages,
528
+ mode,
529
+ sender,
530
+ temperature,
531
+ model
532
+ }) {
533
+ if (answerFn) {
534
+ return runAnswerFn(answerFn, { system, messages, mode, sender });
535
+ }
536
+ return completeOnce({ client, system, messages, temperature, model });
537
+ }
538
+
539
+ // src/core/buckets.ts
540
+ function defaultBuckets(mode) {
541
+ return ["base", mode];
542
+ }
543
+ function isIdentified(sender) {
544
+ return typeof sender === "string" && sender.trim().length > 0;
545
+ }
546
+ function normalize(buckets) {
547
+ const out = [];
548
+ for (const raw of buckets) {
549
+ if (typeof raw !== "string") continue;
550
+ const bucket = raw.trim();
551
+ if (bucket && !out.includes(bucket)) out.push(bucket);
552
+ }
553
+ return out;
554
+ }
555
+ async function resolveBuckets({
556
+ mode,
557
+ sender,
558
+ bucketsFor
559
+ }) {
560
+ if (!bucketsFor) return void 0;
561
+ const identified = isIdentified(sender);
562
+ const requested = await bucketsFor(identified ? { mode, sender } : { mode });
563
+ if (!Array.isArray(requested)) return void 0;
564
+ const cleaned = normalize(requested);
565
+ if (identified) return cleaned;
566
+ const ceiling = new Set(defaultBuckets(mode));
567
+ return cleaned.filter((bucket) => ceiling.has(bucket));
568
+ }
569
+
570
+ // src/core/pipeline.ts
571
+ var MODE_SETTINGS = {
572
+ public: { topK: 6, label: "Context" },
573
+ private: { topK: 8, label: "Internal Context" }
574
+ };
575
+ async function prepareChat({
576
+ store,
577
+ prompts,
578
+ mode,
579
+ messages,
580
+ personaLayer,
581
+ channelHint,
582
+ buckets
583
+ }) {
584
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
585
+ if (!lastUserMsg) {
586
+ throw new Error("no user message found in conversation");
587
+ }
588
+ const { topK, label } = MODE_SETTINGS[mode];
589
+ const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
590
+ const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
591
+ const hint = channelHint?.trim();
592
+ const system = [
593
+ prompts.baseSystemRules,
594
+ persona,
595
+ ...hint ? [hint] : [],
596
+ `${label}:
597
+ ${ctx.join("\n\n")}`
598
+ ].join("\n\n");
599
+ return { system, messages };
600
+ }
601
+
602
+ // src/channels/gates.ts
603
+ function matches(re, text) {
604
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
605
+ }
606
+ function decideChannelAction(msg, config) {
607
+ if (msg.fromBot || !msg.text.trim()) return "ignore";
608
+ if (!msg.isDirectMessage) {
609
+ if (config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId)) {
610
+ return "ignore";
611
+ }
612
+ if (config.muteRegex && matches(config.muteRegex, msg.text)) return "mute";
613
+ if (config.unmuteRegex && matches(config.unmuteRegex, msg.text)) return "unmute";
614
+ if (config.mutedChats.has(msg.chatId)) return "ignore";
615
+ return msg.mentionsBot || msg.isReplyToBot ? "reply" : "ignore";
616
+ }
617
+ return "reply";
618
+ }
619
+ function createSlidingWindowRateLimiter(maxPerWindow, windowMs, now = Date.now) {
620
+ const log = /* @__PURE__ */ new Map();
621
+ return function allow(key) {
622
+ const cutoff = now() - windowMs;
623
+ const entries = (log.get(key) ?? []).filter((t) => t > cutoff);
624
+ if (entries.length >= maxPerWindow) {
625
+ if (entries.length > 0) log.set(key, entries);
626
+ else log.delete(key);
627
+ return false;
628
+ }
629
+ entries.push(now());
630
+ log.set(key, entries);
631
+ return true;
632
+ };
633
+ }
634
+ function underReplyRateLimit(msg, limiters) {
635
+ return msg.isDirectMessage ? limiters.dm(msg.chatId) : limiters.group(msg.chatId);
636
+ }
637
+ function isFromAnySession(id, registry) {
638
+ if (!id) return false;
639
+ for (const ids of registry.values()) {
640
+ if (ids.includes(id)) return true;
641
+ }
642
+ return false;
643
+ }
644
+ function isEffectivelyFromSelf(msg, registry) {
645
+ return msg.fromBot || isFromAnySession(msg.senderId, registry);
646
+ }
647
+
648
+ // src/channels/whatsapp/inbound.ts
649
+ function isGroupJid(jid) {
650
+ return jid.endsWith("@g.us");
651
+ }
652
+ function jidsMatch(a, b) {
653
+ if (!a || !b) return false;
654
+ const bare = (jid) => jid.split("@")[0]?.split(":")[0] ?? jid;
655
+ return bare(a) === bare(b);
656
+ }
657
+ function jidToPhoneNumber(jid) {
658
+ const bare = jid.split("@")[0]?.split(":")[0] ?? "";
659
+ return bare.startsWith("+") ? bare : `+${bare}`;
660
+ }
661
+ function normalizeJid(jid) {
662
+ const [user = "", domain] = jid.split("@");
663
+ const bareUser = user.split(":")[0] ?? user;
664
+ return domain ? `${bareUser}@${domain}` : bareUser;
665
+ }
666
+ async function senderPhoneFor(sock, message, chatJid) {
667
+ const key = message.key;
668
+ let jid = [key.participantPn, key.senderPn, key.participant, key.remoteJidAlt, chatJid].find(
669
+ (candidate) => typeof candidate === "string" && candidate.length > 0
670
+ ) ?? chatJid;
671
+ if (jid.endsWith("@lid")) {
672
+ try {
673
+ const mapped = await sock.signalRepository?.lidMapping?.getPNForLID?.(jid);
674
+ if (mapped) jid = mapped;
675
+ } catch {
676
+ }
677
+ }
678
+ return jid.endsWith("@lid") ? `lid:${jid.split("@")[0]?.split(":")[0] ?? jid}` : jidToPhoneNumber(jid);
679
+ }
680
+ function messageContext(message) {
681
+ const content = message.message;
682
+ const contextInfo = content?.extendedTextMessage?.contextInfo ?? content?.imageMessage?.contextInfo ?? content?.videoMessage?.contextInfo ?? content?.documentMessage?.contextInfo;
683
+ return {
684
+ mentionedJids: contextInfo?.mentionedJid ?? [],
685
+ quotedParticipantJid: contextInfo?.participant ?? void 0
686
+ };
687
+ }
688
+ function extractText(message) {
689
+ const content = message.message;
690
+ if (!content) return "";
691
+ return content.conversation ?? content.extendedTextMessage?.text ?? content.imageMessage?.caption ?? content.videoMessage?.caption ?? content.documentMessage?.caption ?? "";
692
+ }
693
+ var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
694
+ var DEFAULT_DM_RATE_LIMIT = { max: 20, windowMs: 60 * 60 * 1e3 };
695
+ async function resolvePersonaLayer(config, ctx) {
696
+ if (!config.personaResolver) return void 0;
697
+ try {
698
+ return await config.personaResolver(ctx);
699
+ } catch {
700
+ return void 0;
701
+ }
702
+ }
703
+ function createWhatsAppInboundHandler(config) {
704
+ const mutedChats = /* @__PURE__ */ new Set();
705
+ const now = config.now ?? Date.now;
706
+ const group = config.groupRateLimit ?? DEFAULT_GROUP_RATE_LIMIT;
707
+ const dm = config.dmRateLimit ?? DEFAULT_DM_RATE_LIMIT;
708
+ const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
709
+ const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
710
+ return async function handleWhatsAppMessage(event) {
711
+ const { sessionId, sock, message } = event;
712
+ try {
713
+ const chatId = message.key.remoteJid ?? "";
714
+ if (!chatId || chatId === "status@broadcast") return;
715
+ const text = extractText(message);
716
+ const context = messageContext(message);
717
+ const senderId = message.key.participant ?? chatId;
718
+ const ownIds = [sock.user?.id, sock.user?.lid].filter((id) => Boolean(id)).map(normalizeJid);
719
+ config.registry.set(sessionId, ownIds);
720
+ const fromBot = isEffectivelyFromSelf(
721
+ { fromBot: message.key.fromMe ?? false, senderId: normalizeJid(senderId) },
722
+ config.registry
723
+ );
724
+ const msg = {
725
+ chatId,
726
+ senderId,
727
+ text,
728
+ isDirectMessage: !isGroupJid(chatId),
729
+ mentionsBot: context.mentionedJids.some((jid) => ownIds.some((own) => jidsMatch(jid, own))),
730
+ isReplyToBot: ownIds.some((own) => jidsMatch(context.quotedParticipantJid, own)),
731
+ fromBot
732
+ };
733
+ const action = decideChannelAction(msg, {
734
+ allowedChats: config.allowedChats ?? [],
735
+ mutedChats,
736
+ muteRegex: config.muteRegex,
737
+ unmuteRegex: config.unmuteRegex
738
+ });
739
+ if (action === "mute") {
740
+ mutedChats.add(chatId);
741
+ if (config.muteReply) await sock.sendMessage(chatId, { text: config.muteReply });
742
+ return;
743
+ }
744
+ if (action === "unmute") {
745
+ mutedChats.delete(chatId);
746
+ if (config.unmuteReply) await sock.sendMessage(chatId, { text: config.unmuteReply });
747
+ return;
748
+ }
749
+ if (action !== "reply") return;
750
+ if (!underReplyRateLimit(msg, { dm: underDmRateLimit, group: underGroupRateLimit })) {
751
+ return;
752
+ }
753
+ const senderPhone = await senderPhoneFor(sock, message, chatId);
754
+ const personaLayer = await resolvePersonaLayer(config, { senderPhone, text });
755
+ const buckets = await resolveBuckets({
756
+ mode: "public",
757
+ sender: senderPhone,
758
+ bucketsFor: config.bucketsFor
759
+ });
760
+ const messages = [{ role: "user", content: text }];
761
+ const { system } = await prepareChat({
762
+ store: config.store,
763
+ prompts: config.prompts,
764
+ mode: "public",
765
+ messages,
766
+ personaLayer,
767
+ channelHint: config.channelHint,
768
+ buckets
769
+ });
770
+ const { content } = await answerOnce({
771
+ answerFn: config.answerFn,
772
+ client: config.client,
773
+ system,
774
+ messages,
775
+ mode: "public",
776
+ sender: senderPhone,
777
+ model: config.model
778
+ });
779
+ if (content) {
780
+ await sock.sendMessage(chatId, { text: content }, { quoted: message });
781
+ }
782
+ } catch (error) {
783
+ console.warn(`WhatsApp[${sessionId}]: inbound message handling failed:`, error);
784
+ }
785
+ };
786
+ }
462
787
  // Annotate the CommonJS export names for ESM import in node:
463
788
  0 && (module.exports = {
464
789
  LEASE_HEARTBEAT_MS,
@@ -468,11 +793,18 @@ function createWhatsAppChannel(config) {
468
793
  canAcquireLease,
469
794
  createTursoWaLeaseStore,
470
795
  createWhatsAppChannel,
796
+ createWhatsAppInboundHandler,
471
797
  decrypt,
472
798
  encrypt,
799
+ extractText,
800
+ isGroupJid,
801
+ jidToPhoneNumber,
802
+ jidsMatch,
473
803
  loadBaileys,
804
+ messageContext,
474
805
  reconnectDelayMs,
475
806
  senderNameFor,
807
+ senderPhoneFor,
476
808
  shutdownWaSessions,
477
809
  useTursoAuthState
478
810
  });
@@ -410,6 +410,324 @@ function createWhatsAppChannel(config) {
410
410
  }
411
411
  };
412
412
  }
413
+
414
+ // src/core/guardrails.ts
415
+ function detectLeakage(text) {
416
+ const flags = [
417
+ /system prompt/i,
418
+ /hidden instruction/i,
419
+ /here (are|is) (my|the) rules/i,
420
+ /BEGIN SYSTEM PROMPT/i,
421
+ /tool instructions/i
422
+ ];
423
+ return flags.some((rx) => rx.test(text));
424
+ }
425
+ function scrubOutput(text) {
426
+ return text.replace(/sk-[A-Za-z0-9]{15,}/g, "[REDACTED]").replace(/AIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]");
427
+ }
428
+ function guardOutput(text) {
429
+ if (detectLeakage(text)) {
430
+ return "Sorry, I can't share internal instructions. How else can I help?";
431
+ }
432
+ return scrubOutput(text);
433
+ }
434
+
435
+ // src/core/llm.ts
436
+ var DEFAULT_MODEL = "gpt-4o";
437
+ async function completeOnce({
438
+ client,
439
+ system,
440
+ messages,
441
+ temperature = 0.2,
442
+ model = DEFAULT_MODEL
443
+ }) {
444
+ const res = await client.chat.completions.create({
445
+ model,
446
+ temperature,
447
+ messages: [{ role: "system", content: system }, ...messages]
448
+ });
449
+ const text = res.choices[0]?.message?.content ?? "";
450
+ return {
451
+ content: guardOutput(text),
452
+ usage: res.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
453
+ };
454
+ }
455
+
456
+ // src/core/answer.ts
457
+ var noUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
458
+ async function runAnswerFn(answerFn, { system, messages, mode, sender }) {
459
+ const result = await answerFn({ system, messages, mode, sender });
460
+ const raw = typeof result === "string" ? result : result?.content;
461
+ const usage = typeof result === "string" ? void 0 : result?.usage;
462
+ return {
463
+ content: guardOutput(typeof raw === "string" ? raw : ""),
464
+ usage: usage ?? noUsage()
465
+ };
466
+ }
467
+ async function answerOnce({
468
+ answerFn,
469
+ client,
470
+ system,
471
+ messages,
472
+ mode,
473
+ sender,
474
+ temperature,
475
+ model
476
+ }) {
477
+ if (answerFn) {
478
+ return runAnswerFn(answerFn, { system, messages, mode, sender });
479
+ }
480
+ return completeOnce({ client, system, messages, temperature, model });
481
+ }
482
+
483
+ // src/core/buckets.ts
484
+ function defaultBuckets(mode) {
485
+ return ["base", mode];
486
+ }
487
+ function isIdentified(sender) {
488
+ return typeof sender === "string" && sender.trim().length > 0;
489
+ }
490
+ function normalize(buckets) {
491
+ const out = [];
492
+ for (const raw of buckets) {
493
+ if (typeof raw !== "string") continue;
494
+ const bucket = raw.trim();
495
+ if (bucket && !out.includes(bucket)) out.push(bucket);
496
+ }
497
+ return out;
498
+ }
499
+ async function resolveBuckets({
500
+ mode,
501
+ sender,
502
+ bucketsFor
503
+ }) {
504
+ if (!bucketsFor) return void 0;
505
+ const identified = isIdentified(sender);
506
+ const requested = await bucketsFor(identified ? { mode, sender } : { mode });
507
+ if (!Array.isArray(requested)) return void 0;
508
+ const cleaned = normalize(requested);
509
+ if (identified) return cleaned;
510
+ const ceiling = new Set(defaultBuckets(mode));
511
+ return cleaned.filter((bucket) => ceiling.has(bucket));
512
+ }
513
+
514
+ // src/core/pipeline.ts
515
+ var MODE_SETTINGS = {
516
+ public: { topK: 6, label: "Context" },
517
+ private: { topK: 8, label: "Internal Context" }
518
+ };
519
+ async function prepareChat({
520
+ store,
521
+ prompts,
522
+ mode,
523
+ messages,
524
+ personaLayer,
525
+ channelHint,
526
+ buckets
527
+ }) {
528
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
529
+ if (!lastUserMsg) {
530
+ throw new Error("no user message found in conversation");
531
+ }
532
+ const { topK, label } = MODE_SETTINGS[mode];
533
+ const ctx = await store.query(lastUserMsg.content, topK, buckets ?? defaultBuckets(mode));
534
+ const persona = personaLayer?.trim() || (mode === "private" ? prompts.privatePersona : prompts.publicPersona);
535
+ const hint = channelHint?.trim();
536
+ const system = [
537
+ prompts.baseSystemRules,
538
+ persona,
539
+ ...hint ? [hint] : [],
540
+ `${label}:
541
+ ${ctx.join("\n\n")}`
542
+ ].join("\n\n");
543
+ return { system, messages };
544
+ }
545
+
546
+ // src/channels/gates.ts
547
+ function matches(re, text) {
548
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
549
+ }
550
+ function decideChannelAction(msg, config) {
551
+ if (msg.fromBot || !msg.text.trim()) return "ignore";
552
+ if (!msg.isDirectMessage) {
553
+ if (config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId)) {
554
+ return "ignore";
555
+ }
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
+
592
+ // src/channels/whatsapp/inbound.ts
593
+ function isGroupJid(jid) {
594
+ return jid.endsWith("@g.us");
595
+ }
596
+ function jidsMatch(a, b) {
597
+ if (!a || !b) return false;
598
+ const bare = (jid) => jid.split("@")[0]?.split(":")[0] ?? jid;
599
+ return bare(a) === bare(b);
600
+ }
601
+ function jidToPhoneNumber(jid) {
602
+ const bare = jid.split("@")[0]?.split(":")[0] ?? "";
603
+ return bare.startsWith("+") ? bare : `+${bare}`;
604
+ }
605
+ function normalizeJid(jid) {
606
+ const [user = "", domain] = jid.split("@");
607
+ const bareUser = user.split(":")[0] ?? user;
608
+ return domain ? `${bareUser}@${domain}` : bareUser;
609
+ }
610
+ async function senderPhoneFor(sock, message, chatJid) {
611
+ const key = message.key;
612
+ let jid = [key.participantPn, key.senderPn, key.participant, key.remoteJidAlt, chatJid].find(
613
+ (candidate) => typeof candidate === "string" && candidate.length > 0
614
+ ) ?? chatJid;
615
+ if (jid.endsWith("@lid")) {
616
+ try {
617
+ const mapped = await sock.signalRepository?.lidMapping?.getPNForLID?.(jid);
618
+ if (mapped) jid = mapped;
619
+ } catch {
620
+ }
621
+ }
622
+ return jid.endsWith("@lid") ? `lid:${jid.split("@")[0]?.split(":")[0] ?? jid}` : jidToPhoneNumber(jid);
623
+ }
624
+ function messageContext(message) {
625
+ const content = message.message;
626
+ const contextInfo = content?.extendedTextMessage?.contextInfo ?? content?.imageMessage?.contextInfo ?? content?.videoMessage?.contextInfo ?? content?.documentMessage?.contextInfo;
627
+ return {
628
+ mentionedJids: contextInfo?.mentionedJid ?? [],
629
+ quotedParticipantJid: contextInfo?.participant ?? void 0
630
+ };
631
+ }
632
+ function extractText(message) {
633
+ const content = message.message;
634
+ if (!content) return "";
635
+ return content.conversation ?? content.extendedTextMessage?.text ?? content.imageMessage?.caption ?? content.videoMessage?.caption ?? content.documentMessage?.caption ?? "";
636
+ }
637
+ var DEFAULT_GROUP_RATE_LIMIT = { max: 30, windowMs: 60 * 60 * 1e3 };
638
+ var DEFAULT_DM_RATE_LIMIT = { max: 20, windowMs: 60 * 60 * 1e3 };
639
+ async function resolvePersonaLayer(config, ctx) {
640
+ if (!config.personaResolver) return void 0;
641
+ try {
642
+ return await config.personaResolver(ctx);
643
+ } catch {
644
+ return void 0;
645
+ }
646
+ }
647
+ function createWhatsAppInboundHandler(config) {
648
+ const mutedChats = /* @__PURE__ */ new Set();
649
+ const now = config.now ?? Date.now;
650
+ const group = config.groupRateLimit ?? DEFAULT_GROUP_RATE_LIMIT;
651
+ const dm = config.dmRateLimit ?? DEFAULT_DM_RATE_LIMIT;
652
+ const underGroupRateLimit = createSlidingWindowRateLimiter(group.max, group.windowMs, now);
653
+ const underDmRateLimit = createSlidingWindowRateLimiter(dm.max, dm.windowMs, now);
654
+ return async function handleWhatsAppMessage(event) {
655
+ const { sessionId, sock, message } = event;
656
+ try {
657
+ const chatId = message.key.remoteJid ?? "";
658
+ if (!chatId || chatId === "status@broadcast") return;
659
+ const text = extractText(message);
660
+ const context = messageContext(message);
661
+ const senderId = message.key.participant ?? chatId;
662
+ const ownIds = [sock.user?.id, sock.user?.lid].filter((id) => Boolean(id)).map(normalizeJid);
663
+ config.registry.set(sessionId, ownIds);
664
+ const fromBot = isEffectivelyFromSelf(
665
+ { fromBot: message.key.fromMe ?? false, senderId: normalizeJid(senderId) },
666
+ config.registry
667
+ );
668
+ const msg = {
669
+ chatId,
670
+ senderId,
671
+ text,
672
+ isDirectMessage: !isGroupJid(chatId),
673
+ mentionsBot: context.mentionedJids.some((jid) => ownIds.some((own) => jidsMatch(jid, own))),
674
+ isReplyToBot: ownIds.some((own) => jidsMatch(context.quotedParticipantJid, own)),
675
+ fromBot
676
+ };
677
+ const action = decideChannelAction(msg, {
678
+ allowedChats: config.allowedChats ?? [],
679
+ mutedChats,
680
+ muteRegex: config.muteRegex,
681
+ unmuteRegex: config.unmuteRegex
682
+ });
683
+ if (action === "mute") {
684
+ mutedChats.add(chatId);
685
+ if (config.muteReply) await sock.sendMessage(chatId, { text: config.muteReply });
686
+ return;
687
+ }
688
+ if (action === "unmute") {
689
+ mutedChats.delete(chatId);
690
+ if (config.unmuteReply) await sock.sendMessage(chatId, { text: config.unmuteReply });
691
+ return;
692
+ }
693
+ if (action !== "reply") return;
694
+ if (!underReplyRateLimit(msg, { dm: underDmRateLimit, group: underGroupRateLimit })) {
695
+ return;
696
+ }
697
+ const senderPhone = await senderPhoneFor(sock, message, chatId);
698
+ const personaLayer = await resolvePersonaLayer(config, { senderPhone, text });
699
+ const buckets = await resolveBuckets({
700
+ mode: "public",
701
+ sender: senderPhone,
702
+ bucketsFor: config.bucketsFor
703
+ });
704
+ const messages = [{ role: "user", content: text }];
705
+ const { system } = await prepareChat({
706
+ store: config.store,
707
+ prompts: config.prompts,
708
+ mode: "public",
709
+ messages,
710
+ personaLayer,
711
+ channelHint: config.channelHint,
712
+ buckets
713
+ });
714
+ const { content } = await answerOnce({
715
+ answerFn: config.answerFn,
716
+ client: config.client,
717
+ system,
718
+ messages,
719
+ mode: "public",
720
+ sender: senderPhone,
721
+ model: config.model
722
+ });
723
+ if (content) {
724
+ await sock.sendMessage(chatId, { text: content }, { quoted: message });
725
+ }
726
+ } catch (error) {
727
+ console.warn(`WhatsApp[${sessionId}]: inbound message handling failed:`, error);
728
+ }
729
+ };
730
+ }
413
731
  export {
414
732
  LEASE_HEARTBEAT_MS,
415
733
  LEASE_STALE_MS,
@@ -418,11 +736,18 @@ export {
418
736
  canAcquireLease,
419
737
  createTursoWaLeaseStore,
420
738
  createWhatsAppChannel,
739
+ createWhatsAppInboundHandler,
421
740
  decrypt,
422
741
  encrypt,
742
+ extractText,
743
+ isGroupJid,
744
+ jidToPhoneNumber,
745
+ jidsMatch,
423
746
  loadBaileys,
747
+ messageContext,
424
748
  reconnectDelayMs,
425
749
  senderNameFor,
750
+ senderPhoneFor,
426
751
  shutdownWaSessions,
427
752
  useTursoAuthState
428
753
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegoaltoworks/chatter",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Embeddable AI chatbot framework with RAG, authentication, and customizable widgets",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",