@diegoaltoworks/chatter 0.27.0 → 0.29.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 (42) hide show
  1. package/dist/bin/wa-pair.js +320 -0
  2. package/dist/channels/index.d.ts +47 -0
  3. package/dist/channels/index.d.ts.map +1 -0
  4. package/dist/channels/index.js +137 -0
  5. package/dist/channels/index.mjs +105 -0
  6. package/dist/channels/senders.d.ts +30 -0
  7. package/dist/channels/senders.d.ts.map +1 -0
  8. package/dist/channels/senders.test.d.ts +2 -0
  9. package/dist/channels/senders.test.d.ts.map +1 -0
  10. package/dist/channels/whatsapp/authState.d.ts +39 -0
  11. package/dist/channels/whatsapp/authState.d.ts.map +1 -0
  12. package/dist/channels/whatsapp/authState.test.d.ts +2 -0
  13. package/dist/channels/whatsapp/authState.test.d.ts.map +1 -0
  14. package/dist/channels/whatsapp/baileys.d.ts +21 -0
  15. package/dist/channels/whatsapp/baileys.d.ts.map +1 -0
  16. package/dist/channels/whatsapp/baileys.test.d.ts +2 -0
  17. package/dist/channels/whatsapp/baileys.test.d.ts.map +1 -0
  18. package/dist/channels/whatsapp/channel.d.ts +95 -0
  19. package/dist/channels/whatsapp/channel.d.ts.map +1 -0
  20. package/dist/channels/whatsapp/channel.test.d.ts +2 -0
  21. package/dist/channels/whatsapp/channel.test.d.ts.map +1 -0
  22. package/dist/channels/whatsapp/crypto.d.ts +10 -0
  23. package/dist/channels/whatsapp/crypto.d.ts.map +1 -0
  24. package/dist/channels/whatsapp/crypto.test.d.ts +2 -0
  25. package/dist/channels/whatsapp/crypto.test.d.ts.map +1 -0
  26. package/dist/channels/whatsapp/index.d.ts +33 -0
  27. package/dist/channels/whatsapp/index.d.ts.map +1 -0
  28. package/dist/channels/whatsapp/index.js +478 -0
  29. package/dist/channels/whatsapp/index.mjs +428 -0
  30. package/dist/channels/whatsapp/lease.d.ts +42 -0
  31. package/dist/channels/whatsapp/lease.d.ts.map +1 -0
  32. package/dist/channels/whatsapp/lease.test.d.ts +2 -0
  33. package/dist/channels/whatsapp/lease.test.d.ts.map +1 -0
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +83 -2
  37. package/dist/index.mjs +83 -2
  38. package/dist/server.d.ts +21 -2
  39. package/dist/server.d.ts.map +1 -1
  40. package/dist/types.d.ts +17 -0
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +29 -4
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // bin/wa-pair.ts
27
+ var import_client = require("@libsql/client");
28
+
29
+ // src/channels/whatsapp/crypto.ts
30
+ var import_node_crypto = require("node:crypto");
31
+ var IV_LENGTH = 12;
32
+ var TAG_LENGTH = 16;
33
+ function deriveKey(secret) {
34
+ return (0, import_node_crypto.createHash)("sha256").update(secret, "utf-8").digest();
35
+ }
36
+ function encrypt(plaintext, secret) {
37
+ const iv = (0, import_node_crypto.randomBytes)(IV_LENGTH);
38
+ const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", deriveKey(secret), iv);
39
+ const ciphertext = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
40
+ return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64");
41
+ }
42
+ function decrypt(payload, secret) {
43
+ const raw = Buffer.from(payload, "base64");
44
+ const iv = raw.subarray(0, IV_LENGTH);
45
+ const tag = raw.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
46
+ const ciphertext = raw.subarray(IV_LENGTH + TAG_LENGTH);
47
+ const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", deriveKey(secret), iv);
48
+ decipher.setAuthTag(tag);
49
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
50
+ }
51
+
52
+ // src/channels/whatsapp/authState.ts
53
+ var schemaReady = /* @__PURE__ */ new WeakMap();
54
+ function ensureAuthSchema(client) {
55
+ let ready = schemaReady.get(client);
56
+ if (!ready) {
57
+ ready = client.execute("CREATE TABLE IF NOT EXISTS wa_auth (id TEXT PRIMARY KEY, value TEXT NOT NULL)").catch((error) => {
58
+ schemaReady.delete(client);
59
+ throw error;
60
+ });
61
+ schemaReady.set(client, ready);
62
+ }
63
+ return ready;
64
+ }
65
+ async function useTursoAuthState(db2, secret, sessionId2, runtime) {
66
+ await ensureAuthSchema(db2);
67
+ const rowId = (id) => sessionId2 === "default" ? id : `s:${sessionId2}/${id}`;
68
+ async function read(id) {
69
+ const result = await db2.execute({
70
+ sql: "SELECT value FROM wa_auth WHERE id = ?",
71
+ args: [rowId(id)]
72
+ });
73
+ const row = result.rows[0];
74
+ if (!row) return null;
75
+ return JSON.parse(decrypt(row.value, secret), runtime.bufferJSON.reviver);
76
+ }
77
+ async function write(id, value) {
78
+ const payload = encrypt(JSON.stringify(value, runtime.bufferJSON.replacer), secret);
79
+ await db2.execute({
80
+ sql: "INSERT OR REPLACE INTO wa_auth (id, value) VALUES (?, ?)",
81
+ args: [rowId(id), payload]
82
+ });
83
+ }
84
+ async function remove(id) {
85
+ await db2.execute({ sql: "DELETE FROM wa_auth WHERE id = ?", args: [rowId(id)] });
86
+ }
87
+ const creds = await read("creds") ?? runtime.initAuthCreds();
88
+ return {
89
+ state: {
90
+ creds,
91
+ keys: {
92
+ get: async (type, ids) => {
93
+ const data = {};
94
+ await Promise.all(
95
+ ids.map(async (id) => {
96
+ let value = await read(`${type}-${id}`);
97
+ if (type === "app-state-sync-key" && value) {
98
+ value = runtime.appStateSyncKeyFromObject(
99
+ value
100
+ );
101
+ }
102
+ if (value) data[id] = value;
103
+ })
104
+ );
105
+ return data;
106
+ },
107
+ set: async (data) => {
108
+ const jobs = [];
109
+ for (const category of Object.keys(data)) {
110
+ const entries = data[category];
111
+ if (!entries) continue;
112
+ for (const id of Object.keys(entries)) {
113
+ const value = entries[id];
114
+ jobs.push(value ? write(`${category}-${id}`, value) : remove(`${category}-${id}`));
115
+ }
116
+ }
117
+ await Promise.all(jobs);
118
+ }
119
+ }
120
+ },
121
+ saveCreds: () => write("creds", creds),
122
+ clear: async () => {
123
+ if (sessionId2 === "default") {
124
+ await db2.execute("DELETE FROM wa_auth WHERE id NOT LIKE 's:%'");
125
+ } else {
126
+ await db2.execute({
127
+ sql: "DELETE FROM wa_auth WHERE id LIKE ?",
128
+ args: [`s:${sessionId2}/%`]
129
+ });
130
+ }
131
+ }
132
+ };
133
+ }
134
+
135
+ // src/channels/whatsapp/baileys.ts
136
+ function wrapMissingBaileysError(cause) {
137
+ return new Error(
138
+ "The WhatsApp channel ('@diegoaltoworks/chatter/whatsapp') requires the optional peer dependency '@whiskeysockets/baileys', which is not installed. Install it with `bun add @whiskeysockets/baileys` (or npm/pnpm/yarn) to use this channel.",
139
+ { cause }
140
+ );
141
+ }
142
+ var cached;
143
+ function loadBaileys() {
144
+ if (!cached) {
145
+ cached = import("@whiskeysockets/baileys").catch((error) => {
146
+ cached = void 0;
147
+ throw wrapMissingBaileysError(error);
148
+ });
149
+ }
150
+ return cached;
151
+ }
152
+
153
+ // bin/wa-pair.ts
154
+ var args = process.argv.slice(2);
155
+ function flagValue(name) {
156
+ const index = args.indexOf(name);
157
+ return index >= 0 ? args[index + 1] : void 0;
158
+ }
159
+ if (args.includes("--help") || args.includes("-h")) {
160
+ console.log(`
161
+ WhatsApp pairing CLI
162
+
163
+ Usage:
164
+ bun run wa-pair [sessionId] [options]
165
+
166
+ Arguments:
167
+ sessionId Which session to pair (default: "default"). Use a
168
+ distinct id per WhatsApp number for multi-session.
169
+
170
+ Options:
171
+ --code <phoneNumber> Pair via pairing code instead of QR (digits only,
172
+ e.g. 447700900123). For headless setups.
173
+ --reset Wipe the stored session first, then pair fresh.
174
+ --help, -h Show this help.
175
+
176
+ Environment:
177
+ TURSO_URL, TURSO_AUTH_TOKEN Required. The database auth state is stored in.
178
+ WA_SESSION_SECRET Required. Encrypts the stored session at rest.
179
+ `);
180
+ process.exit(0);
181
+ }
182
+ var sessionId = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--code") ?? "default";
183
+ if (args.includes("--code") && !flagValue("--code")) {
184
+ console.error("\u274C --code requires a phone number, e.g. --code 447700900123");
185
+ process.exit(1);
186
+ }
187
+ var phoneNumber = flagValue("--code")?.replace(/[^0-9]/g, "");
188
+ var reset = args.includes("--reset");
189
+ var sessionSecret = process.env.WA_SESSION_SECRET;
190
+ if (!sessionSecret) {
191
+ console.error("\u274C WA_SESSION_SECRET is required (any strong passphrase; keep it stable).");
192
+ process.exit(1);
193
+ }
194
+ if (!process.env.TURSO_URL) {
195
+ console.error("\u274C TURSO_URL is required.");
196
+ process.exit(1);
197
+ }
198
+ var db = (0, import_client.createClient)({
199
+ url: process.env.TURSO_URL,
200
+ authToken: process.env.TURSO_AUTH_TOKEN || ""
201
+ });
202
+ var MAX_RECONNECTS = 12;
203
+ var codeRequested = false;
204
+ var reconnects = 0;
205
+ var didReset = false;
206
+ async function start() {
207
+ const baileys = await loadBaileys().catch((error) => {
208
+ console.error(`\u274C ${error.message}`);
209
+ process.exit(1);
210
+ });
211
+ const runtime = {
212
+ bufferJSON: baileys.BufferJSON,
213
+ initAuthCreds: baileys.initAuthCreds,
214
+ appStateSyncKeyFromObject: (value) => baileys.proto.Message.AppStateSyncKeyData.fromObject(value)
215
+ };
216
+ const { state, saveCreds, clear } = await useTursoAuthState(
217
+ db,
218
+ sessionSecret,
219
+ sessionId,
220
+ runtime
221
+ );
222
+ if (reset && !didReset) {
223
+ didReset = true;
224
+ await clear();
225
+ console.log(`Session "${sessionId}" wiped. Pairing fresh...`);
226
+ return start();
227
+ }
228
+ if (state.creds.registered && !codeRequested) {
229
+ console.log(
230
+ `Already paired. Session "${sessionId}" is connected-ready. Run with --reset to re-pair.`
231
+ );
232
+ process.exit(0);
233
+ }
234
+ const { version } = await baileys.fetchLatestBaileysVersion();
235
+ const noop = () => void 0;
236
+ const logger = {
237
+ level: "silent",
238
+ trace: noop,
239
+ debug: noop,
240
+ info: noop,
241
+ warn: noop,
242
+ error: noop,
243
+ fatal: noop
244
+ };
245
+ logger.child = () => logger;
246
+ const sock = baileys.makeWASocket({
247
+ version,
248
+ auth: {
249
+ creds: state.creds,
250
+ // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
251
+ keys: baileys.makeCacheableSignalKeyStore(state.keys, logger)
252
+ },
253
+ // biome-ignore lint/suspicious/noExplicitAny: structural pino-compatible logger
254
+ logger,
255
+ markOnlineOnConnect: false
256
+ });
257
+ sock.ev.on("creds.update", saveCreds);
258
+ sock.ev.on("connection.update", async (update) => {
259
+ const { connection, lastDisconnect, qr } = update;
260
+ if (qr && !phoneNumber) {
261
+ const qrcodeTerminal = await import("qrcode-terminal").catch(() => void 0);
262
+ if (!qrcodeTerminal) {
263
+ console.error(
264
+ "\u274C QR mode requires the optional peer dependency 'qrcode-terminal'. Install it with `bun add qrcode-terminal`, or re-run with --code <phoneNumber> for pairing-code mode instead."
265
+ );
266
+ process.exit(1);
267
+ }
268
+ console.log(
269
+ "\nScan this QR code: WhatsApp -> Settings -> Linked Devices -> Link a Device.\n"
270
+ );
271
+ qrcodeTerminal.default.generate(qr, { small: true });
272
+ }
273
+ if (connection === "open") {
274
+ console.log(
275
+ `
276
+ \u2705 Paired session "${sessionId}" as ${sock.user?.id}. Session saved (encrypted).`
277
+ );
278
+ console.log("The running server will pick this session up on its next connect.");
279
+ process.exit(0);
280
+ }
281
+ if (connection === "close") {
282
+ const statusCode = lastDisconnect?.error?.output?.statusCode;
283
+ if (codeRequested && reconnects < MAX_RECONNECTS) {
284
+ reconnects++;
285
+ console.log(
286
+ `Connection closed (${statusCode ?? "?"}) - reconnecting (code STILL VALID, keep going on the phone) (${reconnects}/${MAX_RECONNECTS})...`
287
+ );
288
+ setTimeout(() => void start(), 2e3);
289
+ return;
290
+ }
291
+ if (statusCode === baileys.DisconnectReason.loggedOut) {
292
+ console.error("\u274C Pairing rejected (logged out). Run wa-pair again for a fresh code.");
293
+ } else {
294
+ console.error(`\u274C Connection closed (${statusCode ?? "?"}). Run wa-pair again.`);
295
+ }
296
+ process.exit(1);
297
+ }
298
+ });
299
+ if (phoneNumber && !codeRequested && !state.creds.registered) {
300
+ setTimeout(async () => {
301
+ try {
302
+ const code = await sock.requestPairingCode(phoneNumber);
303
+ codeRequested = true;
304
+ console.log("\n==============================================");
305
+ console.log(` Pairing code for +${phoneNumber}: ${code}`);
306
+ console.log("==============================================");
307
+ console.log("On the phone: WhatsApp -> Settings -> Linked Devices ->");
308
+ console.log("Link a Device -> 'Link with phone number instead' -> enter the code.");
309
+ console.log("Waiting for confirmation (leave this running)...");
310
+ } catch (error) {
311
+ console.error("\u274C Failed to request pairing code:", error);
312
+ process.exit(1);
313
+ }
314
+ }, 3e3);
315
+ }
316
+ }
317
+ start().catch((error) => {
318
+ console.error("\u274C Unexpected error:", error);
319
+ process.exit(1);
320
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Channel SPI — the seam a transport (WhatsApp, or any future channel)
3
+ * implements to attach to a chatter server. `createServer` starts every
4
+ * configured channel with the same {@link ServerDependencies} route
5
+ * factories and custom routes receive, so a channel can call
6
+ * `prepareChat`/`answerOnce`, share `deps.db`, etc. without knowing how the
7
+ * server was assembled — and `channel.start(deps)` works identically when
8
+ * called directly, outside `createServer`, for standalone use.
9
+ *
10
+ * Published as the `@diegoaltoworks/chatter/channels` subpath alongside the
11
+ * pure decision gates ({@link decideChannelAction} and friends) and the
12
+ * {@link ChannelSenderRegistry}, so a transport package needs one import for
13
+ * the whole channel toolkit. Zero required dependencies beyond the core
14
+ * package itself.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ import type { ServerDependencies } from "../types";
19
+ export interface Channel {
20
+ /** Human-readable name, used in start-failure log lines and as the sender-registry key. */
21
+ name: string;
22
+ /**
23
+ * Called once, after routes are mounted, with the full server dependencies.
24
+ * Awaited if it returns a promise. A rejection/throw is logged and isolated
25
+ * — one channel failing to start never crashes the server or the other
26
+ * configured channels.
27
+ *
28
+ * `createServer` awaits this before the app is handed back and starts
29
+ * serving requests, so `start` should return once the transport is
30
+ * *initiated* (a socket opened, a listener registered) — not once a remote
31
+ * connection or pairing flow completes. An implementation that awaits a
32
+ * slow handshake blocks the whole server from ever starting.
33
+ */
34
+ start(deps: ServerDependencies): void | Promise<void>;
35
+ /**
36
+ * Called by the host's own shutdown path via the disposer `createServer`
37
+ * returns (`app.stopChannels()`, see `ChatterApp` in `./server`) — never
38
+ * automatically on a process signal, since a library installing its own
39
+ * SIGTERM/SIGINT handler and exiting the process would race a host's
40
+ * shutdown logic.
41
+ */
42
+ stop?(): void | Promise<void>;
43
+ }
44
+ export * from "./gates";
45
+ export type { ChannelSender, ChannelSenderRegistry } from "./senders";
46
+ export { createSenderRegistry } from "./senders";
47
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/channels/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAEnD,MAAM,WAAW,OAAO;IACtB,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,IAAI,EAAE,kBAAkB,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD;;;;;;OAMG;IACH,IAAI,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/B;AAED,cAAc,SAAS,CAAC;AACxB,YAAY,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,MAAM,WAAW,CAAC"}
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/channels/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ createSenderRegistry: () => createSenderRegistry,
24
+ createSlidingWindowRateLimiter: () => createSlidingWindowRateLimiter,
25
+ decideChannelAction: () => decideChannelAction,
26
+ isEffectivelyFromSelf: () => isEffectivelyFromSelf,
27
+ isFromAnySession: () => isFromAnySession,
28
+ underReplyRateLimit: () => underReplyRateLimit
29
+ });
30
+ module.exports = __toCommonJS(index_exports);
31
+
32
+ // src/channels/gates.ts
33
+ function matches(re, text) {
34
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
35
+ }
36
+ function decideChannelAction(msg, config) {
37
+ if (msg.fromBot || !msg.text.trim()) return "ignore";
38
+ if (!msg.isDirectMessage) {
39
+ if (config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId)) {
40
+ return "ignore";
41
+ }
42
+ if (config.muteRegex && matches(config.muteRegex, msg.text)) return "mute";
43
+ if (config.unmuteRegex && matches(config.unmuteRegex, msg.text)) return "unmute";
44
+ if (config.mutedChats.has(msg.chatId)) return "ignore";
45
+ return msg.mentionsBot || msg.isReplyToBot ? "reply" : "ignore";
46
+ }
47
+ return "reply";
48
+ }
49
+ function createSlidingWindowRateLimiter(maxPerWindow, windowMs, now = Date.now) {
50
+ const log = /* @__PURE__ */ new Map();
51
+ return function allow(key) {
52
+ const cutoff = now() - windowMs;
53
+ const entries = (log.get(key) ?? []).filter((t) => t > cutoff);
54
+ if (entries.length >= maxPerWindow) {
55
+ if (entries.length > 0) log.set(key, entries);
56
+ else log.delete(key);
57
+ return false;
58
+ }
59
+ entries.push(now());
60
+ log.set(key, entries);
61
+ return true;
62
+ };
63
+ }
64
+ function underReplyRateLimit(msg, limiters) {
65
+ return msg.isDirectMessage ? limiters.dm(msg.chatId) : limiters.group(msg.chatId);
66
+ }
67
+ function isFromAnySession(id, registry) {
68
+ if (!id) return false;
69
+ for (const ids of registry.values()) {
70
+ if (ids.includes(id)) return true;
71
+ }
72
+ return false;
73
+ }
74
+ function isEffectivelyFromSelf(msg, registry) {
75
+ return msg.fromBot || isFromAnySession(msg.senderId, registry);
76
+ }
77
+
78
+ // src/channels/senders.ts
79
+ function createSenderRegistry() {
80
+ const senders = /* @__PURE__ */ new Map();
81
+ return {
82
+ register(name, sender) {
83
+ if (senders.has(name)) {
84
+ console.warn(`Channel sender "${name}" is already registered; overwriting.`);
85
+ }
86
+ senders.set(name, sender);
87
+ },
88
+ unregister(name) {
89
+ senders.delete(name);
90
+ },
91
+ available(name) {
92
+ return senders.has(name);
93
+ },
94
+ async sendText(name, chatId, text) {
95
+ const sender = senders.get(name);
96
+ if (!sender) return false;
97
+ try {
98
+ await sender.sendText(chatId, text);
99
+ return true;
100
+ } catch (error) {
101
+ console.warn(`Channel sender "${name}" sendText failed:`, error);
102
+ return false;
103
+ }
104
+ },
105
+ async sendVoice(name, chatId, payload) {
106
+ const sender = senders.get(name);
107
+ if (!sender?.sendVoice) return false;
108
+ try {
109
+ await sender.sendVoice(chatId, payload);
110
+ return true;
111
+ } catch (error) {
112
+ console.warn(`Channel sender "${name}" sendVoice failed:`, error);
113
+ return false;
114
+ }
115
+ },
116
+ async sendMedia(name, chatId, payload) {
117
+ const sender = senders.get(name);
118
+ if (!sender?.sendMedia) return false;
119
+ try {
120
+ await sender.sendMedia(chatId, payload);
121
+ return true;
122
+ } catch (error) {
123
+ console.warn(`Channel sender "${name}" sendMedia failed:`, error);
124
+ return false;
125
+ }
126
+ }
127
+ };
128
+ }
129
+ // Annotate the CommonJS export names for ESM import in node:
130
+ 0 && (module.exports = {
131
+ createSenderRegistry,
132
+ createSlidingWindowRateLimiter,
133
+ decideChannelAction,
134
+ isEffectivelyFromSelf,
135
+ isFromAnySession,
136
+ underReplyRateLimit
137
+ });
@@ -0,0 +1,105 @@
1
+ // src/channels/gates.ts
2
+ function matches(re, text) {
3
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, "")).test(text);
4
+ }
5
+ function decideChannelAction(msg, config) {
6
+ if (msg.fromBot || !msg.text.trim()) return "ignore";
7
+ if (!msg.isDirectMessage) {
8
+ if (config.allowedChats.length > 0 && !config.allowedChats.includes(msg.chatId)) {
9
+ return "ignore";
10
+ }
11
+ if (config.muteRegex && matches(config.muteRegex, msg.text)) return "mute";
12
+ if (config.unmuteRegex && matches(config.unmuteRegex, msg.text)) return "unmute";
13
+ if (config.mutedChats.has(msg.chatId)) return "ignore";
14
+ return msg.mentionsBot || msg.isReplyToBot ? "reply" : "ignore";
15
+ }
16
+ return "reply";
17
+ }
18
+ function createSlidingWindowRateLimiter(maxPerWindow, windowMs, now = Date.now) {
19
+ const log = /* @__PURE__ */ new Map();
20
+ return function allow(key) {
21
+ const cutoff = now() - windowMs;
22
+ const entries = (log.get(key) ?? []).filter((t) => t > cutoff);
23
+ if (entries.length >= maxPerWindow) {
24
+ if (entries.length > 0) log.set(key, entries);
25
+ else log.delete(key);
26
+ return false;
27
+ }
28
+ entries.push(now());
29
+ log.set(key, entries);
30
+ return true;
31
+ };
32
+ }
33
+ function underReplyRateLimit(msg, limiters) {
34
+ return msg.isDirectMessage ? limiters.dm(msg.chatId) : limiters.group(msg.chatId);
35
+ }
36
+ function isFromAnySession(id, registry) {
37
+ if (!id) return false;
38
+ for (const ids of registry.values()) {
39
+ if (ids.includes(id)) return true;
40
+ }
41
+ return false;
42
+ }
43
+ function isEffectivelyFromSelf(msg, registry) {
44
+ return msg.fromBot || isFromAnySession(msg.senderId, registry);
45
+ }
46
+
47
+ // src/channels/senders.ts
48
+ function createSenderRegistry() {
49
+ const senders = /* @__PURE__ */ new Map();
50
+ return {
51
+ register(name, sender) {
52
+ if (senders.has(name)) {
53
+ console.warn(`Channel sender "${name}" is already registered; overwriting.`);
54
+ }
55
+ senders.set(name, sender);
56
+ },
57
+ unregister(name) {
58
+ senders.delete(name);
59
+ },
60
+ available(name) {
61
+ return senders.has(name);
62
+ },
63
+ async sendText(name, chatId, text) {
64
+ const sender = senders.get(name);
65
+ if (!sender) return false;
66
+ try {
67
+ await sender.sendText(chatId, text);
68
+ return true;
69
+ } catch (error) {
70
+ console.warn(`Channel sender "${name}" sendText failed:`, error);
71
+ return false;
72
+ }
73
+ },
74
+ async sendVoice(name, chatId, payload) {
75
+ const sender = senders.get(name);
76
+ if (!sender?.sendVoice) return false;
77
+ try {
78
+ await sender.sendVoice(chatId, payload);
79
+ return true;
80
+ } catch (error) {
81
+ console.warn(`Channel sender "${name}" sendVoice failed:`, error);
82
+ return false;
83
+ }
84
+ },
85
+ async sendMedia(name, chatId, payload) {
86
+ const sender = senders.get(name);
87
+ if (!sender?.sendMedia) return false;
88
+ try {
89
+ await sender.sendMedia(chatId, payload);
90
+ return true;
91
+ } catch (error) {
92
+ console.warn(`Channel sender "${name}" sendMedia failed:`, error);
93
+ return false;
94
+ }
95
+ }
96
+ };
97
+ }
98
+ export {
99
+ createSenderRegistry,
100
+ createSlidingWindowRateLimiter,
101
+ decideChannelAction,
102
+ isEffectivelyFromSelf,
103
+ isFromAnySession,
104
+ underReplyRateLimit
105
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Channel-agnostic outbound registry. Brain-side features (a scheduler, the
3
+ * flows engine) send messages by channel name without importing a transport
4
+ * or knowing whether it is currently connected — a channel registers its
5
+ * sender on start and unregisters it on stop, and every send degrades to
6
+ * `false` instead of throwing when the name is unknown or the underlying
7
+ * send fails, so a disconnected channel can never crash a caller.
8
+ */
9
+ /** What a channel implements to receive outbound sends. Voice/media payload shapes are transport-defined. */
10
+ export interface ChannelSender {
11
+ sendText(chatId: string, text: string): Promise<void>;
12
+ /** Optional: channels without voice support (or test fakes) stay valid without it. */
13
+ sendVoice?(chatId: string, payload: unknown): Promise<void>;
14
+ /** Optional: channels without media support (or test fakes) stay valid without it. */
15
+ sendMedia?(chatId: string, payload: unknown): Promise<void>;
16
+ }
17
+ export interface ChannelSenderRegistry {
18
+ register(name: string, sender: ChannelSender): void;
19
+ unregister(name: string): void;
20
+ /** True when a sender is registered under `name` (regardless of what it optionally supports). */
21
+ available(name: string): boolean;
22
+ sendText(name: string, chatId: string, text: string): Promise<boolean>;
23
+ /** False (not a throw) when the sender is missing, disconnected, or lacks voice support. */
24
+ sendVoice(name: string, chatId: string, payload: unknown): Promise<boolean>;
25
+ /** False (not a throw) when the sender is missing, disconnected, or lacks media support. */
26
+ sendMedia(name: string, chatId: string, payload: unknown): Promise<boolean>;
27
+ }
28
+ /** One registry per server: channels register on start, callers send by name. */
29
+ export declare function createSenderRegistry(): ChannelSenderRegistry;
30
+ //# sourceMappingURL=senders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"senders.d.ts","sourceRoot":"","sources":["../../src/channels/senders.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,6GAA6G;AAC7G,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,sFAAsF;IACtF,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5D,sFAAsF;IACtF,SAAS,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,IAAI,CAAC;IACpD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iGAAiG;IACjG,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACvE,4FAA4F;IAC5F,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5E,4FAA4F;IAC5F,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC7E;AAED,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,qBAAqB,CAkD5D"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=senders.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"senders.test.d.ts","sourceRoot":"","sources":["../../src/channels/senders.test.ts"],"names":[],"mappings":""}