Files
friends/telegram.js
Kroonk 5ea49ef05f
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 8s
Feat: Migrate voice transcription from OpenAI Whisper to Google Gemini 1.5 Flash API
2026-05-22 18:16:10 +02:00

359 lines
11 KiB
JavaScript
Raw 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 {
analyzeText,
writeToMonica,
appendLocalRecord,
monicaFetch,
cryptoRandomId,
} from "./server.js";
const monicaBaseUrl = (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");
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${geminiKey}`;
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.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();
}
// 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=150");
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 = analyzeText(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 (analysis.contact) {
const contactUrl = `${monicaBaseUrl}/people/${analysis.contact.id}`;
responseText += `👤 <b>Kontakt:</b> <a href="${contactUrl}">${escapeHtml(analysis.contact.name)}</a>\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) {
const typeLabel = res.type === "journal" ? "📖 Tagebuch" : res.type === "note" ? "📝 Notiz" : "📅 Erinnerung";
if (res.ok) {
responseText += `- ${typeLabel}: ✅ Erfolgreich (ID: ${res.id})\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();
}
})();
}