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:
@@ -2,3 +2,9 @@ PORT=38110
|
|||||||
MONICA_BASE_URL=https://crm.mischlabs.de
|
MONICA_BASE_URL=https://crm.mischlabs.de
|
||||||
MONICA_API_TOKEN=
|
MONICA_API_TOKEN=
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
|
|
||||||
|
# Telegram Bot Integration
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_ALLOWED_CHAT_IDS=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
|||||||
16
server.js
16
server.js
@@ -3,6 +3,7 @@ import express from "express";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { startTelegramBot } from "./telegram.js";
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -228,7 +229,7 @@ app.post("/api/save", async (req, res) => {
|
|||||||
res.json(localRecord);
|
res.json(localRecord);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function monicaFetch(apiPath, options = {}) {
|
export async function monicaFetch(apiPath, options = {}) {
|
||||||
const response = await fetch(`${monicaBaseUrl}${apiPath}`, {
|
const response = await fetch(`${monicaBaseUrl}${apiPath}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -249,7 +250,7 @@ async function monicaFetch(apiPath, options = {}) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeToMonica(entry) {
|
export async function writeToMonica(entry) {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
if (entry.journal?.enabled && entry.journal?.title && entry.journal?.post) {
|
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 normalized = text.replace(/\s+/g, " ").trim();
|
||||||
const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList);
|
const contacts = mode === "journal" ? [] : inferContacts(normalized, contactsList);
|
||||||
const reminder = inferReminder(normalized);
|
const reminder = inferReminder(normalized);
|
||||||
@@ -519,7 +520,7 @@ function demoContacts(query) {
|
|||||||
return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase()));
|
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 });
|
await fs.mkdir(dataDir, { recursive: true });
|
||||||
const file = path.join(dataDir, "entries.json");
|
const file = path.join(dataDir, "entries.json");
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -534,10 +535,15 @@ async function appendLocalRecord(record) {
|
|||||||
await fs.writeFile(file, JSON.stringify(entries.slice(0, 500), null, 2));
|
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)}`;
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Friends listening on http://localhost:${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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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