import dotenv from "dotenv"; 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(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const port = Number(process.env.PORT || 38110); const dataDir = path.resolve(__dirname, process.env.DATA_DIR || "./data"); const monicaBaseUrl = (process.env.MONICA_BASE_URL || "https://crm.mischlabs.de").replace(/\/$/, ""); const monicaApiToken = process.env.MONICA_API_TOKEN || ""; app.use(express.json({ limit: "1mb" })); app.use(express.static(path.join(__dirname, "public"))); app.get("/api/health", async (_req, res) => { res.json({ ok: true, monicaConfigured: Boolean(monicaApiToken), monicaBaseUrl, }); }); app.get("/api/contacts", async (req, res) => { const query = String(req.query.query || "").trim(); if (!monicaApiToken) { res.json({ mode: "demo", data: demoContacts(query), }); return; } try { const limit = 150; const suffix = query ? `?query=${encodeURIComponent(query)}&limit=${limit}` : `?limit=${limit}`; const payload = await monicaFetch(`/api/contacts${suffix}`); const contacts = Array.isArray(payload.data) ? payload.data : []; res.json({ mode: "monica", data: contacts.map((contact) => ({ id: contact.id, name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "), description: contact.description || "", })), }); } catch (error) { res.status(502).json({ error: "Monica contacts could not be loaded.", detail: error.message }); } }); app.get("/api/contacts/:id", async (req, res) => { const id = Number(req.params.id); if (!monicaApiToken) { res.json(demoContactDetails(id)); return; } try { const contactPayload = await monicaFetch(`/api/contacts/${id}`); const contact = contactPayload.data || {}; let emails = []; let phones = []; try { const fieldsPayload = await monicaFetch(`/api/contacts/${id}/contactfields`); const fields = Array.isArray(fieldsPayload.data) ? fieldsPayload.data : []; emails = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("email") || f.contact_field_type_id === 1).map(f => f.data); phones = fields.filter(f => f.contact_field_type?.name?.toLowerCase().includes("phone") || f.contact_field_type_id === 2 || f.contact_field_type_id === 3 || f.contact_field_type?.name?.toLowerCase().includes("telefon") || f.contact_field_type?.name?.toLowerCase().includes("mobil")).map(f => f.data); } catch (e) { console.log("Could not load contact fields:", e.message); } let addressStr = ""; try { const addressPayload = await monicaFetch(`/api/contacts/${id}/addresses`); const addresses = Array.isArray(addressPayload.data) ? addressPayload.data : []; if (addresses.length > 0) { const addr = addresses[0]; addressStr = [addr.street, addr.postal_code, addr.city].filter(Boolean).join(", "); } } catch (e) { console.log("Could not load addresses:", e.message); } res.json({ id: contact.id, first_name: contact.first_name || "", last_name: contact.last_name || "", name: contact.complete_name || [contact.first_name, contact.last_name].filter(Boolean).join(" "), description: contact.description || "", birthdate: contact.birthdate || "", email: emails[0] || "", phone: phones[0] || "", address: addressStr || "", }); } catch (error) { res.status(502).json({ error: "Monica contact details could not be loaded.", detail: error.message }); } }); app.post("/api/contacts/create", async (req, res) => { const { first_name, last_name, description, email, phone, address } = req.body || {}; if (!first_name) { res.status(400).json({ error: "First name is required." }); return; } if (!monicaApiToken) { const newId = Math.floor(Math.random() * 1000) + 10; const newContact = { id: newId, first_name, last_name, name: [first_name, last_name].filter(Boolean).join(" "), description: description || "Neu angelegter Demo-Kontakt", email: email || "", phone: phone || "", address: address || "", }; res.json({ ok: true, mode: "demo", data: newContact }); return; } try { const contactPayload = await monicaFetch("/api/contacts", { method: "POST", body: JSON.stringify({ first_name, last_name: last_name || "", description: description || "", }), }); const contact = contactPayload.data || {}; const contactId = contact.id; if (contactId && email) { try { await monicaFetch("/api/contactfields", { method: "POST", body: JSON.stringify({ contact_id: contactId, contact_field_type_id: 1, // Email data: email, }), }); } catch (e) { console.error("Could not save email:", e.message); } } if (contactId && phone) { try { await monicaFetch("/api/contactfields", { method: "POST", body: JSON.stringify({ contact_id: contactId, contact_field_type_id: 3, // Cell phone data: phone, }), }); } catch (e) { console.error("Could not save phone:", e.message); } } if (contactId && address) { try { await monicaFetch("/api/addresses", { method: "POST", body: JSON.stringify({ contact_id: contactId, name: "Hauptadresse", street: address, }), }); } catch (e) { console.error("Could not save address:", e.message); } } res.json({ ok: true, mode: "monica", data: { id: contactId, name: contact.complete_name || [first_name, last_name].filter(Boolean).join(" "), }, }); } catch (error) { res.status(502).json({ error: "Contact could not be created in Monica.", detail: error.message }); } }); app.post("/api/analyze", async (req, res) => { const text = String(req.body.text || "").trim(); const mode = String(req.body.mode || "auto"); const contactsList = req.body.contacts || []; if (!text) { res.status(400).json({ error: "Text is required." }); return; } res.json(analyzeText(text, mode, contactsList)); }); app.post("/api/save", async (req, res) => { const entry = req.body || {}; const savedAt = new Date().toISOString(); const localRecord = { id: cryptoRandomId(), savedAt, entry, monicaResults: [], }; if (monicaApiToken && entry.writeToMonica) { localRecord.monicaResults = await writeToMonica(entry); } await appendLocalRecord(localRecord); res.json(localRecord); }); export async function monicaFetch(apiPath, options = {}) { const response = await fetch(`${monicaBaseUrl}${apiPath}`, { ...options, headers: { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${monicaApiToken}`, ...(options.headers || {}), }, }); const text = await response.text(); const payload = text ? JSON.parse(text) : {}; if (!response.ok) { throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 500)}`); } return payload; } export async function writeToMonica(entry) { const results = []; if (entry.journal?.enabled && entry.journal?.title && entry.journal?.post) { results.push(await monicaWrite("journal", "/api/journal", { title: entry.journal.title, post: entry.journal.post, })); } const contactId = Number(entry.contact?.id || 0); if (contactId && entry.note?.enabled && entry.note?.body) { results.push(await monicaWrite("note", "/api/notes", { contact_id: contactId, body: entry.note.body, is_favorited: Boolean(entry.note.isFavorited), })); } if (contactId && Array.isArray(entry.reminders)) { for (const reminder of entry.reminders.filter((item) => item.enabled && item.initial_date && item.title)) { results.push(await monicaWrite("reminder", "/api/reminders", { contact_id: contactId, initial_date: reminder.initial_date, frequency_type: reminder.frequency_type || "one_time", frequency_number: Number(reminder.frequency_number || 1), title: reminder.title, description: reminder.description || "", delible: true, })); } } return results; } async function monicaWrite(type, apiPath, body) { try { const payload = await monicaFetch(apiPath, { method: "POST", body: JSON.stringify(body), }); return { type, ok: true, id: payload.data?.id || null }; } catch (error) { return { type, ok: false, error: error.message }; } } 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); const type = mode === "auto" ? inferType(normalized, contacts, reminder) : mode; const summary = summarize(normalized); const today = new Date(); return { rawText: text, type, summary, contacts, contact: contacts[0] || null, note: { enabled: type !== "journal", body: buildContactNote(normalized, reminder), isFavorited: /wichtig|merken|nicht vergessen|unbedingt/i.test(normalized), }, journal: { enabled: type === "journal" || type === "mixed", title: inferJournalTitle(normalized, today), post: normalized, }, reminders: reminder ? [reminder] : [], confidence: contacts.length || reminder ? "medium" : "low", }; } function inferType(text, contacts, reminder) { if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text) && (contacts.length || reminder)) { return "mixed"; } if (/tagebuch|journal|heute war|ich fühle|ich habe mich|gedanken/i.test(text)) { return "journal"; } if (contacts.length || reminder) { return "contact"; } return "journal"; } function inferContacts(text, contactsList = []) { const candidates = []; // 1. Scan for echten Kontakten (from DB/Monica) if (Array.isArray(contactsList) && contactsList.length > 0) { for (const contact of contactsList) { if (!contact.name) continue; const nameParts = contact.name.trim().split(/\s+/); const firstName = nameParts[0]; const fullName = contact.name.trim(); const escFullName = fullName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const escFirstName = firstName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const regexFull = new RegExp(`\\b${escFullName}\\b`, 'i'); const regexFirst = new RegExp(`\\b${escFirstName}\\b`, 'i'); if (regexFull.test(text) || (firstName.length > 2 && regexFirst.test(text))) { if (!candidates.some((item) => item.id === contact.id)) { candidates.push({ id: contact.id, name: contact.name, description: contact.description || "" }); } } } } // 2. Fallback / Ergänzung durch klassische Regexes (wenn keine echten Kontakte gematcht wurden) if (candidates.length === 0) { const patterns = [ /(?:mit|bei|von|über|wegen)\s+([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, /([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)\s+(?:getroffen|gesprochen|telefoniert|gesehen)/g, /(?:treffe|spreche|telefoniere|schreibe)\s+(?:mit\s+)?([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?)/g, /@([A-Za-zÄÖÜäöüß]+(?:\s+[A-Za-zÄÖÜäöüß]+)?)/g, ]; for (const pattern of patterns) { for (const match of text.matchAll(pattern)) { const name = cleanupName(match[1]); if (name && !candidates.some((item) => item.name.toLowerCase() === name.toLowerCase())) { candidates.push({ name }); } } } } return candidates.slice(0, 3); } function demoContactDetails(id) { const details = { 1: { id: 1, first_name: "Aaron", last_name: "Lingel", name: "Aaron Lingel", description: "Bester Kumpel auf dem Campus", email: "aaron.lingel@example.com", phone: "+49 176 898989", address: "Fischbrötchenallee 42, 22767 Hamburg" }, 2: { id: 2, first_name: "Tom", last_name: "", name: "Tom", description: "Vibecoding-Kollege", email: "tom@example.com", phone: "+49 152 111222", address: "Mischweg 7, 20357 Hamburg" }, 3: { id: 3, first_name: "MrDiderot", last_name: "", name: "MrDiderot", description: "Admin und CRM Host", email: "mrdiderot@mischlabs.de", phone: "+49 170 333444", address: "NAS-Zentrale 1, 20095 Hamburg" }, }; return details[id] || { id, first_name: "Unbekannt", last_name: "", name: "Unbekannter Kontakt", description: "", email: "", phone: "", address: "" }; } function cleanupName(name) { const cleaned = name .replace(/\b(heute|morgen|gestern|freitag|samstag|sonntag|montag|dienstag|mittwoch|donnerstag)\b/gi, "") .trim(); if (/^(ich|er|sie|wir|frag|frage|denk)$/i.test(cleaned)) return ""; return cleaned; } function inferReminder(text) { const date = inferReminderDate(text); if (!date) return null; const titleMatch = text.match(/(?:erinnere mich|nachfragen|frag(?:e)?|denk daran|nicht vergessen)[,:]?\s*(.+)$/i); const title = titleMatch ? cleanupReminderTitle(titleMatch[1]) : "Nachfassen"; return { enabled: true, title: title.slice(0, 160), description: text, initial_date: date, frequency_type: "one_time", frequency_number: 1, }; } function cleanupReminderTitle(title) { return title .split(/[.!?]/)[0] .replace(/^(ihn|sie|es|mich|uns)\s+/i, "") .replace(/\bam\s+(montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b,?\s*/i, "") .replace(/\bin\s+\d{1,3}\s+(tagen?|wochen?)\b,?\s*/i, "") .replace(/\bwie\s+es\s+lief\b/i, "fragen, wie es lief") .trim() .replace(/^./, (char) => char.toUpperCase()) || "Nachfassen"; } function inferReminderDate(text) { const now = new Date(); const lower = text.toLowerCase(); if (lower.includes("morgen")) return toDateString(addDays(now, 1)); if (lower.includes("übermorgen") || lower.includes("uebermorgen")) return toDateString(addDays(now, 2)); const daysMatch = lower.match(/in\s+(\d{1,3})\s+tagen?/); if (daysMatch) return toDateString(addDays(now, Number(daysMatch[1]))); const weeksMatch = lower.match(/in\s+(\d{1,2})\s+wochen?/); if (weeksMatch) return toDateString(addDays(now, Number(weeksMatch[1]) * 7)); const weekdays = { montag: 1, dienstag: 2, mittwoch: 3, donnerstag: 4, freitag: 5, samstag: 6, sonntag: 0, }; for (const [name, day] of Object.entries(weekdays)) { if (lower.includes(name)) return toDateString(nextWeekday(now, day)); } const germanDate = lower.match(/\b(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?\b/); if (germanDate) { const year = germanDate[3] ? normalizeYear(Number(germanDate[3])) : now.getFullYear(); return toDateString(new Date(year, Number(germanDate[2]) - 1, Number(germanDate[1]))); } return null; } function buildContactNote(text, reminder) { if (!reminder) return text; return `${text}\n\nErkannte Erinnerung: ${reminder.title} am ${reminder.initial_date}`; } function inferJournalTitle(text, date) { const firstSentence = text.split(/[.!?]/)[0]?.trim(); if (firstSentence && firstSentence.length <= 80) return firstSentence; return `Eintrag vom ${date.toLocaleDateString("de-DE")}`; } function summarize(text) { if (text.length <= 180) return text; return `${text.slice(0, 177).trim()}...`; } function addDays(date, days) { const next = new Date(date); next.setDate(next.getDate() + days); return next; } function nextWeekday(date, targetDay) { const result = new Date(date); const delta = (targetDay + 7 - result.getDay()) % 7 || 7; result.setDate(result.getDate() + delta); return result; } function toDateString(date) { return date.toISOString().slice(0, 10); } function normalizeYear(year) { if (year < 100) return 2000 + year; return year; } function demoContacts(query) { const contacts = [ { id: 1, name: "Aaron Lingel", description: "Demo-Kontakt" }, { id: 2, name: "Tom", description: "Demo-Kontakt" }, { id: 3, name: "MrDiderot", description: "Demo-Kontakt" }, ]; if (!query) return contacts; return contacts.filter((contact) => contact.name.toLowerCase().includes(query.toLowerCase())); } export async function appendLocalRecord(record) { await fs.mkdir(dataDir, { recursive: true }); const file = path.join(dataDir, "entries.json"); let entries = []; try { entries = JSON.parse(await fs.readFile(file, "utf8")); } catch { entries = []; } entries.unshift(record); await fs.writeFile(file, JSON.stringify(entries.slice(0, 500), null, 2)); } 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"); } });