alvin-bot 4.4.1

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 (136) hide show
  1. package/.env.example +43 -0
  2. package/BACKLOG.md +223 -0
  3. package/CHANGELOG.md +63 -0
  4. package/CLAUDE.example.md +152 -0
  5. package/CODE_OF_CONDUCT.md +52 -0
  6. package/CONTRIBUTING.md +72 -0
  7. package/LICENSE +21 -0
  8. package/README.md +529 -0
  9. package/SECURITY.md +38 -0
  10. package/SOUL.example.md +60 -0
  11. package/TOOLS.example.md +42 -0
  12. package/alvin-bot.config.example.json +24 -0
  13. package/bin/cli.js +1088 -0
  14. package/dist/.metadata_never_index +0 -0
  15. package/dist/claude.js +102 -0
  16. package/dist/config.js +65 -0
  17. package/dist/engine.js +90 -0
  18. package/dist/find-claude-binary.js +98 -0
  19. package/dist/handlers/commands.js +1489 -0
  20. package/dist/handlers/document.js +187 -0
  21. package/dist/handlers/message.js +200 -0
  22. package/dist/handlers/photo.js +154 -0
  23. package/dist/handlers/platform-message.js +275 -0
  24. package/dist/handlers/video.js +237 -0
  25. package/dist/handlers/voice.js +148 -0
  26. package/dist/i18n.js +299 -0
  27. package/dist/index.js +442 -0
  28. package/dist/init-data-dir.js +81 -0
  29. package/dist/middleware/auth.js +215 -0
  30. package/dist/migrate.js +139 -0
  31. package/dist/paths.js +87 -0
  32. package/dist/platforms/discord.js +161 -0
  33. package/dist/platforms/index.js +130 -0
  34. package/dist/platforms/signal.js +205 -0
  35. package/dist/platforms/slack.js +318 -0
  36. package/dist/platforms/telegram.js +111 -0
  37. package/dist/platforms/types.js +8 -0
  38. package/dist/platforms/whatsapp.js +648 -0
  39. package/dist/providers/claude-sdk-provider.js +173 -0
  40. package/dist/providers/codex-cli-provider.js +121 -0
  41. package/dist/providers/index.js +7 -0
  42. package/dist/providers/openai-compatible.js +388 -0
  43. package/dist/providers/registry.js +209 -0
  44. package/dist/providers/tool-executor.js +450 -0
  45. package/dist/providers/types.js +205 -0
  46. package/dist/services/access.js +144 -0
  47. package/dist/services/asset-index.js +230 -0
  48. package/dist/services/browser-manager.js +161 -0
  49. package/dist/services/browser.js +121 -0
  50. package/dist/services/compaction.js +129 -0
  51. package/dist/services/cron.js +462 -0
  52. package/dist/services/custom-tools.js +317 -0
  53. package/dist/services/delivery-queue.js +154 -0
  54. package/dist/services/elevenlabs.js +58 -0
  55. package/dist/services/embeddings.js +386 -0
  56. package/dist/services/exec-guard.js +46 -0
  57. package/dist/services/fallback-order.js +151 -0
  58. package/dist/services/heartbeat.js +192 -0
  59. package/dist/services/hooks.js +44 -0
  60. package/dist/services/imagegen.js +72 -0
  61. package/dist/services/language-detect.js +144 -0
  62. package/dist/services/markdown.js +63 -0
  63. package/dist/services/mcp.js +252 -0
  64. package/dist/services/memory.js +133 -0
  65. package/dist/services/personality.js +227 -0
  66. package/dist/services/plugins.js +171 -0
  67. package/dist/services/reminders.js +97 -0
  68. package/dist/services/restart.js +48 -0
  69. package/dist/services/security-audit.js +66 -0
  70. package/dist/services/self-search.js +129 -0
  71. package/dist/services/session.js +93 -0
  72. package/dist/services/skills.js +287 -0
  73. package/dist/services/standing-orders.js +29 -0
  74. package/dist/services/subagents.js +142 -0
  75. package/dist/services/sudo.js +243 -0
  76. package/dist/services/telegram.js +113 -0
  77. package/dist/services/tool-discovery.js +214 -0
  78. package/dist/services/usage-tracker.js +137 -0
  79. package/dist/services/users.js +199 -0
  80. package/dist/services/voice.js +95 -0
  81. package/dist/tui/index.js +507 -0
  82. package/dist/web/canvas.js +30 -0
  83. package/dist/web/doctor-api.js +606 -0
  84. package/dist/web/openai-compat.js +252 -0
  85. package/dist/web/server.js +1351 -0
  86. package/dist/web/setup-api.js +1078 -0
  87. package/docs/mcp.example.json +16 -0
  88. package/docs/screenshots/00-Login.png +0 -0
  89. package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
  90. package/docs/screenshots/02-Chat.png +0 -0
  91. package/docs/screenshots/03-Dashboard-Overview.png +0 -0
  92. package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
  93. package/docs/screenshots/05-Personality-Editor.png +0 -0
  94. package/docs/screenshots/06-Memory-Manager.png +0 -0
  95. package/docs/screenshots/07-Active-Sessions.png +0 -0
  96. package/docs/screenshots/08-File-Browser.png +0 -0
  97. package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
  98. package/docs/screenshots/10-Custom-Tools.png +0 -0
  99. package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
  100. package/docs/screenshots/12-Messaging-Platforms.png +0 -0
  101. package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
  102. package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
  103. package/docs/screenshots/13-User-Management.png +0 -0
  104. package/docs/screenshots/14-Web-Terminal.png +0 -0
  105. package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
  106. package/docs/screenshots/16-Settings-and-Env.png +0 -0
  107. package/docs/screenshots/TG-commands.png +0 -0
  108. package/docs/screenshots/TG.png +0 -0
  109. package/docs/screenshots/_Mac-Installer.png +0 -0
  110. package/docs/tools.example.json +33 -0
  111. package/install.sh +165 -0
  112. package/package.json +190 -0
  113. package/plugins/calendar/index.js +270 -0
  114. package/plugins/email/index.js +231 -0
  115. package/plugins/finance/index.js +254 -0
  116. package/plugins/notes/index.js +227 -0
  117. package/plugins/smarthome/index.js +230 -0
  118. package/plugins/weather/index.js +122 -0
  119. package/skills/apple-notes/SKILL.md +31 -0
  120. package/skills/browse/SKILL.md +136 -0
  121. package/skills/code-project/SKILL.md +43 -0
  122. package/skills/data-analysis/SKILL.md +39 -0
  123. package/skills/document-creation/SKILL.md +48 -0
  124. package/skills/email-summary/SKILL.md +46 -0
  125. package/skills/github/SKILL.md +42 -0
  126. package/skills/summarize/SKILL.md +28 -0
  127. package/skills/system-admin/SKILL.md +39 -0
  128. package/skills/weather/SKILL.md +34 -0
  129. package/skills/web-research/SKILL.md +35 -0
  130. package/web/public/canvas.html +52 -0
  131. package/web/public/css/style.css +555 -0
  132. package/web/public/index.html +189 -0
  133. package/web/public/js/app.js +3102 -0
  134. package/web/public/js/i18n.js +1048 -0
  135. package/web/public/js/icons.js +104 -0
  136. package/web/public/login.html +48 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Custom Tool Registration — Users define their own tools via Markdown.
