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
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
This commit is contained in:
344
telegram.js
Normal file
344
telegram.js
Normal 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, "<")
|
||||
.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,
|
||||
`⚠️ <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();
|
||||
}
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user