aiden-runtime 4.0.2 → 4.1.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 (108) hide show
  1. package/README.md +11 -7
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +421 -5
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +256 -55
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +16 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/show.js +43 -0
  20. package/dist/cli/v4/commands/skills.js +169 -4
  21. package/dist/cli/v4/commands/status.js +84 -0
  22. package/dist/cli/v4/commands/subagent.js +78 -0
  23. package/dist/cli/v4/commands/verbose.js +1 -1
  24. package/dist/cli/v4/commands/voice.js +218 -0
  25. package/dist/cli/v4/cronCli.js +103 -0
  26. package/dist/cli/v4/display.js +297 -13
  27. package/dist/cli/v4/doctor.js +41 -0
  28. package/dist/cli/v4/envSources.js +105 -0
  29. package/dist/cli/v4/ghostMatch.js +74 -0
  30. package/dist/cli/v4/historyStore.js +163 -0
  31. package/dist/cli/v4/pasteCompression.js +124 -0
  32. package/dist/cli/v4/pasteIntercept.js +203 -0
  33. package/dist/cli/v4/replyRenderer.js +209 -0
  34. package/dist/cli/v4/resizeGuard.js +92 -0
  35. package/dist/cli/v4/shellInterpolation.js +139 -0
  36. package/dist/cli/v4/skinEngine.js +21 -1
  37. package/dist/cli/v4/streamingPrefix.js +121 -0
  38. package/dist/cli/v4/syntaxHighlight.js +345 -0
  39. package/dist/cli/v4/table.js +216 -0
  40. package/dist/cli/v4/themeDetect.js +81 -0
  41. package/dist/cli/v4/uiBuild.js +74 -0
  42. package/dist/cli/v4/voiceCli.js +113 -0
  43. package/dist/cli/v4/voicePromptApi.js +196 -0
  44. package/dist/core/channels/discord.js +16 -10
  45. package/dist/core/channels/email.js +13 -9
  46. package/dist/core/channels/imessage.js +13 -9
  47. package/dist/core/channels/manager.js +25 -7
  48. package/dist/core/channels/pdf-extract.js +180 -0
  49. package/dist/core/channels/photo-vision.js +157 -0
  50. package/dist/core/channels/signal.js +11 -7
  51. package/dist/core/channels/slack.js +13 -10
  52. package/dist/core/channels/telegram-commands.js +154 -0
  53. package/dist/core/channels/telegram-groups.js +198 -0
  54. package/dist/core/channels/telegram-rate-limit.js +124 -0
  55. package/dist/core/channels/telegram.js +1980 -0
  56. package/dist/core/channels/twilio.js +11 -7
  57. package/dist/core/channels/webhook.js +9 -5
  58. package/dist/core/channels/whatsapp.js +15 -11
  59. package/dist/core/channels/whisper-transcribe.js +163 -0
  60. package/dist/core/cronManager.js +33 -294
  61. package/dist/core/gateway.js +29 -8
  62. package/dist/core/playwrightBridge.js +90 -0
  63. package/dist/core/v4/aidenAgent.js +35 -0
  64. package/dist/core/v4/auxiliaryClient.js +2 -2
  65. package/dist/core/v4/cron/atomicWrite.js +18 -4
  66. package/dist/core/v4/cron/cronExecute.js +300 -0
  67. package/dist/core/v4/cron/cronManager.js +502 -0
  68. package/dist/core/v4/cron/cronState.js +314 -0
  69. package/dist/core/v4/cron/cronTick.js +90 -0
  70. package/dist/core/v4/cron/diagnostics.js +104 -0
  71. package/dist/core/v4/cron/graceWindow.js +79 -0
  72. package/dist/core/v4/logger/factory.js +110 -0
  73. package/dist/core/v4/logger/index.js +22 -0
  74. package/dist/core/v4/logger/logger.js +101 -0
  75. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  76. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  77. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  78. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  79. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  80. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  81. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  82. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  83. package/dist/core/v4/platformPaths.js +105 -0
  84. package/dist/core/v4/providerFallback.js +25 -0
  85. package/dist/core/v4/skillLoader.js +21 -5
  86. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  87. package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
  88. package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
  89. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  90. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  91. package/dist/core/v4/subagent/budget.js +76 -0
  92. package/dist/core/v4/subagent/diagnostics.js +22 -0
  93. package/dist/core/v4/subagent/fanout.js +216 -0
  94. package/dist/core/v4/subagent/merger.js +148 -0
  95. package/dist/core/v4/subagent/providerRotation.js +54 -0
  96. package/dist/core/v4/voice/audioStream.js +373 -0
  97. package/dist/core/v4/voice/cliVoice.js +393 -0
  98. package/dist/core/v4/voice/diagnostics.js +66 -0
  99. package/dist/core/v4/voice/ttsStream.js +193 -0
  100. package/dist/core/version.js +1 -1
  101. package/dist/core/visionAnalyze.js +291 -90
  102. package/dist/core/voice/audio.js +61 -5
  103. package/dist/core/voice/audioBackend.js +134 -0
  104. package/dist/core/voice/stt.js +61 -6
  105. package/dist/core/voice/tts.js +19 -3
  106. package/dist/tools/v4/index.js +32 -1
  107. package/dist/tools/v4/subagent/subagentFanout.js +166 -0
  108. package/package.json +11 -2
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.TelegramCommandRouter = void 0;
8
+ const logger_1 = require("../v4/logger");
9
+ class TelegramCommandRouter {
10
+ constructor(opts) {
11
+ this.store = opts.store;
12
+ this.log = opts.logger ?? (0, logger_1.noopLogger)();
13
+ this.botUsername = opts.botUsername ?? (() => null);
14
+ this.fetchGroupAdmins = opts.fetchGroupAdmins;
15
+ }
16
+ /**
17
+ * Inspect a Telegram message and decide whether to consume it as a
18
+ * command or pass it through to the agent. Pure dispatch — never
19
+ * sends messages itself; the caller renders the reply text and
20
+ * applies state changes.
21
+ */
22
+ async route(msg) {
23
+ const text = (msg.text ?? '').trim();
24
+ if (!text.startsWith('/'))
25
+ return { kind: 'agent' };
26
+ const { cmd, args } = this.parseCommand(text);
27
+ if (!cmd)
28
+ return { kind: 'agent' };
29
+ const chatId = String(msg.chat.id);
30
+ const chatType = msg.chat.type;
31
+ const senderId = msg.from?.id ? String(msg.from.id) : '';
32
+ switch (cmd) {
33
+ case '/help':
34
+ case '/start':
35
+ return { kind: 'reply', text: this.helpText(chatType) };
36
+ case '/status':
37
+ return { kind: 'reply', text: '✓ Online' };
38
+ case '/clear': {
39
+ // DMs: anyone may /clear their own chat. Groups: admin only.
40
+ if (chatType !== 'private' && !(await this.isAdmin(senderId, chatId))) {
41
+ this.log.info(`/clear ignored — non-admin in group`, { chatId, senderId });
42
+ return { kind: 'handled' };
43
+ }
44
+ if (chatType !== 'private')
45
+ this.store.recordAdminAction(chatId, 'clear', senderId);
46
+ return { kind: 'cleared' };
47
+ }
48
+ case '/pause': {
49
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
50
+ this.log.info(`/pause ignored`, { chatId, senderId, chatType });
51
+ return { kind: 'handled' };
52
+ }
53
+ this.store.setPaused(chatId, true, senderId);
54
+ return { kind: 'paused', groupId: chatId };
55
+ }
56
+ case '/resume': {
57
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
58
+ this.log.info(`/resume ignored`, { chatId, senderId, chatType });
59
+ return { kind: 'handled' };
60
+ }
61
+ this.store.setPaused(chatId, false, senderId);
62
+ return { kind: 'resumed', groupId: chatId };
63
+ }
64
+ case '/allowusers': {
65
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
66
+ this.log.info(`/allowusers ignored`, { chatId, senderId, chatType });
67
+ return { kind: 'handled' };
68
+ }
69
+ // Comma- or space-separated. `/allowusers reset` clears the list.
70
+ const raw = args.join(' ').trim();
71
+ if (raw === '' || raw === 'reset' || raw === 'clear') {
72
+ this.store.setAllowedUsers(chatId, [], senderId);
73
+ return { kind: 'reply', text: '✓ Cleared user allowlist for this group.' };
74
+ }
75
+ const ids = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
76
+ this.store.setAllowedUsers(chatId, ids, senderId);
77
+ return {
78
+ kind: 'reply',
79
+ text: `✓ User allowlist updated: ${ids.length} id(s) — only these users may chat with the bot here.`,
80
+ };
81
+ }
82
+ default:
83
+ // Unknown slash command — fall through to the agent. The
84
+ // model can decide whether to interpret it as natural input.
85
+ return { kind: 'agent' };
86
+ }
87
+ }
88
+ // ── Admin checks ────────────────────────────────────────────────
89
+ /**
90
+ * True when `senderId` is allowed to issue admin-only commands here.
91
+ * Owner takes priority; TELEGRAM_ADMIN_USERS escalates additional
92
+ * ids; TELEGRAM_TRUST_GROUP_ADMINS=true (off by default) accepts
93
+ * Telegram-side group admins.
94
+ */
95
+ async isAdmin(senderId, chatId) {
96
+ if (!senderId)
97
+ return false;
98
+ const ownerId = (process.env.TELEGRAM_OWNER_ID ?? '').trim();
99
+ if (ownerId && senderId === ownerId)
100
+ return true;
101
+ const adminCsv = (process.env.TELEGRAM_ADMIN_USERS ?? '').trim();
102
+ if (adminCsv) {
103
+ const admins = adminCsv.split(',').map((s) => s.trim()).filter(Boolean);
104
+ if (admins.includes(senderId))
105
+ return true;
106
+ }
107
+ const trustGroupAdmins = (process.env.TELEGRAM_TRUST_GROUP_ADMINS ?? '').toLowerCase() === 'true';
108
+ if (trustGroupAdmins && this.fetchGroupAdmins) {
109
+ try {
110
+ const admins = await this.fetchGroupAdmins(chatId);
111
+ if (admins.includes(senderId))
112
+ return true;
113
+ }
114
+ catch (err) {
115
+ this.log.warn(`getChatAdministrators failed: ${err?.message ?? err}`, { chatId });
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+ // ── Internals ───────────────────────────────────────────────────
121
+ /**
122
+ * Extract `/cmd` and the args list from raw text.
123
+ * Strips `@bot_username` suffixes Telegram appends in groups so
124
+ * `/clear@aiden_test_bot` resolves to `/clear`.
125
+ */
126
+ parseCommand(raw) {
127
+ const parts = raw.split(/\s+/);
128
+ if (!parts[0] || !parts[0].startsWith('/'))
129
+ return { cmd: null, args: [] };
130
+ const username = (this.botUsername() ?? '').toLowerCase();
131
+ let head = parts[0].toLowerCase();
132
+ if (username && head.endsWith(`@${username}`)) {
133
+ head = head.slice(0, head.length - username.length - 1);
134
+ }
135
+ return { cmd: head, args: parts.slice(1) };
136
+ }
137
+ requireGroup(chatType) {
138
+ return chatType === 'group' || chatType === 'supergroup';
139
+ }
140
+ helpText(chatType) {
141
+ const groupExtras = chatType !== 'private'
142
+ ? '`/pause` admin: stop bot in this group\n' +
143
+ '`/resume` admin: resume bot\n' +
144
+ '`/allowusers` admin: restrict who may chat\n'
145
+ : '';
146
+ return ('*Aiden* — your local AI assistant.\n\n' +
147
+ 'Send any message (or @mention me in a group) to start. Built-in commands:\n' +
148
+ '`/help` show this message\n' +
149
+ '`/status` bot health check\n' +
150
+ '`/clear` wipe this chat\'s memory\n' +
151
+ groupExtras);
152
+ }
153
+ }
154
+ exports.TelegramCommandRouter = TelegramCommandRouter;
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ var __importDefault = (this && this.__importDefault) || function (mod) {
7
+ return (mod && mod.__esModule) ? mod : { "default": mod };
8
+ };
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.TelegramGroupStore = void 0;
11
+ // core/channels/telegram-groups.ts — Phase v4.1-2.
12
+ //
13
+ // Persistent per-group state for the Telegram channel:
14
+ // - paused — admin /pause stops the bot from replying
15
+ // - allowedUsers — opt-in restriction set by /allowusers
16
+ // - title — group display name (cached for /channel
17
+ // telegram groups list — Telegram's getChat
18
+ // costs an HTTP call per query)
19
+ // - lastMessageAt — wall-clock of the last seen inbound msg
20
+ // - lastAdminAction — when an admin last touched the state
21
+ // - firstSeenAt — when the bot first observed this group
22
+ //
23
+ // State lives at `<aidenRoot>/state/telegram-groups.json`. Atomic
24
+ // writes (tmp → rename) keep the file consistent across process
25
+ // crashes. Loaded once at adapter start; mutations debounce flushes
26
+ // at 1 s so a burst of admin commands doesn't hammer the disk.
27
+ //
28
+ // All diagnostics route through the v4.1-1.3a Logger contract.
29
+ // No console.* anywhere in this module.
30
+ const node_fs_1 = require("node:fs");
31
+ const node_fs_2 = require("node:fs");
32
+ const node_path_1 = __importDefault(require("node:path"));
33
+ const logger_1 = require("../v4/logger");
34
+ /**
35
+ * In-memory + disk-backed store of per-group state. Read paths are
36
+ * always synchronous reads of the in-memory map; mutations schedule
37
+ * a debounced flush so a burst of admin commands collapses to one
38
+ * write.
39
+ */
40
+ class TelegramGroupStore {
41
+ constructor(opts) {
42
+ this.groups = new Map();
43
+ this.flushTimer = null;
44
+ this.loaded = false;
45
+ this.stateDir = node_path_1.default.join(opts.paths.root, 'state');
46
+ this.statePath = node_path_1.default.join(this.stateDir, 'telegram-groups.json');
47
+ this.log = opts.logger ?? (0, logger_1.noopLogger)();
48
+ this.flushDebounceMs = opts.flushDebounceMs ?? 1000;
49
+ }
50
+ /**
51
+ * Synchronously load on first call. Subsequent calls are no-ops.
52
+ * Failure to read is treated as "fresh state" — better than crashing
53
+ * the adapter on a malformed file.
54
+ */
55
+ async load() {
56
+ if (this.loaded)
57
+ return;
58
+ this.loaded = true;
59
+ if (!(0, node_fs_2.existsSync)(this.statePath))
60
+ return;
61
+ try {
62
+ const raw = await node_fs_1.promises.readFile(this.statePath, 'utf8');
63
+ const parsed = JSON.parse(raw);
64
+ if (parsed?.version !== 1 || !parsed.groups)
65
+ return;
66
+ for (const [id, g] of Object.entries(parsed.groups)) {
67
+ if (g && typeof g === 'object' && 'groupId' in g) {
68
+ this.groups.set(id, normalizeOnLoad(g));
69
+ }
70
+ }
71
+ this.log.info(`loaded ${this.groups.size} group(s)`);
72
+ }
73
+ catch (err) {
74
+ this.log.warn(`could not load state: ${err?.message ?? err}`);
75
+ }
76
+ }
77
+ /** True when this group is allowed to interact with the bot. */
78
+ isPaused(groupId) {
79
+ return this.groups.get(groupId)?.paused === true;
80
+ }
81
+ /**
82
+ * When an allowed-users list is set on a group, only those users may
83
+ * converse. Empty list (the default) → everyone in the group is OK.
84
+ * Returns true when the user is allowed.
85
+ */
86
+ userIsAllowed(groupId, userId) {
87
+ const g = this.groups.get(groupId);
88
+ if (!g || g.allowedUsers.length === 0)
89
+ return true;
90
+ return g.allowedUsers.includes(userId);
91
+ }
92
+ /** Public accessor for /channel telegram groups list. */
93
+ list() {
94
+ return Array.from(this.groups.values()).sort((a, b) => (b.lastMessageAt ?? 0) - (a.lastMessageAt ?? 0));
95
+ }
96
+ get(groupId) {
97
+ return this.groups.get(groupId);
98
+ }
99
+ /** Record an inbound observation — bumps lastMessageAt + caches title. */
100
+ observeMessage(groupId, opts) {
101
+ const existing = this.groups.get(groupId);
102
+ const now = Date.now();
103
+ if (existing) {
104
+ existing.lastMessageAt = now;
105
+ if (opts.title && existing.title !== opts.title)
106
+ existing.title = opts.title;
107
+ }
108
+ else {
109
+ this.groups.set(groupId, {
110
+ groupId,
111
+ title: opts.title,
112
+ paused: false,
113
+ allowedUsers: [],
114
+ firstSeenAt: now,
115
+ lastMessageAt: now,
116
+ });
117
+ }
118
+ this.scheduleFlush();
119
+ }
120
+ setPaused(groupId, paused, actor) {
121
+ const g = this.ensureGroup(groupId);
122
+ g.paused = paused;
123
+ g.lastAdminAction = { actor, cmd: paused ? 'pause' : 'resume', at: Date.now() };
124
+ this.scheduleFlush();
125
+ }
126
+ setAllowedUsers(groupId, userIds, actor) {
127
+ const g = this.ensureGroup(groupId);
128
+ g.allowedUsers = [...new Set(userIds.map(s => s.trim()).filter(Boolean))];
129
+ g.lastAdminAction = { actor, cmd: 'allowusers', at: Date.now() };
130
+ this.scheduleFlush();
131
+ }
132
+ recordAdminAction(groupId, cmd, actor) {
133
+ const g = this.ensureGroup(groupId);
134
+ g.lastAdminAction = { actor, cmd, at: Date.now() };
135
+ this.scheduleFlush();
136
+ }
137
+ /** Force-flush + clear debounce timer (called on adapter teardown). */
138
+ async flushNow() {
139
+ if (this.flushTimer) {
140
+ clearTimeout(this.flushTimer);
141
+ this.flushTimer = null;
142
+ }
143
+ await this.writeFile();
144
+ }
145
+ // ── Internals ─────────────────────────────────────────────────
146
+ ensureGroup(groupId) {
147
+ let g = this.groups.get(groupId);
148
+ if (!g) {
149
+ g = {
150
+ groupId,
151
+ paused: false,
152
+ allowedUsers: [],
153
+ firstSeenAt: Date.now(),
154
+ };
155
+ this.groups.set(groupId, g);
156
+ }
157
+ return g;
158
+ }
159
+ scheduleFlush() {
160
+ if (this.flushTimer)
161
+ return;
162
+ this.flushTimer = setTimeout(() => {
163
+ this.flushTimer = null;
164
+ this.writeFile().catch((err) => this.log.warn(`flush failed: ${err?.message ?? err}`));
165
+ }, this.flushDebounceMs);
166
+ if (typeof this.flushTimer.unref === 'function')
167
+ this.flushTimer.unref();
168
+ }
169
+ async writeFile() {
170
+ const payload = {
171
+ version: 1,
172
+ groups: Object.fromEntries(this.groups),
173
+ };
174
+ await node_fs_1.promises.mkdir(this.stateDir, { recursive: true });
175
+ const tmp = `${this.statePath}.${process.pid}.tmp`;
176
+ await node_fs_1.promises.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
177
+ await node_fs_1.promises.rename(tmp, this.statePath);
178
+ }
179
+ }
180
+ exports.TelegramGroupStore = TelegramGroupStore;
181
+ /**
182
+ * Defensive load-time normaliser — older state files may be missing
183
+ * fields we've since added; fall back to safe defaults instead of
184
+ * propagating undefined into the rest of the adapter.
185
+ */
186
+ function normalizeOnLoad(raw) {
187
+ return {
188
+ groupId: String(raw.groupId),
189
+ title: typeof raw.title === 'string' ? raw.title : undefined,
190
+ paused: raw.paused === true,
191
+ allowedUsers: Array.isArray(raw.allowedUsers) ? raw.allowedUsers.map(String) : [],
192
+ firstSeenAt: typeof raw.firstSeenAt === 'number' ? raw.firstSeenAt : Date.now(),
193
+ lastMessageAt: typeof raw.lastMessageAt === 'number' ? raw.lastMessageAt : undefined,
194
+ lastAdminAction: raw.lastAdminAction && typeof raw.lastAdminAction === 'object'
195
+ ? raw.lastAdminAction
196
+ : undefined,
197
+ };
198
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.TelegramRateLimiter = void 0;
8
+ // core/channels/telegram-rate-limit.ts — Phase v4.1-2.
9
+ //
10
+ // Sliding-window per-user rate limiter for the Telegram channel.
11
+ //
12
+ // Design:
13
+ // - In-memory only — resets on aiden restart. The threat model is
14
+ // "stop a single chatter from burning the bot owner's quota in
15
+ // one sitting"; a restart-bypass that costs the abuser an entire
16
+ // restart-window's worth of messages is acceptable.
17
+ // - Single user-id keyspace across all chats — a spammer can't dodge
18
+ // by hopping between groups.
19
+ // - 1-minute sliding window, default 5 messages. Both knobs are
20
+ // configurable via env (`TELEGRAM_USER_RATE_LIMIT`,
21
+ // `TELEGRAM_USER_RATE_WINDOW_MS`) so an op who hosts the bot for a
22
+ // bigger community can bump them without code changes.
23
+ // - `shouldThrottle(userId)` is the only consumer-facing method —
24
+ // records the access *and* reports whether the caller should drop
25
+ // the message. One lookup, one mutation, one decision.
26
+ // - A coalescing sweeper trims stale buckets every 5 minutes so an
27
+ // adversary can't blow the heap by hammering with fresh user ids.
28
+ //
29
+ // `TelegramRateLimiter` accepts an injected logger from the unified
30
+ // `Logger` contract (Phase v4.1-1.3a) — diagnostics file-only, REPL
31
+ // stays sacred. No console.* anywhere in this module.
32
+ const logger_1 = require("../v4/logger");
33
+ const DEFAULT_LIMIT = 5;
34
+ const DEFAULT_WINDOW_MS = 60000;
35
+ const SWEEP_INTERVAL_MS = 5 * 60 * 1000; // prune buckets idle > sweep+window
36
+ class TelegramRateLimiter {
37
+ constructor(opts = {}) {
38
+ this.buckets = new Map();
39
+ this.sweepTimer = null;
40
+ this.limit = readPositiveInt(process.env.TELEGRAM_USER_RATE_LIMIT, opts.limit ?? DEFAULT_LIMIT);
41
+ this.windowMs = readPositiveInt(process.env.TELEGRAM_USER_RATE_WINDOW_MS, opts.windowMs ?? DEFAULT_WINDOW_MS);
42
+ this.now = opts.now ?? Date.now;
43
+ this.log = opts.logger ?? (0, logger_1.noopLogger)();
44
+ }
45
+ /**
46
+ * Record an attempted message + return true when the caller should
47
+ * drop it. The bucket is updated on every call (even ones that get
48
+ * throttled), so a sustained over-limit user stays throttled until
49
+ * the oldest message in their window ages out.
50
+ */
51
+ shouldThrottle(userId) {
52
+ if (!userId)
53
+ return false;
54
+ const cutoff = this.now() - this.windowMs;
55
+ const bucket = this.buckets.get(userId) ?? [];
56
+ // Drop expired entries from the front (oldest-first list).
57
+ while (bucket.length > 0 && bucket[0] <= cutoff)
58
+ bucket.shift();
59
+ if (bucket.length >= this.limit) {
60
+ // Don't append on throttle so the window can age out cleanly —
61
+ // appending here would reset the user's clock every time they
62
+ // try to spam, locking them in indefinitely.
63
+ this.buckets.set(userId, bucket);
64
+ this.log.warn(`rate-limited`, { userId, count: bucket.length, limit: this.limit });
65
+ return true;
66
+ }
67
+ bucket.push(this.now());
68
+ this.buckets.set(userId, bucket);
69
+ this.scheduleSweep();
70
+ return false;
71
+ }
72
+ /** Test / diagnostic accessor — current bucket size for a user. */
73
+ getCount(userId) {
74
+ const bucket = this.buckets.get(userId);
75
+ if (!bucket)
76
+ return 0;
77
+ const cutoff = this.now() - this.windowMs;
78
+ return bucket.filter((t) => t > cutoff).length;
79
+ }
80
+ /** Stop the sweep timer (called on adapter teardown). */
81
+ dispose() {
82
+ if (this.sweepTimer) {
83
+ clearInterval(this.sweepTimer);
84
+ this.sweepTimer = null;
85
+ }
86
+ }
87
+ // ── Internal: prune fully-expired buckets so memory doesn't drift. ──
88
+ scheduleSweep() {
89
+ if (this.sweepTimer)
90
+ return;
91
+ this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
92
+ // Don't keep the process alive just for the sweeper.
93
+ if (typeof this.sweepTimer.unref === 'function')
94
+ this.sweepTimer.unref();
95
+ }
96
+ sweep() {
97
+ const cutoff = this.now() - this.windowMs;
98
+ let pruned = 0;
99
+ for (const [userId, bucket] of this.buckets) {
100
+ const filtered = bucket.filter((t) => t > cutoff);
101
+ if (filtered.length === 0) {
102
+ this.buckets.delete(userId);
103
+ pruned += 1;
104
+ }
105
+ else {
106
+ this.buckets.set(userId, filtered);
107
+ }
108
+ }
109
+ if (pruned > 0)
110
+ this.log.debug(`swept ${pruned} stale buckets`);
111
+ }
112
+ }
113
+ exports.TelegramRateLimiter = TelegramRateLimiter;
114
+ /**
115
+ * Parse a positive integer env var with a fallback. Negative / NaN /
116
+ * empty values fall back to the default — better than crashing the
117
+ * adapter because someone fat-fingered an env var.
118
+ */
119
+ function readPositiveInt(envValue, fallback) {
120
+ if (typeof envValue !== 'string' || envValue.trim() === '')
121
+ return fallback;
122
+ const n = Number.parseInt(envValue, 10);
123
+ return Number.isFinite(n) && n > 0 ? n : fallback;
124
+ }