3
+ *
4
+ * Configuration via TOOLS.md (Markdown format):
5
+ *
6
+ * ## tool_name
7
+ * Tool description (first line after heading)
8
+ * ```
9
+ * shell command here
10
+ * ```
11
+ * **Type:** http (optional, default: shell)
12
+ * **URL:** https://example.com/api (for HTTP tools)
13
+ * **Method:** GET|POST|PUT|DELETE (default: GET)
14
+ * **Headers:** Key: Value (one per line)
15
+ * **Body:** request body
16
+ * **Timeout:** 30s, 5m, or milliseconds
17
+ * **Parameters:**
18
+ * - `name` (type, required): description
19
+ *
20
+ * Legacy: Also supports docs/tools.json as fallback.
21
+ */
22
+ import fs from "fs";
23
+ import { execSync } from "child_process";
24
+ import { isSelfRestartCommand, scheduleGracefulRestart } from "./restart.js";
25
+ import { TOOLS_MD, TOOLS_JSON, TOOLS_EXAMPLE_MD, TOOLS_EXAMPLE_JSON } from "../paths.js";
26
+ // Auto-initialize TOOLS.md from example if missing (prefer MD over JSON)
27
+ if (!fs.existsSync(TOOLS_MD) && fs.existsSync(TOOLS_EXAMPLE_MD)) {
28
+ fs.copyFileSync(TOOLS_EXAMPLE_MD, TOOLS_MD);
29
+ }
30
+ // Legacy fallback: also init tools.json if someone depends on it
31
+ if (!fs.existsSync(TOOLS_JSON) && fs.existsSync(TOOLS_EXAMPLE_JSON)) {
32
+ fs.copyFileSync(TOOLS_EXAMPLE_JSON, TOOLS_JSON);
33
+ }
34
+ // ── Markdown Parser ─────────────────────────────────────
35
+ function parseTimeout(value) {
36
+ const trimmed = value.trim().toLowerCase();
37
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/);
38
+ if (!match)
39
+ return 30000;
40
+ const num = parseFloat(match[1]);
41
+ switch (match[2]) {
42
+ case "h": return num * 3600000;
43
+ case "m": return num * 60000;
44
+ case "s": return num * 1000;
45
+ case "ms": return num;
46
+ default: return num > 1000 ? num : num * 1000; // bare number: assume seconds if small
47
+ }
48
+ }
49
+ function parseToolsMd(content) {
50
+ const tools = [];
51
+ // Split by ## headings (tool boundaries)
52
+ const sections = content.split(/^## /m).slice(1); // skip preamble before first ##
53
+ for (const section of sections) {
54
+ const lines = section.split("\n");
55
+ const name = lines[0].trim().replace(/\s+/g, "_").toLowerCase();
56
+ if (!name)
57
+ continue;
58
+ const tool = { name, description: "" };
59
+ // First non-empty line after heading = description
60
+ let i = 1;
61
+ while (i < lines.length && !lines[i].trim())
62
+ i++;
63
+ if (i < lines.length && !lines[i].startsWith("```") && !lines[i].startsWith("**")) {
64
+ tool.description = lines[i].trim();
65
+ i++;
66
+ }
67
+ // Parse remaining lines
68
+ let inCodeBlock = false;
69
+ let codeLines = [];
70
+ let inHeaders = false;
71
+ const headerLines = [];
72
+ let inParams = false;
73
+ const paramEntries = [];
74
+ for (; i < lines.length; i++) {
75
+ const line = lines[i];
76
+ // Code block (command)
77
+ if (line.startsWith("```")) {
78
+ if (inCodeBlock) {
79
+ tool.command = codeLines.join("\n").trim();
80
+ inCodeBlock = false;
81
+ codeLines = [];
82
+ }
83
+ else {
84
+ inCodeBlock = true;
85
+ }
86
+ continue;
87
+ }
88
+ if (inCodeBlock) {
89
+ codeLines.push(line);
90
+ continue;
91
+ }
92
+ // Bold-key fields: **Key:** value
93
+ const boldMatch = line.match(/^\*\*(\w[\w\s]*):\*\*\s*(.*)/);
94
+ if (boldMatch) {
95
+ const key = boldMatch[1].trim().toLowerCase();
96
+ const value = boldMatch[2].trim();
97
+ // End previous multi-line sections
98
+ if (key !== "headers")
99
+ inHeaders = false;
100
+ if (key !== "parameters")
101
+ inParams = false;
102
+ switch (key) {
103
+ case "type":
104
+ tool.type = value.toLowerCase();
105
+ break;
106
+ case "url":
107
+ tool.url = value;
108
+ break;
109
+ case "method":
110
+ tool.method = value.toUpperCase();
111
+ break;
112
+ case "headers":
113
+ inHeaders = true;
114
+ if (value)
115
+ headerLines.push(value);
116
+ break;
117
+ case "body":
118
+ tool.body = value;
119
+ break;
120
+ case "timeout":
121
+ tool.timeout = parseTimeout(value);
122
+ break;
123
+ case "parameters":
124
+ inParams = true;
125
+ break;
126
+ }
127
+ continue;
128
+ }
129
+ // Header continuation lines (Key: Value)
130
+ if (inHeaders && line.match(/^\s*-?\s*\S+:\s/)) {
131
+ headerLines.push(line.replace(/^\s*-?\s*/, "").trim());
132
+ continue;
133
+ }
134
+ else if (inHeaders && line.trim()) {
135
+ inHeaders = false;
136
+ }
137
+ // Parameter entries: - `name` (type, required): description
138
+ if (inParams && line.match(/^\s*-\s*`/)) {
139
+ const paramMatch = line.match(/^\s*-\s*`(\w+)`\s*\(([^)]+)\)\s*:?\s*(.*)/);
140
+ if (paramMatch) {
141
+ const pName = paramMatch[1];
142
+ const pMeta = paramMatch[2];
143
+ const pDesc = paramMatch[3].trim();
144
+ const parts = pMeta.split(",").map(s => s.trim().toLowerCase());
145
+ const pType = parts.find(p => ["string", "number", "boolean", "integer"].includes(p)) || "string";
146
+ const pRequired = parts.includes("required");
147
+ paramEntries.push({ name: pName, type: pType, description: pDesc, required: pRequired });
148
+ }
149
+ continue;
150
+ }
151
+ else if (inParams && line.trim() && !line.startsWith(" ")) {
152
+ inParams = false;
153
+ }
154
+ }
155
+ // Assemble headers
156
+ if (headerLines.length > 0) {
157
+ tool.headers = {};
158
+ for (const h of headerLines) {
159
+ const colonIdx = h.indexOf(":");
160
+ if (colonIdx > 0) {
161
+ tool.headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
162
+ }
163
+ }
164
+ }
165
+ // Assemble parameters
166
+ if (paramEntries.length > 0) {
167
+ tool.parameters = {};
168
+ for (const p of paramEntries) {
169
+ tool.parameters[p.name] = { type: p.type, description: p.description, required: p.required };
170
+ }
171
+ }
172
+ tools.push(tool);
173
+ }
174
+ return tools;
175
+ }
176
+ // ── Config Loading ──────────────────────────────────────
177
+ function loadToolsConfig() {
178
+ // Prefer TOOLS.md (Markdown format)
179
+ if (fs.existsSync(TOOLS_MD)) {
180
+ try {
181
+ const content = fs.readFileSync(TOOLS_MD, "utf-8");
182
+ const tools = parseToolsMd(content);
183
+ return { tools };
184
+ }
185
+ catch {
186
+ // Fall through to JSON
187
+ }
188
+ }
189
+ // Legacy fallback: docs/tools.json
190
+ if (fs.existsSync(TOOLS_JSON)) {
191
+ try {
192
+ const raw = fs.readFileSync(TOOLS_JSON, "utf-8");
193
+ return JSON.parse(raw);
194
+ }
195
+ catch {
196
+ // ignore
197
+ }
198
+ }
199
+ return { tools: [] };
200
+ }
201
+ /**
202
+ * Get the path of the active tools config file.
203
+ */
204
+ export function getToolsConfigPath() {
205
+ if (fs.existsSync(TOOLS_MD))
206
+ return TOOLS_MD;
207
+ return TOOLS_JSON;
208
+ }
209
+ // ── Template Substitution ───────────────────────────────
210
+ function substituteParams(template, params) {
211
+ let result = template;
212
+ for (const [key, value] of Object.entries(params)) {
213
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), String(value));
214
+ }
215
+ return result;
216
+ }
217
+ // ── Execution ───────────────────────────────────────────
218
+ async function executeShellTool(tool, params) {
219
+ if (!tool.command)
220
+ throw new Error("No command defined");
221
+ const cmd = substituteParams(tool.command, params);
222
+ // Intercept self-restart: use graceful internal restart instead of pm2 kill
223
+ if (isSelfRestartCommand(cmd)) {
224
+ scheduleGracefulRestart();
225
+ return "Bot restart scheduled. Grammy will commit the Telegram offset before exiting.";
226
+ }
227
+ try {
228
+ const result = execSync(cmd, {
229
+ stdio: "pipe",
230
+ timeout: tool.timeout || 30000,
231
+ env: process.env,
232
+ });
233
+ return result.toString().trim() || "(no output)";
234
+ }
235
+ catch (err) {
236
+ const error = err;
237
+ throw new Error(error.stderr?.toString()?.trim() || error.message);
238
+ }
239
+ }
240
+ async function executeHttpTool(tool, params) {
241
+ if (!tool.url)
242
+ throw new Error("No URL defined");
243
+ const url = substituteParams(tool.url, params);
244
+ const method = tool.method || "GET";
245
+ const headers = {};
246
+ if (tool.headers) {
247
+ for (const [key, value] of Object.entries(tool.headers)) {
248
+ headers[key] = substituteParams(value, params);
249
+ }
250
+ }
251
+ const fetchOpts = { method, headers };
252
+ if (tool.body && method !== "GET") {
253
+ fetchOpts.body = substituteParams(tool.body, params);
254
+ if (!headers["Content-Type"]) {
255
+ headers["Content-Type"] = "application/json";
256
+ }
257
+ }
258
+ const controller = new AbortController();
259
+ const timeoutId = setTimeout(() => controller.abort(), tool.timeout || 30000);
260
+ fetchOpts.signal = controller.signal;
261
+ try {
262
+ const response = await fetch(url, fetchOpts);
263
+ clearTimeout(timeoutId);
264
+ const text = await response.text();
265
+ return `HTTP ${response.status}: ${text.slice(0, 2000)}`;
266
+ }
267
+ catch (err) {
268
+ clearTimeout(timeoutId);
269
+ throw err;
270
+ }
271
+ }
272
+ // ── Public API ──────────────────────────────────────────
273
+ /**
274
+ * Get all custom tools for display/registration.
275
+ */
276
+ export function getCustomTools() {
277
+ const config = loadToolsConfig();
278
+ return config.tools.map(t => ({
279
+ name: t.name,
280
+ description: t.description,
281
+ parameters: t.parameters || {},
282
+ }));
283
+ }
284
+ /**
285
+ * Execute a custom tool by name.
286
+ */
287
+ export async function executeCustomTool(name, params) {
288
+ const config = loadToolsConfig();
289
+ const tool = config.tools.find(t => t.name === name);
290
+ if (!tool)
291
+ throw new Error(`Custom tool "${name}" not found`);
292
+ const type = tool.type || (tool.url ? "http" : "shell");
293
+ switch (type) {
294
+ case "http":
295
+ return executeHttpTool(tool, params);
296
+ case "shell":
297
+ default:
298
+ return executeShellTool(tool, params);
299
+ }
300
+ }
301
+ /**
302
+ * List custom tools for the /tools command.
303
+ */
304
+ export function listCustomTools() {
305
+ const config = loadToolsConfig();
306
+ return config.tools.map(t => ({
307
+ name: t.name,
308
+ description: t.description,
309
+ type: t.type || (t.url ? "http" : "shell"),
310
+ }));
311
+ }
312
+ /**
313
+ * Check if custom tools config exists.
314
+ */
315
+ export function hasCustomTools() {
316
+ return fs.existsSync(TOOLS_MD) || fs.existsSync(TOOLS_JSON);
317
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Delivery Queue — Reliable message delivery with retry + exponential backoff.
3
+ *
4
+ * Instead of fire-and-forget sends, messages are enqueued and processed
5
+ * on a 30s interval. Failed deliveries are retried with exponential backoff
6
+ * (10s, 30s, 90s, 270s, 810s). Persisted to ~/.alvin-bot/delivery-queue.json.
7
+ */
8
+ import fs from "fs";
9
+ import crypto from "crypto";
10
+ import { DELIVERY_QUEUE_FILE } from "../paths.js";
11
+ // ── State ───────────────────────────────────────────────
12
+ let senders = {};
13
+ // ── File I/O ────────────────────────────────────────────
14
+ function readQueue() {
15
+ try {
16
+ const raw = fs.readFileSync(DELIVERY_QUEUE_FILE, "utf-8");
17
+ return JSON.parse(raw);
18
+ }
19
+ catch (err) {
20
+ if (fs.existsSync(DELIVERY_QUEUE_FILE)) {
21
+ console.error("Delivery queue: failed to parse queue file, starting fresh:", err);
22
+ }
23
+ return [];
24
+ }
25
+ }
26
+ function writeQueue(entries) {
27
+ const tmp = DELIVERY_QUEUE_FILE + ".tmp";
28
+ fs.writeFileSync(tmp, JSON.stringify(entries, null, 2));
29
+ fs.renameSync(tmp, DELIVERY_QUEUE_FILE);
30
+ }
31
+ // ── Backoff ─────────────────────────────────────────────
32
+ function getBackoffMs(attempts) {
33
+ return Math.min(10000 * Math.pow(3, attempts), 810000);
34
+ }
35
+ // ── Public API ──────────────────────────────────────────
36
+ /**
37
+ * Register send functions for each platform.
38
+ * Must be called before processQueue() can deliver anything.
39
+ */
40
+ export function setSenders(newSenders) {
41
+ senders = { ...senders, ...newSenders };
42
+ }
43
+ /**
44
+ * Enqueue a message for reliable delivery.
45
+ * Writes immediately to disk and returns the entry ID.
46
+ */
47
+ export function enqueue(channel, chatId, content, options) {
48
+ const id = crypto.randomUUID();
49
+ const entry = {
50
+ id,
51
+ channel,
52
+ chatId,
53
+ content,
54
+ mediaPath: options?.mediaPath,
55
+ createdAt: Date.now(),
56
+ attempts: 0,
57
+ lastAttempt: 0,
58
+ maxAttempts: options?.maxAttempts ?? 5,
59
+ status: "pending",
60
+ };
61
+ const queue = readQueue();
62
+ queue.push(entry);
63
+ writeQueue(queue);
64
+ return id;
65
+ }
66
+ /**
67
+ * Process all pending entries in the queue.
68
+ * Respects exponential backoff and max attempts.
69
+ * Returns counts of delivered, failed, and still-pending entries.
70
+ */
71
+ export async function processQueue() {
72
+ const queue = readQueue();
73
+ const now = Date.now();
74
+ let delivered = 0;
75
+ let failed = 0;
76
+ let pending = 0;
77
+ let modified = false;
78
+ for (const entry of queue) {
79
+ if (entry.status !== "pending")
80
+ continue;
81
+ // Check backoff — skip if too soon since last attempt
82
+ if (entry.attempts > 0) {
83
+ const backoff = getBackoffMs(entry.attempts);
84
+ if (now - entry.lastAttempt < backoff) {
85
+ pending++;
86
+ continue;
87
+ }
88
+ }
89
+ // Check if we have a sender for this channel
90
+ const sender = senders[entry.channel];
91
+ if (!sender) {
92
+ // No sender registered — leave pending, don't count as attempt
93
+ pending++;
94
+ continue;
95
+ }
96
+ // Attempt delivery
97
+ try {
98
+ await sender(entry.chatId, entry.content, entry.mediaPath);
99
+ entry.status = "delivered";
100
+ entry.attempts++;
101
+ entry.lastAttempt = now;
102
+ delivered++;
103
+ modified = true;
104
+ }
105
+ catch (err) {
106
+ entry.attempts++;
107
+ entry.lastAttempt = now;
108
+ entry.error = err instanceof Error ? err.message : String(err);
109
+ modified = true;
110
+ if (entry.attempts >= entry.maxAttempts) {
111
+ entry.status = "failed";
112
+ failed++;
113
+ console.error(`Delivery failed permanently [${entry.channel}:${entry.chatId}]: ${entry.error} (${entry.attempts} attempts)`);
114
+ }
115
+ else {
116
+ pending++;
117
+ const nextBackoff = getBackoffMs(entry.attempts);
118
+ console.warn(`Delivery retry scheduled [${entry.channel}:${entry.chatId}]: attempt ${entry.attempts}/${entry.maxAttempts}, next in ${Math.round(nextBackoff / 1000)}s`);
119
+ }
120
+ }
121
+ }
122
+ if (modified) {
123
+ writeQueue(queue);
124
+ }
125
+ return { delivered, failed, pending };
126
+ }
127
+ /**
128
+ * Get counts by status for monitoring.
129
+ */
130
+ export function getQueueStatus() {
131
+ const queue = readQueue();
132
+ const pending = queue.filter(e => e.status === "pending").length;
133
+ const delivered = queue.filter(e => e.status === "delivered").length;
134
+ const failed = queue.filter(e => e.status === "failed").length;
135
+ return { pending, delivered, failed, total: queue.length };
136
+ }
137
+ /**
138
+ * Remove old entries: delivered > 24h, failed > 7d.
139
+ */
140
+ export function cleanupQueue() {
141
+ const queue = readQueue();
142
+ const now = Date.now();
143
+ const DAY = 86400000;
144
+ const cleaned = queue.filter(entry => {
145
+ if (entry.status === "delivered" && now - entry.lastAttempt > DAY)
146
+ return false;
147
+ if (entry.status === "failed" && now - entry.lastAttempt > 7 * DAY)
148
+ return false;
149
+ return true;
150
+ });
151
+ if (cleaned.length !== queue.length) {
152
+ writeQueue(cleaned);
153
+ }
154
+ }
@@ -0,0 +1,58 @@
1
+ import https from "https";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { config } from "../config.js";
6
+ const TEMP_DIR = path.join(os.tmpdir(), "alvin-bot");
7
+ if (!fs.existsSync(TEMP_DIR))
8
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
9
+ /**
10
+ * Generate speech via ElevenLabs API.
11
+ * Returns path to the mp3 file.
12
+ */
13
+ export async function elevenLabsTTS(text, voiceId, modelId) {
14
+ const voice = voiceId || config.elevenlabs.voiceId;
15
+ const model = modelId || config.elevenlabs.modelId;
16
+ const apiKey = config.elevenlabs.apiKey;
17
+ if (!apiKey)
18
+ throw new Error("ELEVENLABS_API_KEY not set");
19
+ const outputPath = path.join(TEMP_DIR, `tts_el_${Date.now()}.mp3`);
20
+ const body = JSON.stringify({
21
+ text,
22
+ model_id: model,
23
+ voice_settings: {
24
+ stability: 0.5,
25
+ similarity_boost: 0.75,
26
+ },
27
+ });
28
+ return new Promise((resolve, reject) => {
29
+ const req = https.request({
30
+ hostname: "api.elevenlabs.io",
31
+ path: `/v1/text-to-speech/${voice}`,
32
+ method: "POST",
33
+ headers: {
34
+ "Accept": "audio/mpeg",
35
+ "Content-Type": "application/json",
36
+ "xi-api-key": apiKey,
37
+ "Content-Length": Buffer.byteLength(body),
38
+ },
39
+ }, (res) => {
40
+ if (res.statusCode !== 200) {
41
+ let data = "";
42
+ res.on("data", chunk => data += chunk);
43
+ res.on("end", () => reject(new Error(`ElevenLabs API error ${res.statusCode}: ${data}`)));
44
+ return;
45
+ }
46
+ const file = fs.createWriteStream(outputPath);
47
+ res.pipe(file);
48
+ file.on("finish", () => {
49
+ file.close();
50
+ resolve(outputPath);
51
+ });
52
+ file.on("error", reject);
53
+ });
54
+ req.on("error", reject);
55
+ req.write(body);
56
+ req.end();
57
+ });
58
+ }