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,1489 @@
1
+ import { InlineKeyboard, InputFile } from "grammy";
2
+ import fs from "fs";
3
+ import path, { resolve } from "path";
4
+ import os from "os";
5
+ import { getSession, resetSession } from "../services/session.js";
6
+ import { getRegistry } from "../engine.js";
7
+ import { reloadSoul } from "../services/personality.js";
8
+ import { parseDuration, createReminder, listReminders, cancelReminder } from "../services/reminders.js";
9
+ import { writeSessionSummary, getMemoryStats, appendDailyLog } from "../services/memory.js";
10
+ import { approveGroup, blockGroup, listGroups, getSettings, setForwardingAllowed, setAutoApprove, } from "../services/access.js";
11
+ import { generateImage } from "../services/imagegen.js";
12
+ import { searchMemory, reindexMemory, getIndexStats } from "../services/embeddings.js";
13
+ import { listProfiles, addUserNote } from "../services/users.js";
14
+ import { getLoadedPlugins, getPluginsDir } from "../services/plugins.js";
15
+ import { getMCPStatus, getMCPTools, callMCPTool } from "../services/mcp.js";
16
+ import { listCustomTools, executeCustomTool } from "../services/custom-tools.js";
17
+ import { screenshotUrl, extractText, generatePdf, hasPlaywright } from "../services/browser.js";
18
+ import { listJobs, createJob, deleteJob, toggleJob, runJobNow, formatNextRun, humanReadableSchedule } from "../services/cron.js";
19
+ import { storePassword, revokePassword, getSudoStatus, verifyPassword } from "../services/sudo.js";
20
+ import { config } from "../config.js";
21
+ import { getWebPort } from "../web/server.js";
22
+ import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
23
+ /** Bot start time for uptime tracking */
24
+ const botStartTime = Date.now();
25
+ /** Format bytes to human-readable */
26
+ function formatBytes(bytes) {
27
+ if (bytes < 1024)
28
+ return `${bytes} B`;
29
+ if (bytes < 1024 * 1024)
30
+ return `${(bytes / 1024).toFixed(1)} KB`;
31
+ if (bytes < 1024 * 1024 * 1024)
32
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
33
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
34
+ }
35
+ const EFFORT_LABELS = {
36
+ low: "Low — Quick, concise answers",
37
+ medium: "Medium — Moderate reasoning depth",
38
+ high: "High — Deep reasoning (default)",
39
+ max: "Max — Maximum effort (Opus only)",
40
+ };
41
+ export function registerCommands(bot) {
42
+ bot.command("ping", async (ctx) => {
43
+ const start = Date.now();
44
+ const registry = getRegistry();
45
+ const active = registry.getActive();
46
+ const info = active.getInfo();
47
+ const latency = Date.now() - start;
48
+ await ctx.reply(`🏓 Pong! (${latency}ms)\n${info.name} ${info.status}`);
49
+ });
50
+ bot.command("help", async (ctx) => {
51
+ await ctx.reply(`🤖 *Alvin Bot — Commands*\n\n` +
52
+ `💬 *Chat*\n` +
53
+ `Just write — I'll respond.\n` +
54
+ `I also understand voice messages & photos.\n\n` +
55
+ `⚙️ *Controls*\n` +
56
+ `/model — Switch AI model\n` +
57
+ `/fallback — Provider order\n` +
58
+ `/effort — Set reasoning depth\n` +
59
+ `/voice — Voice replies on/off\n` +
60
+ `/dir <path> — Working directory\n\n` +
61
+ `🎨 *Extras*\n` +
62
+ `/imagine <prompt> — Generate image\n` +
63
+ `/remind <time> <text> — Set reminder\n` +
64
+ `/export — Export conversation\n\n` +
65
+ `🧠 *Memory*\n` +
66
+ `/recall <query> — Semantic search\n` +
67
+ `/remember <text> — Remember something\n` +
68
+ `/reindex — Re-index memory\n\n` +
69
+ `🌐 *Browser*\n` +
70
+ `/browse <URL> — Screenshot\n` +
71
+ `/browse text <URL> — Extract text\n` +
72
+ `/browse pdf <URL> — Save as PDF\n\n` +
73
+ `🔌 *Extensions*\n` +
74
+ `/plugins — Loaded plugins\n` +
75
+ `/mcp — MCP servers & tools\n` +
76
+ `/users — User profiles\n\n` +
77
+ `🖥️ *Web UI*\n` +
78
+ `/webui — Open Web UI in browser\n\n` +
79
+ `📊 *Session*\n` +
80
+ `/status — Current status\n` +
81
+ `/new — Start new session\n` +
82
+ `/cancel — Cancel running request\n\n` +
83
+ `_Tip: Send me documents, photos, or voice messages!_\n` +
84
+ `_In groups: @mention me or reply to my messages._`, { parse_mode: "Markdown" });
85
+ });
86
+ // Register bot commands in Telegram's menu
87
+ bot.api.setMyCommands([
88
+ { command: "help", description: "Show all commands" },
89
+ { command: "model", description: "Switch AI model" },
90
+ { command: "effort", description: "Set reasoning depth" },
91
+ { command: "voice", description: "Voice replies on/off" },
92
+ { command: "status", description: "Current status" },
93
+ { command: "new", description: "Start new session" },
94
+ { command: "dir", description: "Change working directory" },
95
+ { command: "web", description: "Quick web search" },
96
+ { command: "imagine", description: "Generate image (e.g. /imagine A fox)" },
97
+ { command: "remind", description: "Set reminder (e.g. /remind 30m Text)" },
98
+ { command: "export", description: "Export conversation" },
99
+ { command: "recall", description: "Semantic memory search" },
100
+ { command: "remember", description: "Remember something" },
101
+ { command: "cron", description: "Manage scheduled jobs" },
102
+ { command: "webui", description: "Open Web UI in browser" },
103
+ { command: "setup", description: "Configure API keys & platforms" },
104
+ { command: "cancel", description: "Cancel running request" },
105
+ ]).catch(err => console.error("Failed to set bot commands:", err));
106
+ bot.command("start", async (ctx) => {
107
+ const registry = getRegistry();
108
+ const activeInfo = registry.getActive().getInfo();
109
+ await ctx.reply(`👋 *Hey! I'm Alvin Bot.*\n\n` +
110
+ `Your autonomous AI assistant on Telegram. Just write me — ` +
111
+ `I understand text, voice messages, photos, and documents.\n\n` +
112
+ `🤖 Model: *${activeInfo.name}*\n` +
113
+ `🧠 Reasoning: High\n\n` +
114
+ `Type /help for all commands.`, { parse_mode: "Markdown" });
115
+ });
116
+ bot.command("webui", async (ctx) => {
117
+ const port = getWebPort();
118
+ const url = `http://localhost:${port}`;
119
+ await ctx.reply(`🌐 *Web UI* is running on port ${port}.\n\n` +
120
+ `Open in your browser:\n${url}`, { parse_mode: "Markdown" });
121
+ });
122
+ bot.command("new", async (ctx) => {
123
+ const userId = ctx.from.id;
124
+ const session = getSession(userId);
125
+ const hadSession = !!session.sessionId || session.history.length > 0;
126
+ const msgCount = session.messageCount;
127
+ const cost = session.totalCost;
128
+ // Write session summary to daily log before reset
129
+ if (hadSession && msgCount > 0) {
130
+ const registry = getRegistry();
131
+ writeSessionSummary({
132
+ messageCount: msgCount,
133
+ toolUseCount: session.toolUseCount,
134
+ costUsd: cost,
135
+ provider: registry.getActiveKey(),
136
+ });
137
+ }
138
+ resetSession(userId);
139
+ if (hadSession) {
140
+ await ctx.reply(`🔄 *New session started.*\n\n` +
141
+ `Previous session: ${msgCount} messages, $${cost.toFixed(4)} cost.\n` +
142
+ `Summary saved to memory.`, { parse_mode: "Markdown" });
143
+ }
144
+ else {
145
+ await ctx.reply("🔄 New session started.");
146
+ }
147
+ });
148
+ bot.command("dir", async (ctx) => {
149
+ const userId = ctx.from.id;
150
+ const session = getSession(userId);
151
+ const newDir = ctx.match?.trim();
152
+ if (!newDir) {
153
+ await ctx.reply(`Current directory: ${session.workingDir}`);
154
+ return;
155
+ }
156
+ const resolved = newDir.startsWith("~")
157
+ ? path.join(os.homedir(), newDir.slice(1))
158
+ : path.resolve(newDir);
159
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
160
+ session.workingDir = resolved;
161
+ await ctx.reply(`Working directory: ${session.workingDir}`);
162
+ }
163
+ else {
164
+ await ctx.reply(`Directory not found: ${resolved}`);
165
+ }
166
+ });
167
+ bot.command("status", async (ctx) => {
168
+ const userId = ctx.from.id;
169
+ const session = getSession(userId);
170
+ const registry = getRegistry();
171
+ const active = registry.getActive();
172
+ const info = active.getInfo();
173
+ // Uptime
174
+ const uptimeMs = Date.now() - botStartTime;
175
+ const uptimeH = Math.floor(uptimeMs / 3_600_000);
176
+ const uptimeM = Math.floor((uptimeMs % 3_600_000) / 60_000);
177
+ // Session duration
178
+ const sessionMs = Date.now() - session.startedAt;
179
+ const sessionM = Math.floor(sessionMs / 60_000);
180
+ // Provider type detection
181
+ const isOAuth = active.config.type === "claude-sdk" || active.config.type === "codex-cli";
182
+ const providerTag = isOAuth ? "OAuth/Flat-Rate" : "API";
183
+ // Token stats
184
+ const inTok = formatTokens(session.totalInputTokens);
185
+ const outTok = formatTokens(session.totalOutputTokens);
186
+ // Cost display
187
+ const costStr = isOAuth
188
+ ? `$0.00 (Flat-Rate) | ${formatTokens(session.totalInputTokens + session.totalOutputTokens)} tokens`
189
+ : `$${session.totalCost.toFixed(4)}`;
190
+ // Provider breakdown (session)
191
+ let providerLines = "";
192
+ const providers = Object.entries(session.queriesByProvider);
193
+ if (providers.length > 0) {
194
+ providerLines = providers.map(([key, queries]) => {
195
+ const cost = session.costByProvider[key] || 0;
196
+ const costLabel = isOAuth && key === registry.getActiveKey() ? "flat" : `$${cost.toFixed(4)}`;
197
+ return ` ${key}: ${queries}q, ${costLabel}`;
198
+ }).join("\n");
199
+ }
200
+ // Usage summary (daily/weekly from tracker)
201
+ const usage = getUsageSummary();
202
+ const todayTotalTok = usage.today.inputTokens + usage.today.outputTokens;
203
+ const weekTotalTok = usage.week.inputTokens + usage.week.outputTokens;
204
+ const todayTok = formatTokens(todayTotalTok);
205
+ const weekTok = formatTokens(weekTotalTok);
206
+ // Cost or plan label for usage section
207
+ const todayCostStr = isOAuth ? "" : ` ($${usage.today.costUsd.toFixed(4)})`;
208
+ const weekCostStr = isOAuth ? "" : ` ($${usage.week.costUsd.toFixed(4)})`;
209
+ // Rate limits (from last API response)
210
+ let rlLines = "";
211
+ const allRL = getAllRateLimits();
212
+ if (allRL.size > 0) {
213
+ const parts = [];
214
+ for (const [prov, rl] of allRL) {
215
+ const lines = [];
216
+ if (rl.requestsRemaining != null && rl.requestsLimit) {
217
+ const pct = Math.round((rl.requestsRemaining / rl.requestsLimit) * 100);
218
+ const reset = rl.requestsReset ? ` (reset ${rl.requestsReset.replace(/T.*/, "").slice(5) || rl.requestsReset})` : "";
219
+ lines.push(`Req: ${rl.requestsRemaining}/${rl.requestsLimit} (${pct}%)${reset}`);
220
+ }
221
+ if (rl.tokensRemaining != null && rl.tokensLimit) {
222
+ const pct = Math.round((rl.tokensRemaining / rl.tokensLimit) * 100);
223
+ lines.push(`Tok: ${formatTokens(rl.tokensRemaining)}/${formatTokens(rl.tokensLimit)} (${pct}%)`);
224
+ }
225
+ if (lines.length > 0) {
226
+ parts.push(` ${lines.join(" | ")}`);
227
+ }
228
+ }
229
+ if (parts.length > 0) {
230
+ rlLines = `\n⚡ *Rate Limits*\n${parts.join("\n")}\n`;
231
+ }
232
+ }
233
+ // Memory stats
234
+ const memStats = getMemoryStats();
235
+ const idxStats = getIndexStats();
236
+ const memLine = `${memStats.dailyLogs} days, ${memStats.todayEntries} entries today, ${formatBytes(memStats.longTermSize)} LTM | 🔍 ${idxStats.entries} vectors`;
237
+ await ctx.reply(`🤖 *Alvin Bot Status*\n\n` +
238
+ `*Model:* ${info.name} — ${providerTag}\n` +
239
+ `*Effort:* ${EFFORT_LABELS[session.effort]}\n` +
240
+ `*Voice:* ${session.voiceReply ? "on" : "off"} | *Dir:* \`${session.workingDir.replace(os.homedir(), "~")}\`\n\n` +
241
+ `📊 *Session* (${sessionM} min)\n` +
242
+ `Messages: ${session.messageCount} | Tools: ${session.toolUseCount}\n` +
243
+ `Tokens: ${inTok} in / ${outTok} out\n` +
244
+ `Cost: ${costStr}\n` +
245
+ (providerLines ? `${providerLines}\n` : "") +
246
+ `\n📈 *Usage*\n` +
247
+ `Today: ${usage.today.queries}q, ${todayTok} tokens${todayCostStr}\n` +
248
+ `Week: ${usage.week.queries}q, ${weekTok} tokens${weekCostStr}\n` +
249
+ (usage.daysTracked > 1 ? `Avg: ${formatTokens(usage.avgDailyTokens)} tok/day\n` : "") +
250
+ rlLines +
251
+ `\n🧠 *Memory:* ${memLine}\n` +
252
+ `⏱ *Uptime:* ${uptimeH}h ${uptimeM}m`, { parse_mode: "Markdown" });
253
+ });
254
+ bot.command("voice", async (ctx) => {
255
+ const userId = ctx.from.id;
256
+ const session = getSession(userId);
257
+ session.voiceReply = !session.voiceReply;
258
+ await ctx.reply(session.voiceReply
259
+ ? "Voice replies enabled. Responses will also be sent as voice messages."
260
+ : "Voice replies disabled. Text-only responses.");
261
+ });
262
+ bot.command("effort", async (ctx) => {
263
+ const userId = ctx.from.id;
264
+ const session = getSession(userId);
265
+ const level = ctx.match?.trim().toLowerCase();
266
+ if (!level) {
267
+ const keyboard = new InlineKeyboard();
268
+ for (const [key, label] of Object.entries(EFFORT_LABELS)) {
269
+ const marker = key === session.effort ? "✅ " : "";
270
+ keyboard.text(`${marker}${label}`, `effort:${key}`).row();
271
+ }
272
+ await ctx.reply(`🧠 *Choose reasoning depth:*\n\nActive: *${EFFORT_LABELS[session.effort]}*`, { parse_mode: "Markdown", reply_markup: keyboard });
273
+ return;
274
+ }
275
+ if (!["low", "medium", "high", "max"].includes(level)) {
276
+ await ctx.reply("Invalid. Use: /effort low | medium | high | max");
277
+ return;
278
+ }
279
+ session.effort = level;
280
+ await ctx.reply(`✅ Effort: ${EFFORT_LABELS[session.effort]}`);
281
+ });
282
+ // Inline keyboard callback for effort switching
283
+ bot.callbackQuery(/^effort:(.+)$/, async (ctx) => {
284
+ const level = ctx.match[1];
285
+ if (!["low", "medium", "high", "max"].includes(level)) {
286
+ await ctx.answerCallbackQuery("Invalid level");
287
+ return;
288
+ }
289
+ const userId = ctx.from.id;
290
+ const session = getSession(userId);
291
+ session.effort = level;
292
+ const keyboard = new InlineKeyboard();
293
+ for (const [key, label] of Object.entries(EFFORT_LABELS)) {
294
+ const marker = key === session.effort ? "✅ " : "";
295
+ keyboard.text(`${marker}${label}`, `effort:${key}`).row();
296
+ }
297
+ await ctx.editMessageText(`🧠 *Choose reasoning depth:*\n\nActive: *${EFFORT_LABELS[session.effort]}*`, { parse_mode: "Markdown", reply_markup: keyboard });
298
+ await ctx.answerCallbackQuery(`Effort: ${EFFORT_LABELS[session.effort]}`);
299
+ });
300
+ bot.command("model", async (ctx) => {
301
+ const arg = ctx.match?.trim().toLowerCase();
302
+ const registry = getRegistry();
303
+ if (!arg) {
304
+ // Show inline keyboard with available models
305
+ const providers = await registry.listAll();
306
+ const keyboard = new InlineKeyboard();
307
+ for (const p of providers) {
308
+ const label = p.active ? `✅ ${p.name}` : p.name;
309
+ keyboard.text(label, `model:${p.key}`).row();
310
+ }
311
+ await ctx.reply(`🤖 *Choose model:*\n\nActive: *${registry.getActive().getInfo().name}*`, { parse_mode: "Markdown", reply_markup: keyboard });
312
+ return;
313
+ }
314
+ if (registry.switchTo(arg)) {
315
+ const provider = registry.get(arg);
316
+ const info = provider.getInfo();
317
+ await ctx.reply(`✅ Switched model: ${info.name} (${info.model})`);
318
+ }
319
+ else {
320
+ await ctx.reply(`Model "${arg}" not found. Use /model to see all options.`);
321
+ }
322
+ });
323
+ // Inline keyboard callback for model switching
324
+ bot.callbackQuery(/^model:(.+)$/, async (ctx) => {
325
+ const key = ctx.match[1];
326
+ const registry = getRegistry();
327
+ if (registry.switchTo(key)) {
328
+ const provider = registry.get(key);
329
+ const info = provider.getInfo();
330
+ // Update the keyboard to show new selection
331
+ const providers = await registry.listAll();
332
+ const keyboard = new InlineKeyboard();
333
+ for (const p of providers) {
334
+ const label = p.active ? `✅ ${p.name}` : p.name;
335
+ keyboard.text(label, `model:${p.key}`).row();
336
+ }
337
+ await ctx.editMessageText(`🤖 *Choose model:*\n\nActive: *${info.name}*`, { parse_mode: "Markdown", reply_markup: keyboard });
338
+ await ctx.answerCallbackQuery(`Switched: ${info.name}`);
339
+ }
340
+ else {
341
+ await ctx.answerCallbackQuery(`Model "${key}" not found`);
342
+ }
343
+ });
344
+ // ── Fallback Order ────────────────────────────────────────────────────
345
+ bot.command("fallback", async (ctx) => {
346
+ const { getFallbackOrder, setFallbackOrder, formatOrder } = await import("../services/fallback-order.js");
347
+ const { getHealthStatus } = await import("../services/heartbeat.js");
348
+ const registry = getRegistry();
349
+ const arg = ctx.match?.trim();
350
+ if (!arg) {
351
+ // Show current order with inline keyboard
352
+ const order = getFallbackOrder();
353
+ const health = getHealthStatus();
354
+ const healthMap = new Map(health.map(h => [h.key, h]));
355
+ const allKeys = [order.primary, ...order.fallbacks];
356
+ const keyboard = new InlineKeyboard();
357
+ for (let i = 0; i < allKeys.length; i++) {
358
+ const key = allKeys[i];
359
+ const h = healthMap.get(key);
360
+ const status = h ? (h.healthy ? "✅" : "❌") : "❓";
361
+ const label = i === 0 ? `🥇 ${key} ${status}` : `${i + 1}. ${key} ${status}`;
362
+ if (i > 0)
363
+ keyboard.text("⬆️", `fb:up:${key}`);
364
+ keyboard.text(label, `fb:info:${key}`);
365
+ if (i < allKeys.length - 1)
366
+ keyboard.text("⬇️", `fb:down:${key}`);
367
+ keyboard.row();
368
+ }
369
+ const text = `🔄 *Fallback Order*\n\n` +
370
+ `Providers are tried in this order.\n` +
371
+ `Use ⬆️/⬇️ to reorder.\n\n` +
372
+ `_Last changed: ${order.updatedBy} (${new Date(order.updatedAt).toLocaleString("en-US")})_`;
373
+ await ctx.reply(text, { parse_mode: "Markdown", reply_markup: keyboard });
374
+ return;
375
+ }
376
+ // Direct text commands: /fallback set groq,openai,nvidia-llama-3.3-70b
377
+ if (arg.startsWith("set ")) {
378
+ const parts = arg.slice(4).split(",").map(s => s.trim()).filter(Boolean);
379
+ if (parts.length < 1) {
380
+ await ctx.reply("Usage: `/fallback set primary,fallback1,fallback2,...`", { parse_mode: "Markdown" });
381
+ return;
382
+ }
383
+ const [primary, ...fallbacks] = parts;
384
+ setFallbackOrder(primary, fallbacks, "telegram");
385
+ await ctx.reply(`✅ New order:\n\n${formatOrder()}`);
386
+ return;
387
+ }
388
+ await ctx.reply(`🔄 *Fallback Order*\n\n` +
389
+ `\`/fallback\` — Show & change order\n` +
390
+ `\`/fallback set groq,openai,...\` — Set directly`, { parse_mode: "Markdown" });
391
+ });
392
+ // Callback queries for fallback ordering
393
+ bot.callbackQuery(/^fb:up:(.+)$/, async (ctx) => {
394
+ const { moveUp, formatOrder, getFallbackOrder } = await import("../services/fallback-order.js");
395
+ const { getHealthStatus } = await import("../services/heartbeat.js");
396
+ const key = ctx.match[1];
397
+ moveUp(key, "telegram");
398
+ const order = getFallbackOrder();
399
+ const health = getHealthStatus();
400
+ const healthMap = new Map(health.map(h => [h.key, h]));
401
+ const allKeys = [order.primary, ...order.fallbacks];
402
+ const keyboard = new InlineKeyboard();
403
+ for (let i = 0; i < allKeys.length; i++) {
404
+ const k = allKeys[i];
405
+ const h = healthMap.get(k);
406
+ const status = h ? (h.healthy ? "✅" : "❌") : "❓";
407
+ const label = i === 0 ? `🥇 ${k} ${status}` : `${i + 1}. ${k} ${status}`;
408
+ if (i > 0)
409
+ keyboard.text("⬆️", `fb:up:${k}`);
410
+ keyboard.text(label, `fb:info:${k}`);
411
+ if (i < allKeys.length - 1)
412
+ keyboard.text("⬇️", `fb:down:${k}`);
413
+ keyboard.row();
414
+ }
415
+ await ctx.editMessageText(`🔄 *Fallback Order*\n\n` +
416
+ `Provider werden in dieser Reihenfolge versucht.\n` +
417
+ `Nutze ⬆️/⬇️ zum Umsortieren.\n\n` +
418
+ `_Last changed: telegram (${new Date().toLocaleString("en-US")})_`, { parse_mode: "Markdown", reply_markup: keyboard });
419
+ await ctx.answerCallbackQuery(`${key} moved up`);
420
+ });
421
+ bot.callbackQuery(/^fb:down:(.+)$/, async (ctx) => {
422
+ const { moveDown, getFallbackOrder } = await import("../services/fallback-order.js");
423
+ const { getHealthStatus } = await import("../services/heartbeat.js");
424
+ const key = ctx.match[1];
425
+ moveDown(key, "telegram");
426
+ const order = getFallbackOrder();
427
+ const health = getHealthStatus();
428
+ const healthMap = new Map(health.map(h => [h.key, h]));
429
+ const allKeys = [order.primary, ...order.fallbacks];
430
+ const keyboard = new InlineKeyboard();
431
+ for (let i = 0; i < allKeys.length; i++) {
432
+ const k = allKeys[i];
433
+ const h = healthMap.get(k);
434
+ const status = h ? (h.healthy ? "✅" : "❌") : "❓";
435
+ const label = i === 0 ? `🥇 ${k} ${status}` : `${i + 1}. ${k} ${status}`;
436
+ if (i > 0)
437
+ keyboard.text("⬆️", `fb:up:${k}`);
438
+ keyboard.text(label, `fb:info:${k}`);
439
+ if (i < allKeys.length - 1)
440
+ keyboard.text("⬇️", `fb:down:${k}`);
441
+ keyboard.row();
442
+ }
443
+ await ctx.editMessageText(`🔄 *Fallback Order*\n\n` +
444
+ `Provider werden in dieser Reihenfolge versucht.\n` +
445
+ `Nutze ⬆️/⬇️ zum Umsortieren.\n\n` +
446
+ `_Last changed: telegram (${new Date().toLocaleString("en-US")})_`, { parse_mode: "Markdown", reply_markup: keyboard });
447
+ await ctx.answerCallbackQuery(`${key} moved down`);
448
+ });
449
+ bot.callbackQuery(/^fb:info:(.+)$/, async (ctx) => {
450
+ const { getHealthStatus } = await import("../services/heartbeat.js");
451
+ const key = ctx.match[1];
452
+ const health = getHealthStatus();
453
+ const h = health.find(p => p.key === key);
454
+ if (h) {
455
+ await ctx.answerCallbackQuery({
456
+ text: `${key}: ${h.healthy ? "✅ Healthy" : "❌ Unhealthy"} | ${h.latencyMs}ms | Errors: ${h.failCount}`,
457
+ show_alert: true,
458
+ });
459
+ }
460
+ else {
461
+ await ctx.answerCallbackQuery(`${key}: Not checked yet`);
462
+ }
463
+ });
464
+ bot.command("web", async (ctx) => {
465
+ const query = ctx.match?.trim();
466
+ if (!query) {
467
+ await ctx.reply("Search: `/web your search query`", { parse_mode: "Markdown" });
468
+ return;
469
+ }
470
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
471
+ try {
472
+ // Use DuckDuckGo instant answer API (no key needed)
473
+ const encoded = encodeURIComponent(query);
474
+ const res = await fetch(`https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1&skip_disambig=1`);
475
+ const data = await res.json();
476
+ const lines = [];
477
+ if (data.Answer) {
478
+ lines.push(`💡 *${data.Answer}*\n`);
479
+ }
480
+ if (data.AbstractText) {
481
+ const text = data.AbstractText.length > 500
482
+ ? data.AbstractText.slice(0, 500) + "..."
483
+ : data.AbstractText;
484
+ lines.push(text);
485
+ if (data.AbstractSource && data.AbstractURL) {
486
+ lines.push(`\n_Source: [${data.AbstractSource}](${data.AbstractURL})_`);
487
+ }
488
+ }
489
+ if (lines.length === 0 && data.RelatedTopics && data.RelatedTopics.length > 0) {
490
+ lines.push(`🔍 *Results for "${query}":*\n`);
491
+ for (const topic of data.RelatedTopics.slice(0, 5)) {
492
+ if (topic.Text) {
493
+ const short = topic.Text.length > 150 ? topic.Text.slice(0, 150) + "..." : topic.Text;
494
+ lines.push(`• ${short}`);
495
+ }
496
+ }
497
+ }
498
+ if (lines.length === 0) {
499
+ lines.push(`No results for "${query}". Try it as a regular message — I'll search using the AI model.`);
500
+ }
501
+ await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" }).catch(() => ctx.reply(lines.join("\n")));
502
+ }
503
+ catch (err) {
504
+ await ctx.reply(`Search error: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
506
+ });
507
+ bot.command("imagine", async (ctx) => {
508
+ const prompt = ctx.match?.trim();
509
+ if (!prompt) {
510
+ await ctx.reply("Describe what I should generate:\n`/imagine A fox sitting on the moon`", { parse_mode: "Markdown" });
511
+ return;
512
+ }
513
+ if (!config.apiKeys.google) {
514
+ await ctx.reply("⚠️ Image generation unavailable (GOOGLE_API_KEY missing).");
515
+ return;
516
+ }
517
+ await ctx.api.sendChatAction(ctx.chat.id, "upload_photo");
518
+ const result = await generateImage(prompt, config.apiKeys.google);
519
+ if (result.success && result.filePath) {
520
+ try {
521
+ const fileData = fs.readFileSync(result.filePath);
522
+ await ctx.replyWithPhoto(new InputFile(fileData, `generated${result.filePath.endsWith(".png") ? ".png" : ".jpg"}`), {
523
+ caption: `🎨 _${prompt}_`,
524
+ parse_mode: "Markdown",
525
+ });
526
+ fs.unlink(result.filePath, () => { });
527
+ }
528
+ catch (err) {
529
+ await ctx.reply(`Error sending: ${err instanceof Error ? err.message : String(err)}`);
530
+ }
531
+ }
532
+ else {
533
+ await ctx.reply(`❌ ${result.error || "Image generation failed."}`);
534
+ }
535
+ });
536
+ bot.command("remind", async (ctx) => {
537
+ const userId = ctx.from.id;
538
+ const chatId = ctx.chat.id;
539
+ const input = ctx.match?.trim();
540
+ if (!input) {
541
+ // List reminders
542
+ const pending = listReminders(userId);
543
+ if (pending.length === 0) {
544
+ await ctx.reply("No active reminders.\n\nNew: `/remind 30m Call mom`", { parse_mode: "Markdown" });
545
+ }
546
+ else {
547
+ const lines = pending.map(r => `• *${r.remaining}* — ${r.text} (ID: ${r.id})`);
548
+ await ctx.reply(`⏰ *Active Reminders:*\n\n${lines.join("\n")}\n\nCancel: \`/remind cancel <ID>\``, { parse_mode: "Markdown" });
549
+ }
550
+ return;
551
+ }
552
+ // Cancel a reminder
553
+ if (input.startsWith("cancel ")) {
554
+ const id = parseInt(input.slice(7).trim());
555
+ if (isNaN(id)) {
556
+ await ctx.reply("Invalid ID. Use: `/remind cancel <ID>`", { parse_mode: "Markdown" });
557
+ return;
558
+ }
559
+ if (cancelReminder(id, userId)) {
560
+ await ctx.reply(`✅ Reminder #${id} cancelled.`);
561
+ }
562
+ else {
563
+ await ctx.reply(`❌ Reminder #${id} not found.`);
564
+ }
565
+ return;
566
+ }
567
+ // Parse: /remind <duration> <text>
568
+ const spaceIdx = input.indexOf(" ");
569
+ if (spaceIdx === -1) {
570
+ await ctx.reply("Format: `/remind 30m Reminder text`", { parse_mode: "Markdown" });
571
+ return;
572
+ }
573
+ const durationStr = input.slice(0, spaceIdx);
574
+ const text = input.slice(spaceIdx + 1).trim();
575
+ const delayMs = parseDuration(durationStr);
576
+ if (!delayMs) {
577
+ await ctx.reply("Invalid duration. Examples: `30s`, `5m`, `2h`, `1d`", { parse_mode: "Markdown" });
578
+ return;
579
+ }
580
+ if (!text) {
581
+ await ctx.reply("Please provide text: `/remind 30m Call mom`", { parse_mode: "Markdown" });
582
+ return;
583
+ }
584
+ const reminder = createReminder(chatId, userId, text, delayMs, ctx.api);
585
+ // Format trigger time
586
+ const triggerDate = new Date(reminder.triggerAt);
587
+ const timeStr = triggerDate.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
588
+ await ctx.reply(`✅ Reminder set for *${timeStr}*: ${text}`, { parse_mode: "Markdown" });
589
+ });
590
+ bot.command("export", async (ctx) => {
591
+ const userId = ctx.from.id;
592
+ const session = getSession(userId);
593
+ if (session.history.length === 0 && !session.sessionId) {
594
+ await ctx.reply("No conversation data to export.");
595
+ return;
596
+ }
597
+ // Build export text
598
+ const lines = [
599
+ `# Alvin Bot — Conversation Export`,
600
+ `Date: ${new Date().toLocaleString("en-US")}`,
601
+ `Messages: ${session.messageCount}`,
602
+ `Cost: $${session.totalCost.toFixed(4)}`,
603
+ `---\n`,
604
+ ];
605
+ for (const msg of session.history) {
606
+ const role = msg.role === "user" ? "👤 User" : "🤖 Alvin Bot";
607
+ lines.push(`### ${role}\n${msg.content}\n`);
608
+ }
609
+ if (session.history.length === 0) {
610
+ lines.push("(SDK session — history managed internally, no export available)\n");
611
+ }
612
+ const exportText = lines.join("\n");
613
+ const buffer = Buffer.from(exportText, "utf-8");
614
+ const filename = `chat-export-${new Date().toISOString().slice(0, 10)}.md`;
615
+ await ctx.replyWithDocument(new InputFile(buffer, filename), {
616
+ caption: `📄 Export: ${session.history.length} messages`,
617
+ });
618
+ });
619
+ bot.command("lang", async (ctx) => {
620
+ const userId = ctx.from.id;
621
+ const session = getSession(userId);
622
+ const arg = ctx.match?.trim().toLowerCase();
623
+ if (!arg) {
624
+ const keyboard = new InlineKeyboard()
625
+ .text(session.language === "de" ? "✅ Deutsch" : "Deutsch", "lang:de")
626
+ .text(session.language === "en" ? "✅ English" : "English", "lang:en")
627
+ .row()
628
+ .text("🔄 Auto-detect", "lang:auto");
629
+ await ctx.reply(`🌐 *Language:* ${session.language === "de" ? "Deutsch" : "English"}`, {
630
+ parse_mode: "Markdown",
631
+ reply_markup: keyboard,
632
+ });
633
+ return;
634
+ }
635
+ if (arg === "auto") {
636
+ const { resetToAutoLanguage } = await import("../services/language-detect.js");
637
+ resetToAutoLanguage(userId);
638
+ await ctx.reply("🔄 Auto-detection enabled. I'll adapt to the language you write in.");
639
+ }
640
+ else if (arg === "de" || arg === "en") {
641
+ session.language = arg;
642
+ const { setExplicitLanguage } = await import("../services/language-detect.js");
643
+ setExplicitLanguage(userId, arg);
644
+ await ctx.reply(arg === "de" ? "✅ Language: Deutsch (fixed)" : "✅ Language: English (fixed)");
645
+ }
646
+ else {
647
+ await ctx.reply("Use: `/lang de`, `/lang en`, or `/lang auto`", { parse_mode: "Markdown" });
648
+ }
649
+ });
650
+ bot.callbackQuery(/^lang:(de|en|auto)$/, async (ctx) => {
651
+ const choice = ctx.match[1];
652
+ const userId = ctx.from.id;
653
+ const session = getSession(userId);
654
+ if (choice === "auto") {
655
+ const { resetToAutoLanguage } = await import("../services/language-detect.js");
656
+ resetToAutoLanguage(userId);
657
+ await ctx.answerCallbackQuery({ text: "🔄 Auto-detect enabled" });
658
+ await ctx.editMessageText("🌐 *Language:* Auto-detect 🔄", { parse_mode: "Markdown" });
659
+ return;
660
+ }
661
+ const lang = choice;
662
+ session.language = lang;
663
+ const { setExplicitLanguage } = await import("../services/language-detect.js");
664
+ setExplicitLanguage(userId, lang);
665
+ const keyboard = new InlineKeyboard()
666
+ .text(lang === "de" ? "✅ Deutsch" : "Deutsch", "lang:de")
667
+ .text(lang === "en" ? "✅ English" : "English", "lang:en");
668
+ await ctx.editMessageText(`🌐 *Language:* ${lang === "de" ? "Deutsch" : "English"}`, {
669
+ parse_mode: "Markdown",
670
+ reply_markup: keyboard,
671
+ });
672
+ await ctx.answerCallbackQuery(lang === "de" ? "Deutsch" : "English");
673
+ });
674
+ bot.command("memory", async (ctx) => {
675
+ const stats = getMemoryStats();
676
+ const arg = ctx.match?.trim();
677
+ if (!arg) {
678
+ await ctx.reply(`🧠 *Memory*\n\n` +
679
+ `*Long-term memory:* ${formatBytes(stats.longTermSize)}\n` +
680
+ `*Daily logs:* ${stats.dailyLogs} files\n` +
681
+ `*Today:* ${stats.todayEntries} entries\n\n` +
682
+ `_Memory is automatically written on /new._\n` +
683
+ `_Non-SDK providers load memory as context._`, { parse_mode: "Markdown" });
684
+ return;
685
+ }
686
+ });
687
+ bot.command("system", async (ctx) => {
688
+ const memTotal = os.totalmem();
689
+ const memFree = os.freemem();
690
+ const memUsed = memTotal - memFree;
691
+ const memPercent = Math.round((memUsed / memTotal) * 100);
692
+ const uptime = os.uptime();
693
+ const uptimeH = Math.floor(uptime / 3600);
694
+ const uptimeM = Math.floor((uptime % 3600) / 60);
695
+ const cpus = os.cpus();
696
+ const loadAvg = os.loadavg();
697
+ const procMem = process.memoryUsage();
698
+ await ctx.reply(`🖥 *System Info*\n\n` +
699
+ `*OS:* ${os.platform()} ${os.arch()} (${os.release()})\n` +
700
+ `*Host:* ${os.hostname()}\n` +
701
+ `*CPUs:* ${cpus.length}x ${cpus[0]?.model?.trim() || "unknown"}\n` +
702
+ `*Load:* ${loadAvg.map(l => l.toFixed(2)).join(", ")}\n` +
703
+ `*RAM:* ${formatBytes(memUsed)} / ${formatBytes(memTotal)} (${memPercent}%)\n` +
704
+ `*System Uptime:* ${uptimeH}h ${uptimeM}m\n\n` +
705
+ `🤖 *Bot Process*\n` +
706
+ `*Node:* ${process.version}\n` +
707
+ `*Heap:* ${formatBytes(procMem.heapUsed)} / ${formatBytes(procMem.heapTotal)}\n` +
708
+ `*RSS:* ${formatBytes(procMem.rss)}\n` +
709
+ `*PID:* ${process.pid}`, { parse_mode: "Markdown" });
710
+ });
711
+ bot.command("reload", async (ctx) => {
712
+ const success = reloadSoul();
713
+ await ctx.reply(success ? "✅ SOUL.md reloaded." : "❌ SOUL.md not found.");
714
+ });
715
+ // ── Access Control ────────────────────────────────
716
+ // Callback for group approval/block
717
+ bot.callbackQuery(/^access:(approve|block):(-?\d+)$/, async (ctx) => {
718
+ const action = ctx.match[1];
719
+ const chatId = parseInt(ctx.match[2]);
720
+ if (action === "approve") {
721
+ approveGroup(chatId);
722
+ await ctx.editMessageText(`✅ Group ${chatId} approved. Alvin Bot will now respond there.`);
723
+ // Notify the group
724
+ try {
725
+ await ctx.api.sendMessage(chatId, "👋 Alvin Bot is now active in this group!\n\n@mention me or reply to my messages.");
726
+ }
727
+ catch { /* group might not allow bot messages yet */ }
728
+ }
729
+ else {
730
+ blockGroup(chatId);
731
+ await ctx.editMessageText(`🚫 Group ${chatId} blocked. Alvin Bot will ignore this group.`);
732
+ }
733
+ await ctx.answerCallbackQuery();
734
+ });
735
+ bot.command("groups", async (ctx) => {
736
+ const groups = listGroups();
737
+ if (groups.length === 0) {
738
+ await ctx.reply("No groups registered.");
739
+ return;
740
+ }
741
+ const lines = groups.map(g => {
742
+ const status = g.status === "approved" ? "✅" : g.status === "blocked" ? "🚫" : "⏳";
743
+ return `${status} *${g.title}* (${g.messageCount} msgs)\n ID: \`${g.chatId}\``;
744
+ });
745
+ const keyboard = new InlineKeyboard();
746
+ for (const g of groups) {
747
+ if (g.status === "approved") {
748
+ keyboard.text(`🚫 Block: ${g.title.slice(0, 20)}`, `access:block:${g.chatId}`).row();
749
+ }
750
+ else if (g.status === "blocked" || g.status === "pending") {
751
+ keyboard.text(`✅ Approve: ${g.title.slice(0, 20)}`, `access:approve:${g.chatId}`).row();
752
+ }
753
+ }
754
+ const settings = getSettings();
755
+ await ctx.reply(`🔐 *Group Management*\n\n` +
756
+ `${lines.join("\n\n")}\n\n` +
757
+ `⚙️ *Settings:*\n` +
758
+ `Forwards: ${settings.allowForwards ? "✅" : "❌"}\n` +
759
+ `Auto-Approve: ${settings.autoApproveGroups ? "⚠️ ON" : "✅ OFF"}`, { parse_mode: "Markdown", reply_markup: keyboard });
760
+ });
761
+ bot.command("security", async (ctx) => {
762
+ const arg = ctx.match?.trim().toLowerCase();
763
+ const settings = getSettings();
764
+ if (!arg) {
765
+ await ctx.reply(`🔐 *Security Settings*\n\n` +
766
+ `*Forwards:* ${settings.allowForwards ? "✅ allowed" : "❌ blocked"}\n` +
767
+ `*Auto-Approve Groups:* ${settings.autoApproveGroups ? "⚠️ ON (dangerous!)" : "✅ OFF"}\n` +
768
+ `*Group Rate Limit:* ${settings.groupRateLimitPerHour}/h\n\n` +
769
+ `Change:\n` +
770
+ `\`/security forwards on|off\`\n` +
771
+ `\`/security autoapprove on|off\``, { parse_mode: "Markdown" });
772
+ return;
773
+ }
774
+ if (arg.startsWith("forwards ")) {
775
+ const val = arg.slice(9).trim();
776
+ setForwardingAllowed(val === "on" || val === "true");
777
+ await ctx.reply(`✅ Forwards: ${val === "on" || val === "true" ? "allowed" : "blocked"}`);
778
+ }
779
+ else if (arg.startsWith("autoapprove ")) {
780
+ const val = arg.slice(12).trim();
781
+ setAutoApprove(val === "on" || val === "true");
782
+ await ctx.reply(`${val === "on" || val === "true" ? "⚠️" : "✅"} Auto-Approve: ${val === "on" || val === "true" ? "ON" : "OFF"}`);
783
+ }
784
+ else {
785
+ await ctx.reply("Unknown. Use `/security` for options.", { parse_mode: "Markdown" });
786
+ }
787
+ });
788
+ // ── Browser Automation ─────────────────────────────────
789
+ bot.command("browse", async (ctx) => {
790
+ const arg = ctx.match?.toString().trim();
791
+ if (!arg) {
792
+ await ctx.reply("🌐 *Browser Commands:*\n\n" +
793
+ "`/browse <URL>` — Screenshot a webpage\n" +
794
+ "`/browse text <URL>` — Extract text\n" +
795
+ "`/browse pdf <URL>` — Save as PDF", { parse_mode: "Markdown" });
796
+ return;
797
+ }
798
+ if (!hasPlaywright()) {
799
+ await ctx.reply("❌ Playwright not installed.\n`npm install playwright && npx playwright install chromium`", { parse_mode: "Markdown" });
800
+ return;
801
+ }
802
+ try {
803
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
804
+ // /browse text <url>
805
+ if (arg.startsWith("text ")) {
806
+ const url = arg.slice(5).trim();
807
+ const text = await extractText(url);
808
+ const truncated = text.length > 3500 ? text.slice(0, 3500) + "\n\n_[...truncated]_" : text;
809
+ await ctx.reply(`🌐 *Text from ${url}:*\n\n${truncated}`, { parse_mode: "Markdown" });
810
+ return;
811
+ }
812
+ // /browse pdf <url>
813
+ if (arg.startsWith("pdf ")) {
814
+ const url = arg.slice(4).trim();
815
+ await ctx.api.sendChatAction(ctx.chat.id, "upload_document");
816
+ const pdfPath = await generatePdf(url);
817
+ await ctx.replyWithDocument(new InputFile(fs.readFileSync(pdfPath), "page.pdf"), {
818
+ caption: `📄 PDF from ${url}`,
819
+ });
820
+ fs.unlink(pdfPath, () => { });
821
+ return;
822
+ }
823
+ // Default: screenshot
824
+ const url = arg.startsWith("http") ? arg : `https://${arg}`;
825
+ await ctx.api.sendChatAction(ctx.chat.id, "upload_photo");
826
+ const screenshotPath = await screenshotUrl(url, { fullPage: false });
827
+ await ctx.replyWithPhoto(new InputFile(fs.readFileSync(screenshotPath), "screenshot.png"), {
828
+ caption: `🌐 ${url}`,
829
+ });
830
+ fs.unlink(screenshotPath, () => { });
831
+ }
832
+ catch (err) {
833
+ const msg = err instanceof Error ? err.message : String(err);
834
+ await ctx.reply(`❌ Browser error: ${msg}`);
835
+ }
836
+ });
837
+ // ── Custom Tools ──────────────────────────────────────
838
+ bot.command("tools", async (ctx) => {
839
+ const arg = ctx.match?.toString().trim();
840
+ // /tools run <name> [params json]
841
+ if (arg?.startsWith("run ")) {
842
+ const parts = arg.slice(4).trim().split(/\s+/);
843
+ const toolName = parts[0];
844
+ let params = {};
845
+ if (parts.length > 1) {
846
+ try {
847
+ params = JSON.parse(parts.slice(1).join(" "));
848
+ }
849
+ catch {
850
+ await ctx.reply("❌ Invalid JSON for parameters.", { parse_mode: "Markdown" });
851
+ return;
852
+ }
853
+ }
854
+ try {
855
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
856
+ const result = await executeCustomTool(toolName, params);
857
+ const truncated = result.length > 3000 ? result.slice(0, 3000) + "\n..." : result;
858
+ await ctx.reply(`🔧 *${toolName}:*\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: "Markdown" });
859
+ }
860
+ catch (err) {
861
+ const msg = err instanceof Error ? err.message : String(err);
862
+ await ctx.reply(`❌ Tool error: ${msg}`);
863
+ }
864
+ return;
865
+ }
866
+ // /tools — list all
867
+ const tools = listCustomTools();
868
+ if (tools.length === 0) {
869
+ await ctx.reply("🔧 *Custom Tools*\n\n" +
870
+ "No tools configured.\n" +
871
+ "Create `TOOLS.md` (see `TOOLS.example.md`).", { parse_mode: "Markdown" });
872
+ return;
873
+ }
874
+ const lines = tools.map(t => {
875
+ const icon = t.type === "http" ? "🌐" : "⚡";
876
+ return `${icon} \`${t.name}\` — ${t.description}`;
877
+ });
878
+ await ctx.reply(`🔧 *Custom Tools (${tools.length}):*\n\n${lines.join("\n")}\n\n` +
879
+ `_Run: \`/tools run <name> {"param":"value"}\`_`, { parse_mode: "Markdown" });
880
+ });
881
+ // ── MCP ────────────────────────────────────────────────
882
+ bot.command("mcp", async (ctx) => {
883
+ const arg = ctx.match?.toString().trim();
884
+ // /mcp call <server> <tool> <json-args>
885
+ if (arg?.startsWith("call ")) {
886
+ const parts = arg.slice(5).trim().split(/\s+/);
887
+ if (parts.length < 2) {
888
+ await ctx.reply("Format: `/mcp call <server> <tool> {\"arg\":\"value\"}`", { parse_mode: "Markdown" });
889
+ return;
890
+ }
891
+ const [server, tool, ...rest] = parts;
892
+ let args = {};
893
+ if (rest.length > 0) {
894
+ try {
895
+ args = JSON.parse(rest.join(" "));
896
+ }
897
+ catch {
898
+ await ctx.reply("❌ Invalid JSON for tool arguments.");
899
+ return;
900
+ }
901
+ }
902
+ try {
903
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
904
+ const result = await callMCPTool(server, tool, args);
905
+ const truncated = result.length > 3000 ? result.slice(0, 3000) + "\n..." : result;
906
+ await ctx.reply(`🔧 *${server}/${tool}:*\n\`\`\`\n${truncated}\n\`\`\``, { parse_mode: "Markdown" });
907
+ }
908
+ catch (err) {
909
+ const msg = err instanceof Error ? err.message : String(err);
910
+ await ctx.reply(`❌ MCP error: ${msg}`);
911
+ }
912
+ return;
913
+ }
914
+ // Default: show status
915
+ const mcpServers = getMCPStatus();
916
+ const tools = getMCPTools();
917
+ if (mcpServers.length === 0) {
918
+ await ctx.reply(`🔌 *MCP (Model Context Protocol)*\n\n` +
919
+ `No servers configured.\n` +
920
+ `Create \`docs/mcp.json\` (see \`docs/mcp.example.json\`).`, { parse_mode: "Markdown" });
921
+ return;
922
+ }
923
+ const serverLines = mcpServers.map(s => {
924
+ const status = s.connected ? "🟢" : "🔴";
925
+ return `${status} *${s.name}* — ${s.tools} Tools`;
926
+ });
927
+ const toolLines = tools.length > 0
928
+ ? "\n\n*Available Tools:*\n" + tools.map(t => ` 🔧 \`${t.server}/${t.name}\` — ${t.description}`).join("\n")
929
+ : "";
930
+ await ctx.reply(`🔌 *MCP Server (${mcpServers.length}):*\n\n` +
931
+ serverLines.join("\n") +
932
+ toolLines +
933
+ `\n\n_Use \`/mcp call <server> <tool> {args}\` to execute._`, { parse_mode: "Markdown" });
934
+ });
935
+ // ── Plugins ───────────────────────────────────────────
936
+ bot.command("plugins", async (ctx) => {
937
+ const plugins = getLoadedPlugins();
938
+ if (plugins.length === 0) {
939
+ await ctx.reply(`🔌 No plugins loaded.\n\n` +
940
+ `Place plugins in \`${getPluginsDir()}/\`.\n` +
941
+ `Each plugin needs a folder with \`index.js\`.`, { parse_mode: "Markdown" });
942
+ return;
943
+ }
944
+ const lines = plugins.map(p => {
945
+ const cmds = p.commands.length > 0 ? `\n Commands: ${p.commands.join(", ")}` : "";
946
+ const tools = p.tools.length > 0 ? `\n Tools: ${p.tools.join(", ")}` : "";
947
+ return `🔌 *${p.name}* v${p.version}\n ${p.description}${cmds}${tools}`;
948
+ });
949
+ await ctx.reply(`🔌 *Loaded Plugins (${plugins.length}):*\n\n${lines.join("\n\n")}`, { parse_mode: "Markdown" });
950
+ });
951
+ // ── Skills ─────────────────────────────────────────────
952
+ bot.command("skills", async (ctx) => {
953
+ const { getSkills } = await import("../services/skills.js");
954
+ const skills = getSkills();
955
+ if (skills.length === 0) {
956
+ await ctx.reply("🎯 No skills installed.\n\nAdd SKILL.md files to the `skills/` directory.", { parse_mode: "HTML" });
957
+ return;
958
+ }
959
+ const lines = skills.map(s => `🎯 <b>${s.name}</b> (${s.category})\n ${s.description || "(no description)"}\n Triggers: ${s.triggers.slice(0, 5).join(", ")}`);
960
+ await ctx.reply(`🎯 <b>Skills (${skills.length}):</b>\n\n${lines.join("\n\n")}`, { parse_mode: "HTML" });
961
+ });
962
+ // ── User Profiles ─────────────────────────────────────
963
+ bot.command("users", async (ctx) => {
964
+ const profiles = listProfiles();
965
+ if (profiles.length === 0) {
966
+ await ctx.reply("No user profiles saved yet.");
967
+ return;
968
+ }
969
+ const lines = profiles.map(p => {
970
+ const lastActive = new Date(p.lastActive).toLocaleDateString("en-US");
971
+ const badge = p.isOwner ? "👑" : "👤";
972
+ return `${badge} *${p.name}*${p.username ? ` (@${p.username})` : ""}\n ${p.totalMessages} messages, last active: ${lastActive}`;
973
+ });
974
+ await ctx.reply(`👥 *User-Profile (${profiles.length}):*\n\n${lines.join("\n\n")}`, { parse_mode: "Markdown" });
975
+ });
976
+ bot.command("note", async (ctx) => {
977
+ const arg = ctx.match?.toString().trim();
978
+ if (!arg) {
979
+ await ctx.reply("📝 Use: `/note @username Note text`\nSaves a note about a user.", { parse_mode: "Markdown" });
980
+ return;
981
+ }
982
+ // Parse @username or userId + note text
983
+ const match = arg.match(/^@?(\S+)\s+(.+)$/s);
984
+ if (!match) {
985
+ await ctx.reply("Format: `/note @username Text`", { parse_mode: "Markdown" });
986
+ return;
987
+ }
988
+ const [, target, noteText] = match;
989
+ const profiles = listProfiles();
990
+ const profile = profiles.find(p => p.username === target || p.userId.toString() === target || p.name.toLowerCase() === target.toLowerCase());
991
+ if (!profile) {
992
+ await ctx.reply(`User "${target}" not found.`);
993
+ return;
994
+ }
995
+ addUserNote(profile.userId, noteText);
996
+ await ctx.reply(`📝 Note saved for ${profile.name}.`);
997
+ });
998
+ // ── Memory Search Commands ───────────────────────────
999
+ bot.command("recall", async (ctx) => {
1000
+ const query = ctx.match?.toString().trim();
1001
+ if (!query) {
1002
+ await ctx.reply("🔍 Use: `/recall <search term>`\nSemantic search through my memory.", { parse_mode: "Markdown" });
1003
+ return;
1004
+ }
1005
+ try {
1006
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
1007
+ const results = await searchMemory(query, 5, 0.25);
1008
+ if (results.length === 0) {
1009
+ await ctx.reply(`🔍 No memories found for "${query}".`);
1010
+ return;
1011
+ }
1012
+ const lines = results.map((r, i) => {
1013
+ const score = Math.round(r.score * 100);
1014
+ const preview = r.text.length > 200 ? r.text.slice(0, 200) + "..." : r.text;
1015
+ return `**${i + 1}.** (${score}%) _${r.source}_\n${preview}`;
1016
+ });
1017
+ await ctx.reply(`🧠 Memories for "${query}":\n\n${lines.join("\n\n")}`, { parse_mode: "Markdown" });
1018
+ }
1019
+ catch (err) {
1020
+ const msg = err instanceof Error ? err.message : String(err);
1021
+ await ctx.reply(`❌ Recall error: ${msg}`);
1022
+ }
1023
+ });
1024
+ bot.command("remember", async (ctx) => {
1025
+ const text = ctx.match?.toString().trim();
1026
+ if (!text) {
1027
+ await ctx.reply("💾 Use: `/remember <text>`\nSaves something to my memory.", { parse_mode: "Markdown" });
1028
+ return;
1029
+ }
1030
+ try {
1031
+ appendDailyLog(`**Manually remembered:** ${text}`);
1032
+ // Trigger reindex so the new entry is searchable
1033
+ const stats = await reindexMemory();
1034
+ await ctx.reply(`💾 Remembered! (${stats.total} entries in index)`);
1035
+ }
1036
+ catch (err) {
1037
+ const msg = err instanceof Error ? err.message : String(err);
1038
+ await ctx.reply(`❌ Error saving: ${msg}`);
1039
+ }
1040
+ });
1041
+ bot.command("reindex", async (ctx) => {
1042
+ try {
1043
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
1044
+ const stats = await reindexMemory(true);
1045
+ const indexStats = getIndexStats();
1046
+ const sizeKB = (indexStats.sizeBytes / 1024).toFixed(1);
1047
+ await ctx.reply(`🔄 Memory re-indexed!\n\n` +
1048
+ `📊 ${stats.indexed} chunks processed\n` +
1049
+ `📁 ${indexStats.files} files indexed\n` +
1050
+ `🧠 ${stats.total} total entries\n` +
1051
+ `💾 Index size: ${sizeKB} KB`);
1052
+ }
1053
+ catch (err) {
1054
+ const msg = err instanceof Error ? err.message : String(err);
1055
+ await ctx.reply(`❌ Reindex error: ${msg}`);
1056
+ }
1057
+ });
1058
+ // ── Cron Jobs ──────────────────────────────────────────
1059
+ bot.command("cron", async (ctx) => {
1060
+ const arg = ctx.match?.toString().trim() || "";
1061
+ const userId = ctx.from.id;
1062
+ const chatId = ctx.chat.id;
1063
+ // /cron — list all jobs
1064
+ if (!arg) {
1065
+ const jobs = listJobs();
1066
+ if (jobs.length === 0) {
1067
+ await ctx.reply("⏰ <b>Cron Jobs</b>\n\nNo jobs configured.\n\n" +
1068
+ "Create:\n" +
1069
+ "<code>/cron add 5m reminder Wasser trinken</code>\n" +
1070
+ "<code>/cron add \"0 9 * * 1\" shell pm2 status</code>\n" +
1071
+ "<code>/cron add 1h http https://api.example.com/health</code>\n\n" +
1072
+ "<i>Manage jobs also in the Web UI under ⏰ Cron.</i>", { parse_mode: "HTML" });
1073
+ return;
1074
+ }
1075
+ const lines = jobs.map(j => {
1076
+ const status = j.enabled ? "🟢" : "⏸️";
1077
+ const next = j.enabled ? formatNextRun(j.nextRunAt) : "paused";
1078
+ const lastErr = j.lastError ? " ⚠️" : "";
1079
+ const readable = humanReadableSchedule(j.schedule);
1080
+ const recur = j.oneShot ? "⚡ One-shot" : "🔄 " + readable;
1081
+ return `${status} <b>${j.name}</b>\n 📅 ${recur} | Next: ${next}\n Runs: ${j.runCount}${lastErr} | ID: <code>${j.id}</code>`;
1082
+ });
1083
+ const keyboard = new InlineKeyboard();
1084
+ for (const j of jobs) {
1085
+ const label = j.enabled ? `⏸ ${j.name}` : `▶️ ${j.name}`;
1086
+ keyboard.text(label, `cron:toggle:${j.id}`);
1087
+ keyboard.text(`🗑`, `cron:delete:${j.id}`);
1088
+ keyboard.row();
1089
+ }
1090
+ await ctx.reply(`⏰ <b>Cron Jobs (${jobs.length}):</b>\n\n${lines.join("\n\n")}\n\n` +
1091
+ `Commands: /cron add · delete · toggle · run · info`, { parse_mode: "HTML", reply_markup: keyboard });
1092
+ return;
1093
+ }
1094
+ // /cron add <schedule> <type> <payload>
1095
+ if (arg.startsWith("add ")) {
1096
+ const rest = arg.slice(4).trim();
1097
+ // Natural language schedule shortcuts (German + English)
1098
+ const naturalSchedules = {
1099
+ "täglich": "0 8 * * *", "daily": "0 8 * * *",
1100
+ "stündlich": "0 * * * *", "hourly": "0 * * * *",
1101
+ "wöchentlich": "0 8 * * 1", "weekly": "0 8 * * 1",
1102
+ "monatlich": "0 8 1 * *", "monthly": "0 8 1 * *",
1103
+ "werktags": "0 8 * * 1-5", "weekdays": "0 8 * * 1-5",
1104
+ "wochenende": "0 10 * * 0,6", "weekend": "0 10 * * 0,6",
1105
+ "montags": "0 8 * * 1", "dienstags": "0 8 * * 2", "mittwochs": "0 8 * * 3",
1106
+ "donnerstags": "0 8 * * 4", "freitags": "0 8 * * 5", "samstags": "0 10 * * 6", "sonntags": "0 10 * * 0",
1107
+ "morgens": "0 8 * * *", "mittags": "0 12 * * *", "abends": "0 18 * * *", "nachts": "0 0 * * *",
1108
+ };
1109
+ // Time-prefixed natural: "8:30 täglich" or "täglich 8:30"
1110
+ function resolveNatural(input) {
1111
+ // Try "HH:MM keyword rest" or "keyword HH:MM rest"
1112
+ const timeKeyword = input.match(/^(\d{1,2}):(\d{2})\s+(\S+)\s*(.*)/);
1113
+ if (timeKeyword) {
1114
+ const key = timeKeyword[3].toLowerCase();
1115
+ if (naturalSchedules[key]) {
1116
+ const base = naturalSchedules[key].split(" ");
1117
+ base[0] = String(parseInt(timeKeyword[2]));
1118
+ base[1] = String(parseInt(timeKeyword[1]));
1119
+ return { schedule: base.join(" "), rest: timeKeyword[4] };
1120
+ }
1121
+ }
1122
+ const keywordTime = input.match(/^(\S+)\s+(\d{1,2}):(\d{2})\s*(.*)/);
1123
+ if (keywordTime) {
1124
+ const key = keywordTime[1].toLowerCase();
1125
+ if (naturalSchedules[key]) {
1126
+ const base = naturalSchedules[key].split(" ");
1127
+ base[0] = String(parseInt(keywordTime[3]));
1128
+ base[1] = String(parseInt(keywordTime[2]));
1129
+ return { schedule: base.join(" "), rest: keywordTime[4] };
1130
+ }
1131
+ }
1132
+ // Simple keyword
1133
+ const firstWord = input.split(" ")[0].toLowerCase();
1134
+ if (naturalSchedules[firstWord]) {
1135
+ return { schedule: naturalSchedules[firstWord], rest: input.slice(firstWord.length).trim() };
1136
+ }
1137
+ return null;
1138
+ }
1139
+ // Parse: schedule can be "5m", natural keyword, or "0 9 * * 1" (quoted)
1140
+ let schedule;
1141
+ let remainder;
1142
+ const natural = resolveNatural(rest);
1143
+ if (natural) {
1144
+ schedule = natural.schedule;
1145
+ remainder = natural.rest;
1146
+ }
1147
+ else if (rest.startsWith('"')) {
1148
+ const endQuote = rest.indexOf('"', 1);
1149
+ if (endQuote < 0) {
1150
+ await ctx.reply("❌ Missing closing quote for cron expression.");
1151
+ return;
1152
+ }
1153
+ schedule = rest.slice(1, endQuote);
1154
+ remainder = rest.slice(endQuote + 1).trim();
1155
+ }
1156
+ else {
1157
+ const sp = rest.indexOf(" ");
1158
+ if (sp < 0) {
1159
+ await ctx.reply("Format: <code>/cron add &lt;schedule&gt; &lt;type&gt; &lt;payload&gt;</code>\n\nSchedule options:\n• <b>Intervals:</b> 5m, 1h, 30s, 2d\n• <b>Natural:</b> daily, weekly, monthly, weekdays, hourly\n• <b>With time:</b> 8:30 daily, weekdays 9:00\n• <b>German:</b> täglich, wöchentlich, morgens, abends\n• <b>Cron:</b> \"0 9 * * 1-5\"", { parse_mode: "HTML" });
1160
+ return;
1161
+ }
1162
+ schedule = rest.slice(0, sp);
1163
+ remainder = rest.slice(sp + 1).trim();
1164
+ }
1165
+ // Parse type + payload
1166
+ const typeSp = remainder.indexOf(" ");
1167
+ const typeStr = typeSp >= 0 ? remainder.slice(0, typeSp) : remainder;
1168
+ const payloadStr = typeSp >= 0 ? remainder.slice(typeSp + 1).trim() : "";
1169
+ const validTypes = ["reminder", "shell", "http", "message", "ai-query"];
1170
+ if (!validTypes.includes(typeStr)) {
1171
+ await ctx.reply(`❌ Invalid type "${typeStr}". Allowed: ${validTypes.join(", ")}`);
1172
+ return;
1173
+ }
1174
+ const payload = {};
1175
+ switch (typeStr) {
1176
+ case "reminder":
1177
+ case "message":
1178
+ payload.text = payloadStr;
1179
+ break;
1180
+ case "shell":
1181
+ payload.command = payloadStr;
1182
+ break;
1183
+ case "http":
1184
+ payload.url = payloadStr;
1185
+ break;
1186
+ case "ai-query":
1187
+ payload.prompt = payloadStr;
1188
+ break;
1189
+ }
1190
+ const name = `${typeStr}: ${payloadStr.slice(0, 30)}${payloadStr.length > 30 ? "..." : ""}`;
1191
+ const job = createJob({
1192
+ name,
1193
+ type: typeStr,
1194
+ schedule,
1195
+ payload,
1196
+ target: { platform: "telegram", chatId: String(chatId) },
1197
+ createdBy: `telegram:${userId}`,
1198
+ });
1199
+ const readableSched = humanReadableSchedule(job.schedule);
1200
+ await ctx.reply(`✅ <b>Cron Job created</b>\n\n` +
1201
+ `<b>Name:</b> ${job.name}\n` +
1202
+ `📅 <b>${readableSched}</b>\n` +
1203
+ `<b>Type:</b> ${job.type}\n` +
1204
+ `<b>Next run:</b> ${formatNextRun(job.nextRunAt)}\n` +
1205
+ `<b>ID:</b> <code>${job.id}</code>`, { parse_mode: "HTML" });
1206
+ return;
1207
+ }
1208
+ // /cron delete <id>
1209
+ if (arg.startsWith("delete ")) {
1210
+ const id = arg.slice(7).trim();
1211
+ if (deleteJob(id)) {
1212
+ await ctx.reply(`✅ Job \`${id}\` deleted.`, { parse_mode: "Markdown" });
1213
+ }
1214
+ else {
1215
+ await ctx.reply(`❌ Job \`${id}\` not found.`, { parse_mode: "Markdown" });
1216
+ }
1217
+ return;
1218
+ }
1219
+ // /cron toggle <id>
1220
+ if (arg.startsWith("toggle ")) {
1221
+ const id = arg.slice(7).trim();
1222
+ const job = toggleJob(id);
1223
+ if (job) {
1224
+ await ctx.reply(`${job.enabled ? "▶️" : "⏸️"} Job "${job.name}" ${job.enabled ? "enabled" : "paused"}.`);
1225
+ }
1226
+ else {
1227
+ await ctx.reply(`❌ Job not found.`);
1228
+ }
1229
+ return;
1230
+ }
1231
+ // /cron run <id>
1232
+ if (arg.startsWith("run ")) {
1233
+ const id = arg.slice(4).trim();
1234
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
1235
+ const result = await (runJobNow(id) || Promise.resolve(null));
1236
+ if (!result) {
1237
+ await ctx.reply(`❌ Job not found.`);
1238
+ return;
1239
+ }
1240
+ const output = result.output ? `\`\`\`\n${result.output.slice(0, 2000)}\n\`\`\`` : "(no output)";
1241
+ await ctx.reply(`🔧 Job executed:\n${output}${result.error ? `\n\n❌ ${result.error}` : ""}`, { parse_mode: "Markdown" });
1242
+ return;
1243
+ }
1244
+ await ctx.reply("Unknown cron command. Use /cron for help.");
1245
+ });
1246
+ // Inline keyboard callbacks for cron
1247
+ bot.callbackQuery(/^cron:toggle:(.+)$/, async (ctx) => {
1248
+ const id = ctx.match[1];
1249
+ const job = toggleJob(id);
1250
+ if (job) {
1251
+ await ctx.answerCallbackQuery(`${job.enabled ? "Enabled" : "Paused"}: ${job.name}`);
1252
+ // Refresh the cron list
1253
+ ctx.match = "";
1254
+ // Re-render the list message (HTML to avoid Markdown * conflicts with cron expressions)
1255
+ const jobs = listJobs();
1256
+ const lines = jobs.map(j => {
1257
+ const status = j.enabled ? "🟢" : "⏸️";
1258
+ const next = j.enabled ? formatNextRun(j.nextRunAt) : "paused";
1259
+ const readable = humanReadableSchedule(j.schedule);
1260
+ const recur = j.oneShot ? "⚡ One-shot" : "🔄 " + readable;
1261
+ return `${status} <b>${j.name}</b>\n 📅 ${recur} | Next: ${next}\n Runs: ${j.runCount} | ID: <code>${j.id}</code>`;
1262
+ });
1263
+ const keyboard = new InlineKeyboard();
1264
+ for (const j of jobs) {
1265
+ keyboard.text(j.enabled ? `⏸ ${j.name}` : `▶️ ${j.name}`, `cron:toggle:${j.id}`);
1266
+ keyboard.text(`🗑`, `cron:delete:${j.id}`);
1267
+ keyboard.row();
1268
+ }
1269
+ await ctx.editMessageText(`⏰ <b>Cron Jobs (${jobs.length}):</b>\n\n${lines.join("\n\n")}`, { parse_mode: "HTML", reply_markup: keyboard });
1270
+ }
1271
+ });
1272
+ bot.callbackQuery(/^cron:delete:(.+)$/, async (ctx) => {
1273
+ const id = ctx.match[1];
1274
+ deleteJob(id);
1275
+ await ctx.answerCallbackQuery("Deleted");
1276
+ // Refresh (HTML parse mode)
1277
+ const jobs = listJobs();
1278
+ if (jobs.length === 0) {
1279
+ await ctx.editMessageText("⏰ No cron jobs configured.");
1280
+ }
1281
+ else {
1282
+ const lines = jobs.map(j => {
1283
+ const status = j.enabled ? "🟢" : "⏸️";
1284
+ const readable = humanReadableSchedule(j.schedule);
1285
+ return `${status} <b>${j.name}</b>\n 📅 ${readable} | ID: <code>${j.id}</code>`;
1286
+ });
1287
+ const keyboard = new InlineKeyboard();
1288
+ for (const j of jobs) {
1289
+ keyboard.text(j.enabled ? `⏸ ${j.name}` : `▶️ ${j.name}`, `cron:toggle:${j.id}`);
1290
+ keyboard.text(`🗑`, `cron:delete:${j.id}`);
1291
+ keyboard.row();
1292
+ }
1293
+ await ctx.editMessageText(`⏰ <b>Cron Jobs (${jobs.length}):</b>\n\n${lines.join("\n\n")}`, { parse_mode: "HTML", reply_markup: keyboard });
1294
+ }
1295
+ });
1296
+ // ── Setup (API Keys & Platforms via Telegram) ─────────
1297
+ bot.command("setup", async (ctx) => {
1298
+ const arg = ctx.match?.toString().trim() || "";
1299
+ if (!arg) {
1300
+ const registry = getRegistry();
1301
+ const providers = await registry.listAll();
1302
+ const activeInfo = registry.getActive().getInfo();
1303
+ const keyboard = new InlineKeyboard()
1304
+ .text("🔑 Manage API Keys", "setup:keys").row()
1305
+ .text("📱 Platforms", "setup:platforms").row()
1306
+ .text("🔐 Sudo / Admin Access", "setup:sudo").row()
1307
+ .text("🔧 Open Web Dashboard", "setup:web").row();
1308
+ await ctx.reply(`⚙️ *Alvin Bot Setup*\n\n` +
1309
+ `*Active Model:* ${activeInfo.name}\n` +
1310
+ `*Providers:* ${providers.length} configured\n` +
1311
+ `*Web UI:* http://localhost:${process.env.WEB_PORT || 3100}\n\n` +
1312
+ `What would you like to configure?`, { parse_mode: "Markdown", reply_markup: keyboard });
1313
+ return;
1314
+ }
1315
+ // /setup sudo [password] — configure sudo access
1316
+ if (arg.startsWith("sudo")) {
1317
+ const pw = arg.slice(4).trim();
1318
+ if (!pw) {
1319
+ // Show status
1320
+ const status = await getSudoStatus();
1321
+ const statusIcon = status.configured ? (status.verified ? "✅" : "⚠️") : "❌";
1322
+ const keyboard = new InlineKeyboard();
1323
+ if (status.configured) {
1324
+ keyboard.text("🧪 Verify", "sudo:verify").row();
1325
+ keyboard.text("🔴 Revoke Access", "sudo:revoke").row();
1326
+ }
1327
+ await ctx.reply(`🔐 *Sudo / Admin Access*\n\n` +
1328
+ `*Status:* ${statusIcon} ${status.configured ? (status.verified ? "Configured & verified" : "Configured, not verified") : "Not set up"}\n` +
1329
+ `*Storage:* ${status.storageMethod}\n` +
1330
+ `*System:* ${status.platform} (${status.user})\n` +
1331
+ (status.permissions.accessibility !== null ? `*Accessibility:* ${status.permissions.accessibility ? "✅" : "❌"}\n` : "") +
1332
+ (status.permissions.fullDiskAccess !== null ? `*Full Disk Access:* ${status.permissions.fullDiskAccess ? "✅" : "❌"}\n` : "") +
1333
+ `\n*Setup:*\n\`/setup sudo <your-system-password>\`\n\n` +
1334
+ `_The password is securely stored in ${status.storageMethod}. ` +
1335
+ `This allows Alvin Bot to run admin commands (install software, change system settings, etc.)._\n\n` +
1336
+ `⚠️ _Delete this message after setup! The password is visible in chat history._`, { parse_mode: "Markdown", reply_markup: keyboard });
1337
+ return;
1338
+ }
1339
+ // Store the password
1340
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
1341
+ const result = storePassword(pw);
1342
+ if (!result.ok) {
1343
+ await ctx.reply(`❌ Error saving: ${result.error}`);
1344
+ return;
1345
+ }
1346
+ // Verify
1347
+ const verify = await verifyPassword();
1348
+ if (verify.ok) {
1349
+ await ctx.reply(`✅ *Sudo access configured!*\n\n` +
1350
+ `Password stored in: ${result.method}\n` +
1351
+ `Verification: ✅ successful\n\n` +
1352
+ `Alvin Bot can now run admin commands.\n\n` +
1353
+ `⚠️ _Please delete the message with the password from the chat!_`, { parse_mode: "Markdown" });
1354
+ }
1355
+ else {
1356
+ revokePassword(); // Wrong password — clean up
1357
+ await ctx.reply(`❌ *Wrong password!*\n\n` +
1358
+ `The entered password does not work for sudo.\n` +
1359
+ `Please try again: \`/setup sudo <correct-password>\``, { parse_mode: "Markdown" });
1360
+ }
1361
+ // Try to delete the user's message containing the password
1362
+ try {
1363
+ await ctx.api.deleteMessage(ctx.chat.id, ctx.message.message_id);
1364
+ }
1365
+ catch {
1366
+ // Can't delete in private chats sometimes — that's ok
1367
+ }
1368
+ return;
1369
+ }
1370
+ // /setup key <provider> <key>
1371
+ if (arg.startsWith("key ")) {
1372
+ const parts = arg.slice(4).trim().split(/\s+/);
1373
+ if (parts.length < 2) {
1374
+ await ctx.reply("🔑 *Set API Key:*\n\n" +
1375
+ "`/setup key openai sk-...`\n" +
1376
+ "`/setup key google AIza...`\n" +
1377
+ "`/setup key nvidia nvapi-...`\n" +
1378
+ "`/setup key openrouter sk-or-...`\n\n" +
1379
+ "_The key will be saved to .env. Restart required._", { parse_mode: "Markdown" });
1380
+ return;
1381
+ }
1382
+ const envMap = {
1383
+ openai: "OPENAI_API_KEY",
1384
+ google: "GOOGLE_API_KEY",
1385
+ nvidia: "NVIDIA_API_KEY",
1386
+ openrouter: "OPENROUTER_API_KEY",
1387
+ groq: "GROQ_API_KEY",
1388
+ };
1389
+ const provider = parts[0].toLowerCase();
1390
+ const key = parts.slice(1).join(" ");
1391
+ const envKey = envMap[provider];
1392
+ if (!envKey) {
1393
+ await ctx.reply(`❌ Unknown provider "${provider}". Use: ${Object.keys(envMap).join(", ")}`);
1394
+ return;
1395
+ }
1396
+ // Write to .env
1397
+ const envFile = resolve(process.cwd(), ".env");
1398
+ let content = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf-8") : "";
1399
+ const regex = new RegExp(`^${envKey}=.*$`, "m");
1400
+ if (regex.test(content))
1401
+ content = content.replace(regex, `${envKey}=${key}`);
1402
+ else
1403
+ content = content.trimEnd() + `\n${envKey}=${key}\n`;
1404
+ fs.writeFileSync(envFile, content);
1405
+ await ctx.reply(`✅ ${envKey} saved! Please restart the bot (/system restart or Web UI).`);
1406
+ return;
1407
+ }
1408
+ });
1409
+ bot.callbackQuery(/^sudo:(.+)$/, async (ctx) => {
1410
+ const action = ctx.match[1];
1411
+ if (action === "verify") {
1412
+ const result = await verifyPassword();
1413
+ await ctx.answerCallbackQuery(result.ok ? "✅ Sudo works!" : `❌ ${result.error}`);
1414
+ }
1415
+ else if (action === "revoke") {
1416
+ revokePassword();
1417
+ await ctx.editMessageText("🔴 Sudo access revoked. Password deleted.");
1418
+ await ctx.answerCallbackQuery("Access revoked");
1419
+ }
1420
+ });
1421
+ bot.callbackQuery(/^setup:(.+)$/, async (ctx) => {
1422
+ const action = ctx.match[1];
1423
+ switch (action) {
1424
+ case "keys": {
1425
+ const envMap = [
1426
+ { name: "OpenAI", env: "OPENAI_API_KEY", has: !!config.apiKeys.openai },
1427
+ { name: "Google", env: "GOOGLE_API_KEY", has: !!config.apiKeys.google },
1428
+ { name: "NVIDIA", env: "NVIDIA_API_KEY", has: !!config.apiKeys.nvidia },
1429
+ { name: "OpenRouter", env: "OPENROUTER_API_KEY", has: !!config.apiKeys.openrouter },
1430
+ { name: "Groq", env: "GROQ_API_KEY", has: !!config.apiKeys.groq },
1431
+ ];
1432
+ const lines = envMap.map(e => `${e.has ? "✅" : "❌"} *${e.name}* — \`${e.env}\``);
1433
+ await ctx.editMessageText(`🔑 *API Keys*\n\n${lines.join("\n")}\n\n` +
1434
+ `Set key: \`/setup key <provider> <key>\`\n` +
1435
+ `Example: \`/setup key nvidia nvapi-...\`\n\n` +
1436
+ `_Restart required after changes._`, { parse_mode: "Markdown" });
1437
+ break;
1438
+ }
1439
+ case "platforms": {
1440
+ const platforms = [
1441
+ { name: "Telegram", icon: "📱", env: "BOT_TOKEN", has: !!process.env.BOT_TOKEN },
1442
+ { name: "Discord", icon: "🎮", env: "DISCORD_TOKEN", has: !!process.env.DISCORD_TOKEN },
1443
+ { name: "WhatsApp", icon: "💬", env: "WHATSAPP_ENABLED", has: process.env.WHATSAPP_ENABLED === "true" },
1444
+ { name: "Signal", icon: "🔒", env: "SIGNAL_API_URL", has: !!process.env.SIGNAL_API_URL },
1445
+ ];
1446
+ const lines = platforms.map(p => `${p.has ? "✅" : "❌"} ${p.icon} *${p.name}* — \`${p.env}\``);
1447
+ await ctx.editMessageText(`📱 *Platforms*\n\n${lines.join("\n")}\n\n` +
1448
+ `_Set up platforms in Web UI: Models → Platforms_\n` +
1449
+ `_There you can enter tokens and install dependencies._`, { parse_mode: "Markdown" });
1450
+ break;
1451
+ }
1452
+ case "sudo": {
1453
+ const status = await getSudoStatus();
1454
+ const statusIcon = status.configured ? (status.verified ? "✅" : "⚠️") : "❌";
1455
+ await ctx.editMessageText(`🔐 *Sudo / Admin Access*\n\n` +
1456
+ `*Status:* ${statusIcon} ${status.configured ? (status.verified ? "Active & verified" : "Configured") : "Not set up"}\n` +
1457
+ `*Storage:* ${status.storageMethod}\n\n` +
1458
+ `Setup: \`/setup sudo <system-password>\`\n` +
1459
+ `Revoke: \`/setup sudo\` → "Revoke" button\n\n` +
1460
+ `_The password is securely stored in ${status.storageMethod}._`, { parse_mode: "Markdown" });
1461
+ break;
1462
+ }
1463
+ case "web": {
1464
+ await ctx.editMessageText(`🌐 *Web Dashboard*\n\n` +
1465
+ `URL: \`http://localhost:${process.env.WEB_PORT || 3100}\`\n\n` +
1466
+ `In the dashboard you can:\n` +
1467
+ `• 🤖 Manage models & API keys\n` +
1468
+ `• 📱 Set up platforms\n` +
1469
+ `• ⏰ Manage cron jobs\n` +
1470
+ `• 🧠 Edit memory\n` +
1471
+ `• 💻 Use terminal\n` +
1472
+ `• 🛠️ Run tools`, { parse_mode: "Markdown" });
1473
+ break;
1474
+ }
1475
+ }
1476
+ await ctx.answerCallbackQuery();
1477
+ });
1478
+ bot.command("cancel", async (ctx) => {
1479
+ const userId = ctx.from.id;
1480
+ const session = getSession(userId);
1481
+ if (session.isProcessing && session.abortController) {
1482
+ session.abortController.abort();
1483
+ await ctx.reply("Cancelling request...");
1484
+ }
1485
+ else {
1486
+ await ctx.reply("No running request.");
1487
+ }
1488
+ });
1489
+ }