diff --git a/.env.example b/.env.example index 8b3ec30..29fda33 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,9 @@ PORT=38110 MONICA_BASE_URL=https://crm.mischlabs.de MONICA_API_TOKEN= DATA_DIR=./data + +# Telegram Bot Integration +TELEGRAM_BOT_TOKEN= +TELEGRAM_ALLOWED_CHAT_IDS= +OPENAI_API_KEY= + diff --git a/server.js b/server.js index b244328..4df1a2e 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,7 @@ import express from "express"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { startTelegramBot } from "./telegram.js"; dotenv.config(); @@ -228,7 +229,7 @@ app.post("/api/save", async (req, res) => { res.json(localRecord); }); -async function monicaFetch(apiPath, options = {}) { +export async function monicaFetch(apiPath, options = {}) { const response = await fetch(`${monicaBaseUrl}${apiPath}`, { ...options, headers: { @@ -249,7 +250,7 @@ async function monicaFetch(apiPath, options = {}) { return payload; } -async function writeToMonica(entry) { +export async function writeToMonica(entry) { const results = []; if (entry.journal?.enabled && entry.journal?.title && entry.journal?.post) { @@ -297,7 +298,7 @@ async function monicaWrite(type, apiPath, body) { } } -function analyzeText(text, mode = "auto", contactsList = []) { +export function analyzeText(text, mode = "auto", contactsList = []) { const normalized = text.replace(/\s+/g, " ").trim(); const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList); const reminder = inferReminder(normalized); @@ -519,7 +520,7 @@ function demoContacts(query) { return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase())); } -async function appendLocalRecord(record) { +export async function appendLocalRecord(record) { await fs.mkdir(dataDir, { recursive: true }); const file = path.join(dataDir, "entries.json"); let entries = []; @@ -534,10 +535,15 @@ async function appendLocalRecord(record) { await fs.writeFile(file, JSON.stringify(entries.slice(0, 500), null, 2)); } -function cryptoRandomId() { +export function cryptoRandomId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } app.listen(port, () => { console.log(`Friends listening on http://localhost:${port}`); + if (process.env.TELEGRAM_BOT_TOKEN) { + startTelegramBot(); + } else { + console.log("Telegram Bot not started: TELEGRAM_BOT_TOKEN is not configured in .env"); + } }); diff --git a/telegram.js b/telegram.js new file mode 100644 index 0000000..be0d10b --- /dev/null +++ b/telegram.js @@ -0,0 +1,344 @@ +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, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// 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 OpenAI Whisper API +async function transcribeVoice(audioBuffer) { + if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY is not configured in .env."); + } + + const formData = new FormData(); + // Whisper requires the ogg/opus file to have a proper filename extension + const blob = new Blob([audioBuffer], { type: "audio/ogg" }); + formData.append("file", blob, "voice.ogg"); + formData.append("model", "whisper-1"); + formData.append("language", "de"); + + const res = await fetch("https://api.openai.com/v1/audio/transcriptions", { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: formData, + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`OpenAI Whisper API failed (${res.status}): ${errorText}`); + } + + const data = await res.json(); + return data.text || ""; +} + +// 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, + `⚠️ Zugriff verweigert\n\nDieses Friends-System ist privat. Deine Telegram Chat-ID ist:\n${chatId}\n\nBitte füge diese ID der Variablen TELEGRAM_ALLOWED_CHAT_IDS in deiner .env-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, + "ℹ️ Hinweis: 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.OPENAI_API_KEY) { + await sendTelegramMessage( + chatId, + "⚠️ Sprachnachrichten deaktiviert:\nEs ist kein OPENAI_API_KEY in der .env-Konfiguration hinterlegt. Bitte sende stattdessen Textnachrichten oder füge den API-Key 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 Whisper + processedText = await transcribeVoice(audioBuffer); + if (!processedText.trim()) { + await sendTelegramMessage( + chatId, + "🎤 ❌ Transkriptionsfehler: 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, + `🎤 ❌ Fehler bei Sprachnachricht:\n${escapeHtml(err.message)}`, + 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 = `Eintrag erfasst! ✅\n\n`; + + if (isVoice) { + responseText += `🎤 Transkription:\n"${escapeHtml(processedText)}"\n\n`; + } + + if (analysis.contact) { + const contactUrl = `${monicaBaseUrl}/people/${analysis.contact.id}`; + responseText += `👤 Kontakt: ${escapeHtml(analysis.contact.name)}\n`; + } else { + responseText += `👤 Kontakt: Keine Zuordnung\n`; + } + + if (analysis.note?.enabled && analysis.note?.body) { + responseText += `📝 Kontaktnotiz: ${escapeHtml(analysis.note.body)}\n`; + } + + if (analysis.journal?.enabled && analysis.journal?.title) { + responseText += `📖 Tagebuch: ${escapeHtml(analysis.journal.title)}\n`; + } + + if (analysis.reminders && analysis.reminders.length > 0) { + for (const reminder of analysis.reminders) { + responseText += `📅 Erinnerung: ${escapeHtml(reminder.title)} am ${escapeHtml(reminder.initial_date)}\n`; + } + } + + responseText += `\nMonica CRM Synchronisation:\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: ${escapeHtml(res.error)}\n`; + } + } + } else { + responseText += `Lokal protokolliert (Keine Übertragung an Monica CRM).\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(); + } + })(); +}