Feat: Add native Telegram Bot integration with Voice transcription via Whisper and Monica CRM synchronization
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s

This commit is contained in:
Kroonk
2026-05-22 18:06:50 +02:00
parent 9f62eebed6
commit eccee059cf
3 changed files with 361 additions and 5 deletions

344
telegram.js Normal file
View File

@@ -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, "&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 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,
`⚠️ <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.OPENAI_API_KEY) {
await sendTelegramMessage(
chatId,
"⚠️ <b>Sprachnachrichten deaktiviert:</b>\nEs ist kein <code>OPENAI_API_KEY</code> in der <code>.env</code>-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,
"🎤 ❌ <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:</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();
}
})();
}