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,192 @@
1
+ /**
2
+ * Heartbeat Service — Provider health monitoring with auto-failover.
3
+ *
4
+ * Periodically pings providers (tiny completion request) to detect outages.
5
+ * If the primary provider fails, auto-switches to the first healthy fallback.
6
+ * When the primary recovers, switches back automatically.
7
+ *
8
+ * The heartbeat provider (Groq by default) is always registered as the
9
+ * last-resort fallback — free, fast, reliable.
10
+ */
11
+ import { getRegistry } from "../engine.js";
12
+ import { config } from "../config.js";
13
+ // ── Configuration ───────────────────────────────────────────────────────────
14
+ const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
15
+ const HEARTBEAT_TIMEOUT_MS = 15_000; // 15s timeout per check
16
+ const FAIL_THRESHOLD = 2; // Switch after 2 consecutive failures
17
+ const RECOVERY_THRESHOLD = 1; // Switch back after 1 success
18
+ // Default heartbeat/fallback provider (free, no key needed for check)
19
+ const HEARTBEAT_PROVIDER = "groq";
20
+ // ── State ───────────────────────────────────────────────────────────────────
21
+ const state = {
22
+ providers: new Map(),
23
+ intervalId: null,
24
+ isRunning: false,
25
+ originalPrimary: "",
26
+ wasFailedOver: false,
27
+ };
28
+ // ── Public API ──────────────────────────────────────────────────────────────
29
+ /**
30
+ * Start the heartbeat monitor.
31
+ */
32
+ export function startHeartbeat() {
33
+ if (state.isRunning)
34
+ return;
35
+ const registry = getRegistry();
36
+ state.originalPrimary = registry.getActiveKey();
37
+ state.isRunning = true;
38
+ // Initial health state for all providers
39
+ const allProviders = registry;
40
+ // We'll check providers in the fallback chain
41
+ const chain = [
42
+ config.primaryProvider,
43
+ ...config.fallbackProviders,
44
+ ].filter((v, i, a) => a.indexOf(v) === i); // dedupe
45
+ for (const key of chain) {
46
+ state.providers.set(key, {
47
+ key,
48
+ healthy: true, // assume healthy until proven otherwise
49
+ lastCheck: 0,
50
+ lastLatencyMs: 0,
51
+ failCount: 0,
52
+ });
53
+ }
54
+ console.log(`💓 Heartbeat monitor started (${HEARTBEAT_INTERVAL_MS / 1000}s interval, ${chain.length} providers)`);
55
+ // Run first check after 30s (let bot fully start)
56
+ setTimeout(() => {
57
+ runHeartbeat();
58
+ state.intervalId = setInterval(runHeartbeat, HEARTBEAT_INTERVAL_MS);
59
+ }, 30_000);
60
+ }
61
+ /**
62
+ * Stop the heartbeat monitor.
63
+ */
64
+ export function stopHeartbeat() {
65
+ if (state.intervalId) {
66
+ clearInterval(state.intervalId);
67
+ state.intervalId = null;
68
+ }
69
+ state.isRunning = false;
70
+ console.log("💓 Heartbeat monitor stopped");
71
+ }
72
+ /**
73
+ * Get current health status of all monitored providers.
74
+ */
75
+ export function getHealthStatus() {
76
+ return Array.from(state.providers.values()).map(p => ({
77
+ key: p.key,
78
+ healthy: p.healthy,
79
+ latencyMs: p.lastLatencyMs,
80
+ failCount: p.failCount,
81
+ lastCheck: p.lastCheck ? new Date(p.lastCheck).toISOString() : "never",
82
+ lastError: p.lastError,
83
+ }));
84
+ }
85
+ /**
86
+ * Get the fallback order (user-configurable).
87
+ */
88
+ export function getFallbackOrder() {
89
+ return config.fallbackProviders;
90
+ }
91
+ /**
92
+ * Whether we're currently failed over from the primary.
93
+ */
94
+ export function isFailedOver() {
95
+ return state.wasFailedOver;
96
+ }
97
+ // ── Internal ────────────────────────────────────────────────────────────────
98
+ async function runHeartbeat() {
99
+ const registry = getRegistry();
100
+ for (const [key, health] of state.providers) {
101
+ const provider = registry.get(key);
102
+ if (!provider)
103
+ continue;
104
+ const start = Date.now();
105
+ try {
106
+ // Quick availability check first
107
+ const available = await Promise.race([
108
+ provider.isAvailable(),
109
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), HEARTBEAT_TIMEOUT_MS)),
110
+ ]);
111
+ if (!available) {
112
+ throw new Error("Provider reported unavailable");
113
+ }
114
+ // Tiny completion request to verify actual functionality
115
+ const testResult = await Promise.race([
116
+ pingProvider(provider, key),
117
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), HEARTBEAT_TIMEOUT_MS)),
118
+ ]);
119
+ // Success
120
+ health.healthy = true;
121
+ health.lastLatencyMs = Date.now() - start;
122
+ health.lastCheck = Date.now();
123
+ health.lastError = undefined;
124
+ // Recovery check: if primary was down and is back
125
+ if (health.failCount > 0) {
126
+ console.log(`💓 ${key}: recovered (${health.lastLatencyMs}ms)`);
127
+ }
128
+ health.failCount = 0;
129
+ }
130
+ catch (err) {
131
+ health.failCount++;
132
+ health.lastLatencyMs = Date.now() - start;
133
+ health.lastCheck = Date.now();
134
+ health.lastError = err instanceof Error ? err.message : String(err);
135
+ if (health.failCount >= FAIL_THRESHOLD) {
136
+ health.healthy = false;
137
+ console.log(`💓 ❌ ${key}: unhealthy (${health.failCount} failures: ${health.lastError})`);
138
+ }
139
+ else {
140
+ console.log(`💓 ⚠️ ${key}: failure ${health.failCount}/${FAIL_THRESHOLD} (${health.lastError})`);
141
+ }
142
+ }
143
+ }
144
+ // Auto-failover logic
145
+ handleFailover(registry);
146
+ }
147
+ async function pingProvider(provider, key) {
148
+ // For CLI-based providers, just check availability (no full query needed)
149
+ if (key === "claude-sdk" || key === "codex-cli") {
150
+ const available = await provider.isAvailable();
151
+ return available ? "ok" : "unavailable";
152
+ }
153
+ // For OpenAI-compatible: tiny completion
154
+ let text = "";
155
+ for await (const chunk of provider.query({
156
+ prompt: "Hi",
157
+ systemPrompt: "Reply with exactly: ok",
158
+ history: [],
159
+ })) {
160
+ if (chunk.type === "text")
161
+ text = chunk.text;
162
+ if (chunk.type === "done")
163
+ return text || "ok";
164
+ if (chunk.type === "error")
165
+ throw new Error(chunk.error);
166
+ }
167
+ return text || "ok";
168
+ }
169
+ function handleFailover(registry) {
170
+ const primaryHealth = state.providers.get(state.originalPrimary);
171
+ const currentKey = registry.getActiveKey();
172
+ // Case 1: Primary is down → switch to first healthy fallback
173
+ if (primaryHealth && !primaryHealth.healthy && currentKey === state.originalPrimary) {
174
+ const fallbackOrder = config.fallbackProviders;
175
+ for (const fbKey of fallbackOrder) {
176
+ const fbHealth = state.providers.get(fbKey);
177
+ if (fbHealth?.healthy) {
178
+ console.log(`💓 🔄 Auto-failover: ${state.originalPrimary} → ${fbKey}`);
179
+ registry.switchTo(fbKey);
180
+ state.wasFailedOver = true;
181
+ return;
182
+ }
183
+ }
184
+ console.log("💓 ⚠️ All providers unhealthy — staying on primary");
185
+ }
186
+ // Case 2: Primary recovered → switch back
187
+ if (primaryHealth?.healthy && state.wasFailedOver && currentKey !== state.originalPrimary) {
188
+ console.log(`💓 ✅ Primary recovered — switching back to ${state.originalPrimary}`);
189
+ registry.switchTo(state.originalPrimary);
190
+ state.wasFailedOver = false;
191
+ }
192
+ }
@@ -0,0 +1,44 @@
1
+ import { readdirSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { HOOKS_DIR } from "../paths.js";
4
+ const registry = [];
5
+ export function registerHook(hook) {
6
+ registry.push(hook);
7
+ }
8
+ export async function emit(event, payload = {}) {
9
+ const handlers = registry.filter(h => h.event === event);
10
+ for (const h of handlers) {
11
+ try {
12
+ await h.handler({ ...payload, _event: event, _timestamp: Date.now() });
13
+ }
14
+ catch (err) {
15
+ console.error(`Hook error (${h.name}/${event}):`, err);
16
+ }
17
+ }
18
+ }
19
+ export function loadHooks() {
20
+ if (!existsSync(HOOKS_DIR))
21
+ return 0;
22
+ const files = readdirSync(HOOKS_DIR).filter(f => f.endsWith(".js") || f.endsWith(".mjs"));
23
+ let loaded = 0;
24
+ for (const file of files) {
25
+ try {
26
+ const hookPath = resolve(HOOKS_DIR, file);
27
+ // Use dynamic import for ESM modules
28
+ import(hookPath).then(mod => {
29
+ if (mod.event && typeof mod.handler === "function") {
30
+ registerHook({ event: mod.event, name: file, handler: mod.handler });
31
+ console.log(`Hook loaded: ${file} → ${mod.event}`);
32
+ }
33
+ }).catch(err => console.error(`Failed to load hook ${file}:`, err));
34
+ loaded++;
35
+ }
36
+ catch (err) {
37
+ console.error(`Failed to load hook ${file}:`, err);
38
+ }
39
+ }
40
+ return loaded;
41
+ }
42
+ export function getRegisteredHooks() {
43
+ return registry.map(h => ({ event: h.event, name: h.name }));
44
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Image Generation Service — Generate images via Gemini (Nano Banana Pro).
3
+ *
4
+ * Uses Google's generativelanguage API with responseModalities: IMAGE.
5
+ * Requires GOOGLE_API_KEY in .env.
6
+ */
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ const TEMP_DIR = path.join(os.tmpdir(), "alvin-bot");
11
+ if (!fs.existsSync(TEMP_DIR))
12
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
13
+ const MODEL = "gemini-2.0-flash-exp"; // Free tier image gen model
14
+ const API_URL = "https://generativelanguage.googleapis.com/v1beta/models";
15
+ /**
16
+ * Generate an image from a text prompt using Gemini.
17
+ */
18
+ export async function generateImage(prompt, apiKey) {
19
+ if (!apiKey) {
20
+ return { success: false, error: "GOOGLE_API_KEY not configured" };
21
+ }
22
+ try {
23
+ const url = `${API_URL}/${MODEL}:generateContent?key=${apiKey}`;
24
+ const response = await fetch(url, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({
28
+ contents: [{
29
+ parts: [{ text: `Generate an image: ${prompt}` }],
30
+ }],
31
+ generationConfig: {
32
+ responseModalities: ["IMAGE", "TEXT"],
33
+ },
34
+ }),
35
+ });
36
+ if (!response.ok) {
37
+ const errText = await response.text().catch(() => "Unknown error");
38
+ return { success: false, error: `Gemini API error (${response.status}): ${errText}` };
39
+ }
40
+ const data = await response.json();
41
+ // Extract image from response
42
+ const parts = data.candidates?.[0]?.content?.parts;
43
+ if (!parts) {
44
+ return { success: false, error: "No response from Gemini" };
45
+ }
46
+ for (const part of parts) {
47
+ if (part.inlineData?.data) {
48
+ const ext = part.inlineData.mimeType === "image/png" ? ".png" : ".jpg";
49
+ const filePath = path.join(TEMP_DIR, `gen_${Date.now()}${ext}`);
50
+ const buffer = Buffer.from(part.inlineData.data, "base64");
51
+ fs.writeFileSync(filePath, buffer);
52
+ return {
53
+ success: true,
54
+ filePath,
55
+ mimeType: part.inlineData.mimeType,
56
+ };
57
+ }
58
+ }
59
+ // Check if there's a text response explaining why no image was generated
60
+ const textPart = parts.find(p => p.text);
61
+ return {
62
+ success: false,
63
+ error: textPart?.text || "No image generated",
64
+ };
65
+ }
66
+ catch (err) {
67
+ return {
68
+ success: false,
69
+ error: `Image generation failed: ${err instanceof Error ? err.message : String(err)}`,
70
+ };
71
+ }
72
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Language Detection & Auto-Adaptation Service
3
+ *
4
+ * Detects the language of incoming messages using keyword heuristics,
5
+ * tracks usage statistics per user, and auto-adapts the preferred language
6
+ * when a clear pattern emerges.
7
+ *
8
+ * No external APIs — lightweight, fast, runs on every message.
9
+ */
10
+ import { loadProfile, saveProfile } from "./users.js";
11
+ // ── Detection Heuristics ─────────────────────────────────
12
+ // Common words that strongly indicate a language
13
+ const DE_MARKERS = new Set([
14
+ // Articles, pronouns, prepositions
15
+ "ich", "du", "er", "sie", "wir", "ihr", "ein", "eine", "der", "die", "das",
16
+ "den", "dem", "des", "ist", "sind", "hat", "haben", "wird", "werden",
17
+ "nicht", "und", "oder", "aber", "auch", "noch", "schon", "nur", "sehr",
18
+ "mit", "von", "für", "auf", "aus", "bei", "nach", "über", "unter",
19
+ "kann", "muss", "soll", "will", "möchte", "bitte", "danke", "ja", "nein",
20
+ "wie", "was", "wer", "wo", "wann", "warum", "welche", "welcher",
21
+ "diese", "dieser", "dieses", "jetzt", "hier", "dort", "heute", "morgen",
22
+ "hallo", "guten", "morgen", "abend", "nacht", "tschüss", "mach", "mache",
23
+ "kannst", "könntest", "würde", "würdest", "gibt", "gib", "zeig", "sag",
24
+ "mir", "dir", "uns", "euch", "mein", "dein", "sein", "kein", "keine",
25
+ "alle", "alles", "etwas", "nichts", "viel", "mehr", "wenig", "gut",
26
+ "neue", "neuen", "neues", "ersten", "letzten", "nächsten",
27
+ ]);
28
+ const EN_MARKERS = new Set([
29
+ // Articles, pronouns, prepositions
30
+ "the", "is", "are", "was", "were", "have", "has", "had", "will", "would",
31
+ "can", "could", "should", "must", "shall", "may", "might",
32
+ "not", "and", "but", "also", "still", "already", "only", "very",
33
+ "with", "from", "for", "about", "into", "through", "between",
34
+ "this", "that", "these", "those", "here", "there", "now", "then",
35
+ "what", "who", "where", "when", "why", "which", "how",
36
+ "please", "thanks", "thank", "yes", "hello", "hey", "bye",
37
+ "you", "your", "my", "his", "her", "our", "their",
38
+ "some", "any", "every", "all", "each", "many", "much", "more",
39
+ "just", "really", "actually", "right", "well", "sure", "okay",
40
+ "want", "need", "know", "think", "make", "give", "show", "tell",
41
+ "new", "first", "last", "next", "good", "great",
42
+ "create", "delete", "update", "send", "check", "find", "search",
43
+ "daily", "weekly", "summary", "list", "file", "open", "close",
44
+ "start", "stop", "run", "set", "get", "add", "remove",
45
+ ]);
46
+ /**
47
+ * Detect the language of a text message.
48
+ * Returns 'de', 'en', or 'unknown'.
49
+ */
50
+ export function detectLanguage(text) {
51
+ if (!text || text.length < 3)
52
+ return "unknown";
53
+ // Skip commands, URLs, code blocks
54
+ const cleaned = text
55
+ .replace(/^\/\w+/g, "") // remove /commands
56
+ .replace(/https?:\/\/\S+/g, "") // remove URLs
57
+ .replace(/```[\s\S]*?```/g, "") // remove code blocks
58
+ .replace(/`[^`]+`/g, "") // remove inline code
59
+ .toLowerCase();
60
+ const words = cleaned.split(/[\s,.!?;:()[\]{}'"]+/).filter(w => w.length >= 2);
61
+ if (words.length < 2)
62
+ return "unknown";
63
+ let deScore = 0;
64
+ let enScore = 0;
65
+ for (const word of words) {
66
+ if (DE_MARKERS.has(word))
67
+ deScore++;
68
+ if (EN_MARKERS.has(word))
69
+ enScore++;
70
+ }
71
+ // Umlauts are a very strong German signal
72
+ if (/[äöüß]/i.test(cleaned))
73
+ deScore += 3;
74
+ const total = deScore + enScore;
75
+ if (total < 2)
76
+ return "unknown"; // too few signals
77
+ if (deScore > enScore * 1.3)
78
+ return "de";
79
+ if (enScore > deScore * 1.3)
80
+ return "en";
81
+ return "unknown"; // ambiguous
82
+ }
83
+ /**
84
+ * Update language statistics for a user and auto-adapt if pattern is clear.
85
+ * Returns the recommended language for this session.
86
+ */
87
+ export function trackAndAdapt(userId, text, currentSessionLang) {
88
+ const profile = loadProfile(userId);
89
+ if (!profile)
90
+ return currentSessionLang;
91
+ // If user explicitly set language, don't auto-switch
92
+ if (profile.langExplicit)
93
+ return profile.language;
94
+ const detected = detectLanguage(text);
95
+ if (detected === "unknown")
96
+ return currentSessionLang;
97
+ // Initialize langStats if missing (existing profiles)
98
+ if (!profile.langStats) {
99
+ profile.langStats = { de: 0, en: 0, other: 0 };
100
+ }
101
+ // Update stats
102
+ profile.langStats[detected]++;
103
+ const total = profile.langStats.de + profile.langStats.en;
104
+ // Auto-adapt after enough signal (at least 3 messages)
105
+ if (total >= 3) {
106
+ const deRatio = profile.langStats.de / total;
107
+ const enRatio = profile.langStats.en / total;
108
+ let newLang = profile.language;
109
+ if (deRatio >= 0.6)
110
+ newLang = "de";
111
+ else if (enRatio >= 0.6)
112
+ newLang = "en";
113
+ if (newLang !== profile.language) {
114
+ profile.language = newLang;
115
+ }
116
+ }
117
+ else {
118
+ // Early phase: follow immediate language for responsiveness
119
+ profile.language = detected;
120
+ }
121
+ saveProfile(profile);
122
+ return profile.language;
123
+ }
124
+ /**
125
+ * Mark language as explicitly set by user (disables auto-detection).
126
+ */
127
+ export function setExplicitLanguage(userId, lang) {
128
+ const profile = loadProfile(userId);
129
+ if (!profile)
130
+ return;
131
+ profile.language = lang;
132
+ profile.langExplicit = true;
133
+ saveProfile(profile);
134
+ }
135
+ /**
136
+ * Reset to auto-detection mode.
137
+ */
138
+ export function resetToAutoLanguage(userId) {
139
+ const profile = loadProfile(userId);
140
+ if (!profile)
141
+ return;
142
+ profile.langExplicit = false;
143
+ saveProfile(profile);
144
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Telegram Markdown Sanitizer
3
+ *
4
+ * Telegram's Markdown parser is strict — unbalanced markers crash message sending.
5
+ * This module sanitizes AI-generated markdown to be Telegram-safe.
6
+ */
7
+ /**
8
+ * Sanitize markdown for Telegram compatibility.
9
+ * Fixes common issues:
10
+ * - Unbalanced bold (*), italic (_), code (`) markers
11
+ * - Nested formatting that Telegram doesn't support
12
+ * - Code blocks without closing ```
13
+ */
14
+ export function sanitizeTelegramMarkdown(text) {
15
+ if (!text)
16
+ return text;
17
+ let result = text;
18
+ // Fix unclosed code blocks (```)
19
+ const codeBlockCount = (result.match(/```/g) || []).length;
20
+ if (codeBlockCount % 2 !== 0) {
21
+ result += "\n```";
22
+ }
23
+ // Fix unclosed inline code (`)
24
+ // Count backticks outside of code blocks
25
+ const withoutCodeBlocks = result.replace(/```[\s\S]*?```/g, "");
26
+ const inlineCodeCount = (withoutCodeBlocks.match(/`/g) || []).length;
27
+ if (inlineCodeCount % 2 !== 0) {
28
+ result += "`";
29
+ }
30
+ // Fix unbalanced bold markers (*) outside code blocks
31
+ // Simple approach: count * outside code, close if unbalanced
32
+ const outsideCode = result.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
33
+ const boldCount = (outsideCode.match(/\*/g) || []).length;
34
+ if (boldCount % 2 !== 0) {
35
+ // Find the last * and remove it (safer than adding one)
36
+ const lastStarIdx = result.lastIndexOf("*");
37
+ if (lastStarIdx >= 0) {
38
+ result = result.slice(0, lastStarIdx) + result.slice(lastStarIdx + 1);
39
+ }
40
+ }
41
+ // Fix unbalanced italic markers (_) outside code blocks
42
+ const underscoreCount = (outsideCode.match(/_/g) || []).length;
43
+ if (underscoreCount % 2 !== 0) {
44
+ const lastIdx = result.lastIndexOf("_");
45
+ if (lastIdx >= 0) {
46
+ result = result.slice(0, lastIdx) + result.slice(lastIdx + 1);
47
+ }
48
+ }
49
+ return result;
50
+ }
51
+ /**
52
+ * Attempt to send with Markdown, fallback to plain text.
53
+ * Returns the parse_mode that worked (or undefined for plain).
54
+ */
55
+ export function getMarkdownSafe(text) {
56
+ try {
57
+ const sanitized = sanitizeTelegramMarkdown(text);
58
+ return { text: sanitized, parseMode: "Markdown" };
59
+ }
60
+ catch {
61
+ return { text, parseMode: undefined };
62
+ }
63
+ }