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();
+ }
+ })();
+}