All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
385 lines
12 KiB
JavaScript
385 lines
12 KiB
JavaScript
import {
|
||
analyzeText,
|
||
writeToMonica,
|
||
appendLocalRecord,
|
||
monicaFetch,
|
||
cryptoRandomId,
|
||
} from "./server.js";
|
||
|
||
function getMonicaBaseUrl() {
|
||
return (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 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");
|
||
|
||
// List of models to try in order of preference
|
||
const modelsToTry = [
|
||
"gemini-2.5-flash",
|
||
"gemini-2.0-flash",
|
||
"gemini-1.5-flash-latest",
|
||
"gemini-1.5-flash"
|
||
];
|
||
|
||
let lastError = null;
|
||
for (const model of modelsToTry) {
|
||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${geminiKey}`;
|
||
try {
|
||
console.log(`[Telegram] Trying voice transcription with model: ${model}`);
|
||
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.status === 404) {
|
||
console.warn(`[Telegram] Model ${model} returned 404. Trying next model...`);
|
||
continue;
|
||
}
|
||
|
||
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();
|
||
} catch (err) {
|
||
console.error(`[Telegram] Error with model ${model}:`, err.message);
|
||
lastError = err;
|
||
}
|
||
}
|
||
|
||
throw new Error(`All Gemini models failed. Last error: ${lastError ? lastError.message : "unknown"}`);
|
||
}
|
||
|
||
// 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=100");
|
||
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 = `${getMonicaBaseUrl()}/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();
|
||
}
|
||
})();
|
||
}
|