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.
- package/.env.example +43 -0
- package/BACKLOG.md +223 -0
- package/CHANGELOG.md +63 -0
- package/CLAUDE.example.md +152 -0
- package/CODE_OF_CONDUCT.md +52 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/SECURITY.md +38 -0
- package/SOUL.example.md +60 -0
- package/TOOLS.example.md +42 -0
- package/alvin-bot.config.example.json +24 -0
- package/bin/cli.js +1088 -0
- package/dist/.metadata_never_index +0 -0
- package/dist/claude.js +102 -0
- package/dist/config.js +65 -0
- package/dist/engine.js +90 -0
- package/dist/find-claude-binary.js +98 -0
- package/dist/handlers/commands.js +1489 -0
- package/dist/handlers/document.js +187 -0
- package/dist/handlers/message.js +200 -0
- package/dist/handlers/photo.js +154 -0
- package/dist/handlers/platform-message.js +275 -0
- package/dist/handlers/video.js +237 -0
- package/dist/handlers/voice.js +148 -0
- package/dist/i18n.js +299 -0
- package/dist/index.js +442 -0
- package/dist/init-data-dir.js +81 -0
- package/dist/middleware/auth.js +215 -0
- package/dist/migrate.js +139 -0
- package/dist/paths.js +87 -0
- package/dist/platforms/discord.js +161 -0
- package/dist/platforms/index.js +130 -0
- package/dist/platforms/signal.js +205 -0
- package/dist/platforms/slack.js +318 -0
- package/dist/platforms/telegram.js +111 -0
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/whatsapp.js +648 -0
- package/dist/providers/claude-sdk-provider.js +173 -0
- package/dist/providers/codex-cli-provider.js +121 -0
- package/dist/providers/index.js +7 -0
- package/dist/providers/openai-compatible.js +388 -0
- package/dist/providers/registry.js +209 -0
- package/dist/providers/tool-executor.js +450 -0
- package/dist/providers/types.js +205 -0
- package/dist/services/access.js +144 -0
- package/dist/services/asset-index.js +230 -0
- package/dist/services/browser-manager.js +161 -0
- package/dist/services/browser.js +121 -0
- package/dist/services/compaction.js +129 -0
- package/dist/services/cron.js +462 -0
- package/dist/services/custom-tools.js +317 -0
- package/dist/services/delivery-queue.js +154 -0
- package/dist/services/elevenlabs.js +58 -0
- package/dist/services/embeddings.js +386 -0
- package/dist/services/exec-guard.js +46 -0
- package/dist/services/fallback-order.js +151 -0
- package/dist/services/heartbeat.js +192 -0
- package/dist/services/hooks.js +44 -0
- package/dist/services/imagegen.js +72 -0
- package/dist/services/language-detect.js +144 -0
- package/dist/services/markdown.js +63 -0
- package/dist/services/mcp.js +252 -0
- package/dist/services/memory.js +133 -0
- package/dist/services/personality.js +227 -0
- package/dist/services/plugins.js +171 -0
- package/dist/services/reminders.js +97 -0
- package/dist/services/restart.js +48 -0
- package/dist/services/security-audit.js +66 -0
- package/dist/services/self-search.js +129 -0
- package/dist/services/session.js +93 -0
- package/dist/services/skills.js +287 -0
- package/dist/services/standing-orders.js +29 -0
- package/dist/services/subagents.js +142 -0
- package/dist/services/sudo.js +243 -0
- package/dist/services/telegram.js +113 -0
- package/dist/services/tool-discovery.js +214 -0
- package/dist/services/usage-tracker.js +137 -0
- package/dist/services/users.js +199 -0
- package/dist/services/voice.js +95 -0
- package/dist/tui/index.js +507 -0
- package/dist/web/canvas.js +30 -0
- package/dist/web/doctor-api.js +606 -0
- package/dist/web/openai-compat.js +252 -0
- package/dist/web/server.js +1351 -0
- package/dist/web/setup-api.js +1078 -0
- package/docs/mcp.example.json +16 -0
- package/docs/screenshots/00-Login.png +0 -0
- package/docs/screenshots/01-Chat-Dark-Conversation.png +0 -0
- package/docs/screenshots/02-Chat.png +0 -0
- package/docs/screenshots/03-Dashboard-Overview.png +0 -0
- package/docs/screenshots/04-AI-Models-and-Providers.png +0 -0
- package/docs/screenshots/05-Personality-Editor.png +0 -0
- package/docs/screenshots/06-Memory-Manager.png +0 -0
- package/docs/screenshots/07-Active-Sessions.png +0 -0
- package/docs/screenshots/08-File-Browser.png +0 -0
- package/docs/screenshots/09-Scheduled-Jobs.png +0 -0
- package/docs/screenshots/10-Custom-Tools.png +0 -0
- package/docs/screenshots/11-Plugins-and-MCP.png +0 -0
- package/docs/screenshots/12-Messaging-Platforms.png +0 -0
- package/docs/screenshots/12.1-Messaging-Platforms-WhatsApp-Groups-List.png +0 -0
- package/docs/screenshots/12.2-Messaging-Platforms-WA-Group-Details.png +0 -0
- package/docs/screenshots/13-User-Management.png +0 -0
- package/docs/screenshots/14-Web-Terminal.png +0 -0
- package/docs/screenshots/15-Maintenance-and-Health.png +0 -0
- package/docs/screenshots/16-Settings-and-Env.png +0 -0
- package/docs/screenshots/TG-commands.png +0 -0
- package/docs/screenshots/TG.png +0 -0
- package/docs/screenshots/_Mac-Installer.png +0 -0
- package/docs/tools.example.json +33 -0
- package/install.sh +165 -0
- package/package.json +190 -0
- package/plugins/calendar/index.js +270 -0
- package/plugins/email/index.js +231 -0
- package/plugins/finance/index.js +254 -0
- package/plugins/notes/index.js +227 -0
- package/plugins/smarthome/index.js +230 -0
- package/plugins/weather/index.js +122 -0
- package/skills/apple-notes/SKILL.md +31 -0
- package/skills/browse/SKILL.md +136 -0
- package/skills/code-project/SKILL.md +43 -0
- package/skills/data-analysis/SKILL.md +39 -0
- package/skills/document-creation/SKILL.md +48 -0
- package/skills/email-summary/SKILL.md +46 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/summarize/SKILL.md +28 -0
- package/skills/system-admin/SKILL.md +39 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/web-research/SKILL.md +35 -0
- package/web/public/canvas.html +52 -0
- package/web/public/css/style.css +555 -0
- package/web/public/index.html +189 -0
- package/web/public/js/app.js +3102 -0
- package/web/public/js/i18n.js +1048 -0
- package/web/public/js/icons.js +104 -0
- 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
|
+
}
|