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,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
|
+
}
|