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,187 @@
1
+ import { InputFile } from "grammy";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import https from "https";
6
+ import { config } from "../config.js";
7
+ import { getSession, addToHistory } from "../services/session.js";
8
+ import { TelegramStreamer } from "../services/telegram.js";
9
+ import { getRegistry } from "../engine.js";
10
+ import { textToSpeech } from "../services/voice.js";
11
+ import { buildSystemPrompt } from "../services/personality.js";
12
+ const TEMP_DIR = path.join(os.tmpdir(), "alvin-bot");
13
+ if (!fs.existsSync(TEMP_DIR))
14
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
15
+ /** React to a message with an emoji. Silently fails if not supported. */
16
+ async function react(ctx, emoji) {
17
+ try {
18
+ await ctx.react(emoji);
19
+ }
20
+ catch { /* ignore */ }
21
+ }
22
+ async function downloadFile(url, dest) {
23
+ return new Promise((resolve, reject) => {
24
+ const file = fs.createWriteStream(dest);
25
+ https.get(url, (response) => {
26
+ response.pipe(file);
27
+ file.on("finish", () => file.close(() => resolve()));
28
+ }).on("error", (err) => {
29
+ fs.unlink(dest, () => { });
30
+ reject(err);
31
+ });
32
+ });
33
+ }
34
+ // File types we can handle
35
+ const SUPPORTED_EXTENSIONS = new Set([
36
+ ".pdf", ".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm",
37
+ ".doc", ".docx", ".xls", ".xlsx", ".pptx",
38
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
39
+ ".rs", ".go", ".rb", ".php", ".sh", ".bash", ".zsh",
40
+ ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg",
41
+ ".log", ".sql", ".env", ".gitignore", ".dockerfile",
42
+ ]);
43
+ function isSupportedFile(filename) {
44
+ const ext = path.extname(filename).toLowerCase();
45
+ return SUPPORTED_EXTENSIONS.has(ext);
46
+ }
47
+ export async function handleDocument(ctx) {
48
+ const doc = ctx.message?.document;
49
+ if (!doc)
50
+ return;
51
+ const userId = ctx.from.id;
52
+ const session = getSession(userId);
53
+ if (session.isProcessing) {
54
+ await ctx.reply("Please wait, previous request still running... (/cancel to abort)");
55
+ return;
56
+ }
57
+ const filename = doc.file_name || "unknown";
58
+ const ext = path.extname(filename).toLowerCase();
59
+ // Check file size (Telegram max is 20MB for bots)
60
+ if (doc.file_size && doc.file_size > 20 * 1024 * 1024) {
61
+ await ctx.reply("⚠️ File too large (max 20 MB).");
62
+ return;
63
+ }
64
+ session.isProcessing = true;
65
+ session.abortController = new AbortController();
66
+ const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
67
+ let finalText = "";
68
+ const typingInterval = setInterval(() => {
69
+ ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => { });
70
+ }, 4000);
71
+ try {
72
+ await react(ctx, "📄");
73
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
74
+ // Download the file
75
+ const file = await ctx.api.getFile(doc.file_id);
76
+ const fileUrl = `https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`;
77
+ const localPath = path.join(TEMP_DIR, `doc_${Date.now()}_${filename}`);
78
+ await downloadFile(fileUrl, localPath);
79
+ const caption = ctx.message?.caption || "";
80
+ const userInstruction = caption || `Analysiere diese Datei: ${filename}`;
81
+ session.messageCount++;
82
+ const registry = getRegistry();
83
+ const activeProvider = registry.getActive();
84
+ const isSDK = activeProvider.config.type === "claude-sdk";
85
+ let queryOpts;
86
+ if (isSDK) {
87
+ // SDK provider: pass file path — Claude can read files natively
88
+ queryOpts = {
89
+ prompt: `Der User hat eine Datei gesendet: ${localPath}\nDateiname: ${filename}\n\nLies die Datei mit dem Read-Tool und bearbeite folgende Anfrage:\n${userInstruction}`,
90
+ systemPrompt: buildSystemPrompt(true, session.language),
91
+ workingDir: session.workingDir,
92
+ effort: session.effort,
93
+ abortSignal: session.abortController.signal,
94
+ sessionId: session.sessionId,
95
+ _sessionState: {
96
+ messageCount: session.messageCount,
97
+ toolUseCount: session.toolUseCount,
98
+ },
99
+ };
100
+ }
101
+ else {
102
+ // Non-SDK: try to extract text content and include in prompt
103
+ let fileContent = "";
104
+ if ([".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm",
105
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h",
106
+ ".rs", ".go", ".rb", ".php", ".sh", ".bash", ".zsh",
107
+ ".yaml", ".yml", ".toml", ".ini", ".conf", ".cfg",
108
+ ".log", ".sql", ".env", ".gitignore", ".dockerfile"].includes(ext)) {
109
+ // Plain text files — read directly
110
+ fileContent = fs.readFileSync(localPath, "utf-8");
111
+ // Truncate very large files
112
+ if (fileContent.length > 50000) {
113
+ fileContent = fileContent.slice(0, 50000) + "\n\n[... File truncated, total " + fileContent.length + " characters]";
114
+ }
115
+ }
116
+ else {
117
+ fileContent = `[Binary file: ${filename}, ${doc.file_size ? Math.round(doc.file_size / 1024) + " KB" : "unknown size"}. Can only be analyzed with the SDK provider (Claude).]`;
118
+ }
119
+ const fullPrompt = `Datei: ${filename}\n\n\`\`\`\n${fileContent}\n\`\`\`\n\n${userInstruction}`;
120
+ addToHistory(userId, { role: "user", content: fullPrompt });
121
+ queryOpts = {
122
+ prompt: fullPrompt,
123
+ systemPrompt: buildSystemPrompt(false, session.language),
124
+ workingDir: session.workingDir,
125
+ effort: session.effort,
126
+ abortSignal: session.abortController.signal,
127
+ history: session.history,
128
+ };
129
+ }
130
+ for await (const chunk of registry.queryWithFallback(queryOpts)) {
131
+ switch (chunk.type) {
132
+ case "text":
133
+ finalText = chunk.text || "";
134
+ await streamer.update(finalText);
135
+ break;
136
+ case "tool_use":
137
+ if (chunk.toolName)
138
+ session.toolUseCount++;
139
+ break;
140
+ case "done":
141
+ if (chunk.sessionId)
142
+ session.sessionId = chunk.sessionId;
143
+ if (chunk.costUsd)
144
+ session.totalCost += chunk.costUsd;
145
+ session.lastActivity = Date.now();
146
+ break;
147
+ case "error":
148
+ await ctx.reply(`Error: ${chunk.error}`);
149
+ break;
150
+ }
151
+ }
152
+ await streamer.finalize(finalText);
153
+ await react(ctx, "👍");
154
+ if (!isSDK && finalText) {
155
+ addToHistory(userId, { role: "assistant", content: finalText });
156
+ }
157
+ // Voice reply if enabled
158
+ if (session.voiceReply && finalText.trim()) {
159
+ try {
160
+ await ctx.api.sendChatAction(ctx.chat.id, "upload_voice");
161
+ const audioPath = await textToSpeech(finalText);
162
+ await ctx.replyWithVoice(new InputFile(fs.readFileSync(audioPath), "response.mp3"));
163
+ fs.unlink(audioPath, () => { });
164
+ }
165
+ catch (err) {
166
+ console.error("TTS error:", err);
167
+ }
168
+ }
169
+ // Clean up temp file after a delay (SDK might still need it)
170
+ setTimeout(() => fs.unlink(localPath, () => { }), 60000);
171
+ }
172
+ catch (err) {
173
+ const errorMsg = err instanceof Error ? err.message : String(err);
174
+ await react(ctx, "👎");
175
+ if (errorMsg.includes("abort")) {
176
+ await ctx.reply("Anfrage abgebrochen.");
177
+ }
178
+ else {
179
+ await ctx.reply(`Error: ${errorMsg}`);
180
+ }
181
+ }
182
+ finally {
183
+ clearInterval(typingInterval);
184
+ session.isProcessing = false;
185
+ session.abortController = null;
186
+ }
187
+ }
@@ -0,0 +1,200 @@
1
+ import { InputFile } from "grammy";
2
+ import fs from "fs";
3
+ import { getSession, addToHistory, trackProviderUsage, buildSessionKey } from "../services/session.js";
4
+ import { TelegramStreamer } from "../services/telegram.js";
5
+ import { getRegistry } from "../engine.js";
6
+ import { textToSpeech } from "../services/voice.js";
7
+ import { buildSystemPrompt, buildSmartSystemPrompt } from "../services/personality.js";
8
+ import { buildSkillContext } from "../services/skills.js";
9
+ import { isForwardingAllowed } from "../services/access.js";
10
+ import { touchProfile } from "../services/users.js";
11
+ import { trackAndAdapt } from "../services/language-detect.js";
12
+ import { shouldCompact, compactSession } from "../services/compaction.js";
13
+ import { emit } from "../services/hooks.js";
14
+ import { trackUsage } from "../services/usage-tracker.js";
15
+ /** React to a message with an emoji. Silently fails if reactions aren't supported. */
16
+ async function react(ctx, emoji) {
17
+ try {
18
+ await ctx.react(emoji);
19
+ }
20
+ catch {
21
+ // Reactions not supported in this chat — silently ignore
22
+ }
23
+ }
24
+ export async function handleMessage(ctx) {
25
+ const rawText = ctx.message?.text;
26
+ if (!rawText || rawText.startsWith("/"))
27
+ return;
28
+ let text = rawText;
29
+ // Forwarded message — add forward context (if allowed)
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ const msgAny = ctx.message;
32
+ if (msgAny?.forward_origin || msgAny?.forward_date) {
33
+ if (!isForwardingAllowed()) {
34
+ await ctx.reply("⚠️ Weitergeleitete Nachrichten sind deaktiviert. Aktiviere mit `/security forwards on`", { parse_mode: "Markdown" });
35
+ return;
36
+ }
37
+ const forwardFrom = msgAny.forward_sender_name || "unbekannt";
38
+ text = `[Weitergeleitete Nachricht von ${forwardFrom}]\n\n${rawText}`;
39
+ }
40
+ // Reply context — include quoted message
41
+ const replyTo = ctx.message?.reply_to_message;
42
+ if (replyTo?.text) {
43
+ const quotedText = replyTo.text.length > 500
44
+ ? replyTo.text.slice(0, 500) + "..."
45
+ : replyTo.text;
46
+ text = `[Replying to previous message: "${quotedText}"]\n\n${text}`;
47
+ }
48
+ const userId = ctx.from.id;
49
+ const session = getSession(buildSessionKey("telegram", ctx.chat.id, userId));
50
+ // Track user profile
51
+ touchProfile(userId, ctx.from?.first_name, ctx.from?.username, "telegram", text);
52
+ // Sync session language from persistent profile (on first message)
53
+ if (session.messageCount === 0) {
54
+ const { loadProfile } = await import("../services/users.js");
55
+ const profile = loadProfile(userId);
56
+ if (profile?.language)
57
+ session.language = profile.language;
58
+ }
59
+ if (session.isProcessing) {
60
+ // Queue the message instead of rejecting it (max 3)
61
+ if (session.messageQueue.length < 3) {
62
+ session.messageQueue.push(text);
63
+ await react(ctx, "📝");
64
+ }
65
+ else {
66
+ await ctx.reply("⏳ Warteschlange voll (3 Nachrichten). Bitte warten oder /cancel.");
67
+ }
68
+ return;
69
+ }
70
+ // Consume queued messages (sent while previous query was processing)
71
+ if (session.messageQueue.length > 0) {
72
+ const queued = session.messageQueue.splice(0);
73
+ text = [...queued, text].join("\n\n");
74
+ }
75
+ session.isProcessing = true;
76
+ session.abortController = new AbortController();
77
+ const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
78
+ let finalText = "";
79
+ const typingInterval = setInterval(() => {
80
+ ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => { });
81
+ }, 4000);
82
+ try {
83
+ // React with 🤔 to show we're thinking
84
+ await react(ctx, "🤔");
85
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
86
+ session.messageCount++;
87
+ emit("message:received", { userId, text, platform: "telegram" });
88
+ // Determine provider type early for compaction check
89
+ const registry = getRegistry();
90
+ const activeProvider = registry.getActive();
91
+ const isSDK = activeProvider.config.type === "claude-sdk";
92
+ // Auto-compact if needed (non-SDK only)
93
+ if (!isSDK) {
94
+ if (shouldCompact(session)) {
95
+ const result = await compactSession(session);
96
+ if (result.removedEntries > 0) {
97
+ console.log(`Compacted session: removed ${result.removedEntries} entries, flushed=${result.flushedToMemory}`);
98
+ }
99
+ }
100
+ }
101
+ // Auto-detect and adapt language from user's message
102
+ const adaptedLang = trackAndAdapt(userId, text, session.language);
103
+ if (adaptedLang !== session.language) {
104
+ session.language = adaptedLang;
105
+ }
106
+ // Build query options (with semantic memory search for non-SDK + skill injection)
107
+ const chatIdStr = String(ctx.chat.id);
108
+ const skillContext = buildSkillContext(text);
109
+ const systemPrompt = (isSDK
110
+ ? buildSystemPrompt(isSDK, session.language, chatIdStr)
111
+ : await buildSmartSystemPrompt(isSDK, session.language, text, chatIdStr)) + skillContext;
112
+ const queryOpts = {
113
+ prompt: text,
114
+ systemPrompt,
115
+ workingDir: session.workingDir,
116
+ effort: session.effort,
117
+ abortSignal: session.abortController.signal,
118
+ // SDK-specific
119
+ sessionId: isSDK ? session.sessionId : null,
120
+ // Non-SDK: include conversation history
121
+ history: !isSDK ? session.history : undefined,
122
+ // SDK checkpoint tracking
123
+ _sessionState: isSDK ? {
124
+ messageCount: session.messageCount,
125
+ toolUseCount: session.toolUseCount,
126
+ } : undefined,
127
+ };
128
+ // Add user message to history (for non-SDK providers)
129
+ if (!isSDK) {
130
+ addToHistory(userId, { role: "user", content: text });
131
+ }
132
+ // Stream response from provider (with fallback)
133
+ for await (const chunk of registry.queryWithFallback(queryOpts)) {
134
+ switch (chunk.type) {
135
+ case "text":
136
+ finalText = chunk.text || "";
137
+ await streamer.update(finalText);
138
+ break;
139
+ case "tool_use":
140
+ // Could show tool activity indicator
141
+ if (chunk.toolName) {
142
+ session.toolUseCount++;
143
+ }
144
+ break;
145
+ case "done":
146
+ if (chunk.sessionId)
147
+ session.sessionId = chunk.sessionId;
148
+ if (chunk.costUsd)
149
+ session.totalCost += chunk.costUsd;
150
+ trackProviderUsage(userId, registry.getActiveKey(), chunk.costUsd || 0, chunk.inputTokens, chunk.outputTokens);
151
+ trackUsage(registry.getActiveKey(), chunk.inputTokens || 0, chunk.outputTokens || 0, chunk.costUsd || 0);
152
+ session.lastActivity = Date.now();
153
+ break;
154
+ case "fallback":
155
+ await ctx.reply(`⚡ _${chunk.failedProvider} unavailable — switching to ${chunk.providerName}_`, { parse_mode: "Markdown" });
156
+ break;
157
+ case "error":
158
+ await ctx.reply(`Error: ${chunk.error}`);
159
+ break;
160
+ }
161
+ }
162
+ await streamer.finalize(finalText);
163
+ emit("message:sent", { userId, text: finalText, platform: "telegram" });
164
+ // Clear thinking reaction (replace with nothing — message was answered)
165
+ await react(ctx, "👍");
166
+ // Add assistant response to history (for non-SDK providers)
167
+ if (!isSDK && finalText) {
168
+ addToHistory(userId, { role: "assistant", content: finalText });
169
+ }
170
+ // Voice reply if enabled
171
+ if (session.voiceReply && finalText.trim()) {
172
+ try {
173
+ await ctx.api.sendChatAction(ctx.chat.id, "upload_voice");
174
+ const audioPath = await textToSpeech(finalText);
175
+ await ctx.replyWithVoice(new InputFile(fs.readFileSync(audioPath), "response.mp3"));
176
+ fs.unlink(audioPath, () => { });
177
+ }
178
+ catch (err) {
179
+ console.error("TTS error:", err);
180
+ }
181
+ }
182
+ }
183
+ catch (err) {
184
+ const errorMsg = err instanceof Error ? err.message : String(err);
185
+ await react(ctx, "👎");
186
+ if (errorMsg.includes("abort")) {
187
+ await ctx.reply("Anfrage abgebrochen.");
188
+ }
189
+ else {
190
+ await ctx.reply(`Error: ${errorMsg}`);
191
+ }
192
+ }
193
+ finally {
194
+ clearInterval(typingInterval);
195
+ session.isProcessing = false;
196
+ session.abortController = null;
197
+ // Check for queued messages — they'll be prepended to the next real message
198
+ // Queue stays in session and gets consumed on next handleMessage call
199
+ }
200
+ }
@@ -0,0 +1,154 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import https from "https";
5
+ /** React to a message with an emoji. Silently fails if not supported. */
6
+ async function react(ctx, emoji) {
7
+ try {
8
+ await ctx.react(emoji);
9
+ }
10
+ catch { /* ignore */ }
11
+ }
12
+ import { config } from "../config.js";
13
+ import { getSession, addToHistory } from "../services/session.js";
14
+ import { TelegramStreamer } from "../services/telegram.js";
15
+ import { getRegistry } from "../engine.js";
16
+ import { buildSystemPrompt } from "../services/personality.js";
17
+ const TEMP_DIR = path.join(os.tmpdir(), "alvin-bot");
18
+ if (!fs.existsSync(TEMP_DIR)) {
19
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
20
+ }
21
+ async function downloadFile(url, dest) {
22
+ return new Promise((resolve, reject) => {
23
+ const file = fs.createWriteStream(dest);
24
+ https.get(url, (response) => {
25
+ response.pipe(file);
26
+ file.on("finish", () => file.close(() => resolve()));
27
+ }).on("error", (err) => {
28
+ fs.unlink(dest, () => { });
29
+ reject(err);
30
+ });
31
+ });
32
+ }
33
+ export async function handlePhoto(ctx) {
34
+ const photos = ctx.message?.photo;
35
+ if (!photos || photos.length === 0)
36
+ return;
37
+ const userId = ctx.from.id;
38
+ const session = getSession(userId);
39
+ if (session.isProcessing) {
40
+ await ctx.reply("Please wait, previous request still running... (/cancel to abort)");
41
+ return;
42
+ }
43
+ session.isProcessing = true;
44
+ session.abortController = new AbortController();
45
+ const streamer = new TelegramStreamer(ctx.chat.id, ctx.api, ctx.message?.message_id);
46
+ let finalText = "";
47
+ const typingInterval = setInterval(() => {
48
+ ctx.api.sendChatAction(ctx.chat.id, "typing").catch(() => { });
49
+ }, 4000);
50
+ try {
51
+ await react(ctx, "👀");
52
+ await ctx.api.sendChatAction(ctx.chat.id, "typing");
53
+ // Get highest resolution photo
54
+ const photo = photos[photos.length - 1];
55
+ const file = await ctx.api.getFile(photo.file_id);
56
+ const fileUrl = `https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`;
57
+ const ext = path.extname(file.file_path || "") || ".jpg";
58
+ const imagePath = path.join(TEMP_DIR, `photo_${Date.now()}${ext}`);
59
+ await downloadFile(fileUrl, imagePath);
60
+ const caption = ctx.message?.caption || "Analysiere dieses Bild.";
61
+ session.messageCount++;
62
+ const registry = getRegistry();
63
+ const activeProvider = registry.getActive();
64
+ const isSDK = activeProvider.config.type === "claude-sdk";
65
+ let queryOpts;
66
+ if (isSDK) {
67
+ // SDK: pass image path in prompt — SDK's Read tool handles it natively
68
+ queryOpts = {
69
+ prompt: `Analysiere dieses Bild: ${imagePath}\n\n${caption}`,
70
+ systemPrompt: buildSystemPrompt(true, session.language),
71
+ workingDir: session.workingDir,
72
+ effort: session.effort,
73
+ abortSignal: session.abortController.signal,
74
+ sessionId: session.sessionId,
75
+ _sessionState: {
76
+ messageCount: session.messageCount,
77
+ toolUseCount: session.toolUseCount,
78
+ },
79
+ };
80
+ }
81
+ else {
82
+ // Non-SDK: encode image as base64 for vision API
83
+ let imageContent;
84
+ if (activeProvider.config.supportsVision) {
85
+ const imageBuffer = fs.readFileSync(imagePath);
86
+ imageContent = imageBuffer.toString("base64");
87
+ }
88
+ else {
89
+ // No vision support — tell the user
90
+ imageContent = "";
91
+ }
92
+ if (!activeProvider.config.supportsVision) {
93
+ await ctx.reply(`⚠️ The current model (${activeProvider.config.name}) does not support image analysis. Switch to a vision model with /model.`);
94
+ return;
95
+ }
96
+ addToHistory(userId, {
97
+ role: "user",
98
+ content: caption,
99
+ images: [imageContent],
100
+ });
101
+ queryOpts = {
102
+ prompt: caption,
103
+ systemPrompt: buildSystemPrompt(false, session.language),
104
+ workingDir: session.workingDir,
105
+ effort: session.effort,
106
+ abortSignal: session.abortController.signal,
107
+ history: session.history,
108
+ };
109
+ }
110
+ for await (const chunk of registry.queryWithFallback(queryOpts)) {
111
+ switch (chunk.type) {
112
+ case "text":
113
+ finalText = chunk.text || "";
114
+ await streamer.update(finalText);
115
+ break;
116
+ case "tool_use":
117
+ if (chunk.toolName)
118
+ session.toolUseCount++;
119
+ break;
120
+ case "done":
121
+ if (chunk.sessionId)
122
+ session.sessionId = chunk.sessionId;
123
+ if (chunk.costUsd)
124
+ session.totalCost += chunk.costUsd;
125
+ session.lastActivity = Date.now();
126
+ break;
127
+ case "error":
128
+ await ctx.reply(`Error: ${chunk.error}`);
129
+ break;
130
+ }
131
+ }
132
+ await streamer.finalize(finalText);
133
+ await react(ctx, "👍");
134
+ if (!isSDK && finalText) {
135
+ addToHistory(userId, { role: "assistant", content: finalText });
136
+ }
137
+ // Clean up temp file
138
+ fs.unlink(imagePath, () => { });
139
+ }
140
+ catch (err) {
141
+ const errorMsg = err instanceof Error ? err.message : String(err);
142
+ if (errorMsg.includes("abort")) {
143
+ await ctx.reply("Anfrage abgebrochen.");
144
+ }
145
+ else {
146
+ await ctx.reply(`Error: ${errorMsg}`);
147
+ }
148
+ }
149
+ finally {
150
+ clearInterval(typingInterval);
151
+ session.isProcessing = false;
152
+ session.abortController = null;
153
+ }
154
+ }