Files
friends/telegram.js
Kroonk 1c3477138e
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 9s
feat: implement gemini async nlp extraction, contact auto-creation, data enrichment & clean notes
2026-05-22 23:51:15 +02:00

416 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
analyzeTextAI,
writeToMonica,
appendLocalRecord,
monicaFetch,
cryptoRandomId,
} from "./server.js";
function getMonicaBaseUrl() {
return (process.env.MONICA_BASE_URL || "https://crm.mischlabs.de").replace(/\/$/, "");
}
// Helper to escape HTML characters for Telegram HTML parse_mode
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Send a message back to the Telegram chat
async function sendTelegramMessage(chatId, text, replyToMessageId = null) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return;
const url = `https://api.telegram.org/bot${token}/sendMessage`;
const body = {
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
};
if (replyToMessageId) {
body.reply_to_message_id = replyToMessageId;
}
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`[Telegram] Failed to send message: ${res.status} - ${await res.text()}`);
}
} catch (err) {
console.error("[Telegram] Error sending message:", err);
}
}
// Send Chat Action (e.g. typing, upload_voice) to show the bot is active
async function sendChatAction(chatId, action) {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return;
const url = `https://api.telegram.org/bot${token}/sendChatAction`;
try {
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, action }),
});
} catch (e) {
// Silently ignore chat action errors
}
}
// Download voice note file from Telegram servers
async function downloadTelegramFile(filePath) {
const token = process.env.TELEGRAM_BOT_TOKEN;
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download Telegram file: ${res.status} ${res.statusText}`);
}
return await res.arrayBuffer();
}
// Transcribe OGG/Opus voice data using Google Gemini 1.5 Flash API (100% Free)
async function transcribeVoice(audioBuffer) {
const geminiKey = process.env.GEMINI_API_KEY;
if (!geminiKey) {
throw new Error("GEMINI_API_KEY is not configured in .env.");
}
const base64Audio = Buffer.from(audioBuffer).toString("base64");
// List of models to try in order of preference
const modelsToTry = [
"gemini-2.5-flash",
"gemini-2.0-flash",
"gemini-1.5-flash-latest",
"gemini-1.5-flash"
];
let lastError = null;
for (const model of modelsToTry) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`;
try {
console.log(`[Telegram] Trying voice transcription with model: ${model}`);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contents: [
{
parts: [
{
inlineData: {
mimeType: "audio/ogg",
data: base64Audio,
},
},
{
text: "Transkribiere diese Sprachnachricht wortgetreu in deutschen Text. Gib NUR die Transkription zurück, ohne Einleitung, Kommentare oder sonstige Zusätze. Falls nichts verständlich gesprochen wurde, antworte mit einem leeren Text.",
},
],
},
],
}),
});
if (res.status === 404) {
console.warn(`[Telegram] Model ${model} returned 404. Trying next model...`);
continue;
}
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Gemini API failed (${res.status}): ${errorText}`);
}
const data = await res.json();
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
return text.trim();
} catch (err) {
console.error(`[Telegram] Error with model ${model}:`, err.message);
lastError = err;
}
}
throw new Error(`All Gemini models failed. Last error: ${lastError ? lastError.message : "unknown"}`);
}
// Fetch available contacts from Monica to perform NLP name matching
async function fetchMonicaContacts() {
if (!process.env.MONICA_API_TOKEN) {
// Return standard demo contacts if no Monica token configured
return [
{ id: 1, name: "Aaron Lingel", description: "Demo-Kontakt" },
{ id: 2, name: "Tom", description: "Demo-Kontakt" },
{ id: 3, name: "MrDiderot", description: "Demo-Kontakt" },
];
}
try {
const payload = await monicaFetch("/api/contacts?limit=100");
const contacts = Array.isArray(payload.data) ? payload.data : [];
return contacts.map((c) => ({
id: c.id,
name: c.complete_name || [c.first_name, c.last_name].filter(Boolean).join(" "),
description: c.description || "",
}));
} catch (err) {
console.error("[Telegram] Error fetching Monica contacts:", err.message);
return [];
}
}
// Process an incoming update from the poller
async function handleUpdate(update) {
const message = update.message;
if (!message) return;
const chatId = String(message.chat?.id || "");
const fromId = String(message.from?.id || "");
if (!chatId) return;
// 1. Security & Access Control check
const allowedIds = (process.env.TELEGRAM_ALLOWED_CHAT_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
const isAllowed = allowedIds.includes(chatId) || allowedIds.includes(fromId);
if (!isAllowed) {
console.warn(`[Telegram] Unauthorized access attempt from Chat ID: ${chatId}, From ID: ${fromId}`);
await sendTelegramMessage(
chatId,
`⚠️ <b>Zugriff verweigert</b>\n\nDieses Friends-System ist privat. Deine Telegram Chat-ID ist:\n<code>${chatId}</code>\n\nBitte füge diese ID der Variablen <code>TELEGRAM_ALLOWED_CHAT_IDS</code> in deiner <code>.env</code>-Datei auf dem Server hinzu und starte Friends neu, um dich zu autorisieren.`,
message.message_id
);
return;
}
const msgText = message.text;
const voice = message.voice;
if (!msgText && !voice) {
await sendTelegramMessage(
chatId,
" <b>Hinweis:</b> Ich unterstütze aktuell nur Textnachrichten und Sprachnachrichten (Sprachnotizen). Sende mir einfach einen Text oder eine Sprachaufzeichnung!",
message.message_id
);
return;
}
let processedText = "";
let isVoice = false;
// Send a typing status immediately to show the bot is thinking
await sendChatAction(chatId, voice ? "record_audio" : "typing");
// 2. Extract text (either message text or transcribe voice note)
if (voice) {
isVoice = true;
if (!process.env.GEMINI_API_KEY) {
await sendTelegramMessage(
chatId,
"⚠️ <b>Sprachnachrichten deaktiviert:</b>\nEs ist kein <code>GEMINI_API_KEY</code> in der <code>.env</code>-Konfiguration hinterlegt. Bitte erstelle einen kostenlosen Key im Google AI Studio und füge ihn hinzu.",
message.message_id
);
return;
}
try {
const token = process.env.TELEGRAM_BOT_TOKEN;
// Get file path from Telegram Bot API
const fileInfoRes = await fetch(`https://api.telegram.org/bot${token}/getFile?file_id=${voice.file_id}`);
if (!fileInfoRes.ok) {
throw new Error(`Telegram getFile endpoint failed: ${fileInfoRes.status}`);
}
const fileInfo = await fileInfoRes.json();
if (!fileInfo.ok || !fileInfo.result?.file_path) {
throw new Error(`Invalid Telegram file info response: ${JSON.stringify(fileInfo)}`);
}
// Download file binary
const audioBuffer = await downloadTelegramFile(fileInfo.result.file_path);
// Transcribe via Gemini
processedText = await transcribeVoice(audioBuffer);
if (!processedText.trim()) {
await sendTelegramMessage(
chatId,
"🎤 ❌ <b>Transkriptionsfehler:</b> Die Sprachnachricht konnte transkribiert werden, enthielt aber keinen Text. Bitte versuche es deutlicher.",
message.message_id
);
return;
}
} catch (err) {
console.error("[Telegram] Voice transcription error:", err);
await sendTelegramMessage(
chatId,
`🎤 ❌ <b>Fehler bei Sprachnachricht:</b>\n<code>${escapeHtml(err.message)}</code>`,
message.message_id
);
return;
}
} else {
processedText = msgText.trim();
}
// 3. Match contacts and run NLP engine
await sendChatAction(chatId, "typing");
const contacts = await fetchMonicaContacts();
const analysis = await analyzeTextAI(processedText, "auto", contacts);
// 4. Save entry locally and sync to Monica CRM
const entry = {
rawText: processedText,
type: analysis.type,
summary: analysis.summary,
contact: analysis.contact,
note: analysis.note,
journal: analysis.journal,
reminders: analysis.reminders,
writeToMonica: true,
};
const savedAt = new Date().toISOString();
const localRecord = {
id: cryptoRandomId(),
savedAt,
entry,
monicaResults: [],
};
if (process.env.MONICA_API_TOKEN) {
localRecord.monicaResults = await writeToMonica(entry);
}
await appendLocalRecord(localRecord);
// 5. Build and send beautiful success report to the user
let responseText = `<b>Eintrag erfasst!</b> ✅\n\n`;
if (isVoice) {
responseText += `🎤 <b>Transkription (Gemini):</b>\n<i>"${escapeHtml(processedText)}"</i>\n\n`;
}
if (entry.contact) {
if (entry.contact.id) {
const contactUrl = `${getMonicaBaseUrl()}/people/${entry.contact.id}`;
const nameStr = entry.contact.is_new ? `${entry.contact.name} (neu erstellt)` : entry.contact.name;
responseText += `👤 <b>Kontakt:</b> <a href="${contactUrl}">${escapeHtml(nameStr)}</a>\n`;
} else {
responseText += `👤 <b>Kontakt:</b> <i>Fehler bei Erstellung (${escapeHtml(entry.contact.name)})</i>\n`;
}
} else {
responseText += `👤 <b>Kontakt:</b> <i>Keine Zuordnung</i>\n`;
}
if (analysis.note?.enabled && analysis.note?.body) {
responseText += `📝 <b>Kontaktnotiz:</b> ${escapeHtml(analysis.note.body)}\n`;
}
if (analysis.journal?.enabled && analysis.journal?.title) {
responseText += `📖 <b>Tagebuch:</b> ${escapeHtml(analysis.journal.title)}\n`;
}
if (analysis.reminders && analysis.reminders.length > 0) {
for (const reminder of analysis.reminders) {
responseText += `📅 <b>Erinnerung:</b> ${escapeHtml(reminder.title)} am ${escapeHtml(reminder.initial_date)}\n`;
}
}
responseText += `\n<b>Monica CRM Synchronisation:</b>\n`;
if (localRecord.monicaResults && localRecord.monicaResults.length > 0) {
for (const res of localRecord.monicaResults) {
let typeLabel = "⚙️ CRM";
switch (res.type) {
case "journal":
typeLabel = "📖 Tagebuch";
break;
case "note":
typeLabel = "📝 Notiz";
break;
case "reminder":
typeLabel = "📅 Erinnerung";
break;
case "contact_create":
typeLabel = "👤 Kontakt erstellt";
break;
case "contact_birthdate":
typeLabel = "📅 Geburtstag aktualisiert";
break;
case "contact_email":
typeLabel = "📧 E-Mail hinzugefügt";
break;
case "contact_phone":
typeLabel = "📞 Telefon hinzugefügt";
break;
case "contact_address":
typeLabel = "🏠 Adresse hinzugefügt";
break;
}
if (res.ok) {
responseText += `- ${typeLabel}: ✅ Erfolgreich\n`;
} else {
responseText += `- ${typeLabel}: ❌ Fehler: <code>${escapeHtml(res.error)}</code>\n`;
}
}
} else {
responseText += `<i>Lokal protokolliert (Keine Übertragung an Monica CRM).</i>\n`;
}
await sendTelegramMessage(chatId, responseText, message.message_id);
}
// Infinite loop long poller for Telegram Updates
let offset = 0;
async function pollUpdates() {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) return;
const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=30`;
try {
const res = await fetch(url);
if (!res.ok) {
console.error(`[Telegram] Polling failed (${res.status}): ${res.statusText}`);
await new Promise((resolve) => setTimeout(resolve, 5000));
return;
}
const data = await res.json();
if (data.ok && Array.isArray(data.result)) {
for (const update of data.result) {
offset = update.update_id + 1;
await handleUpdate(update).catch((err) => {
console.error("[Telegram] Error processing update:", err);
});
}
}
} catch (err) {
console.error("[Telegram] Network error during polling:", err);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
// Main bot entrypoint
export function startTelegramBot() {
console.log("[Telegram] Long-Polling Bot started successfully.");
(async () => {
while (true) {
await pollUpdates();
}
})();
}