All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 10s
866 lines
30 KiB
JavaScript
866 lines
30 KiB
JavaScript
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 = 100;
|
|
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 || "",
|
|
is_birthdate_known: false,
|
|
is_deceased: false,
|
|
is_deceased_date_known: false
|
|
}),
|
|
});
|
|
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;
|
|
}
|
|
|
|
try {
|
|
const analysis = await analyzeTextAI(text, mode, contactsList);
|
|
res.json(analysis);
|
|
} catch (error) {
|
|
console.error("[API] Analysis error:", error.message);
|
|
res.status(500).json({ error: "Analysis failed.", detail: error.message });
|
|
}
|
|
});
|
|
|
|
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,
|
|
}));
|
|
}
|
|
|
|
let contactId = Number(entry.contact?.id || 0);
|
|
|
|
// Auto-creation of a new contact
|
|
if (entry.contact?.is_new && entry.contact?.first_name) {
|
|
try {
|
|
console.log(`[CRM] Auto-creating new contact: "${entry.contact.first_name} ${entry.contact.last_name || ''}"`);
|
|
const newContactPayload = await monicaFetch("/api/contacts", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
first_name: entry.contact.first_name,
|
|
last_name: entry.contact.last_name || "",
|
|
description: "Automatisch angelegter Kontakt via Friends",
|
|
is_birthdate_known: !!entry.contact.birthdate,
|
|
is_deceased: false,
|
|
is_deceased_date_known: false
|
|
}),
|
|
});
|
|
const newContact = newContactPayload.data || {};
|
|
contactId = Number(newContact.id);
|
|
|
|
if (contactId) {
|
|
entry.contact.id = contactId;
|
|
entry.contact.name = newContact.complete_name || [entry.contact.first_name, entry.contact.last_name].filter(Boolean).join(" ");
|
|
results.push({ type: "contact_create", ok: true, id: contactId });
|
|
} else {
|
|
throw new Error("Could not retrieve new contact ID from Monica response.");
|
|
}
|
|
} catch (error) {
|
|
console.error("[CRM] Failed to auto-create contact in Monica:", error.message);
|
|
results.push({ type: "contact_create", ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
// Update/enrich contact fields if we have a contact ID
|
|
if (contactId) {
|
|
// 1. Birthdate enrichment (native birthday field on the contact)
|
|
if (entry.contact?.birthdate) {
|
|
try {
|
|
console.log(`[CRM] Enriching contact ${contactId} birthdate: ${entry.contact.birthdate}`);
|
|
// Monica PUT /api/contacts/:id requires first_name to be passed, so we fetch it first
|
|
const contactDetails = await monicaFetch(`/api/contacts/${contactId}`);
|
|
const existingData = contactDetails.data || {};
|
|
|
|
await monicaFetch(`/api/contacts/${contactId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
first_name: existingData.first_name || entry.contact.first_name || "Unbekannt",
|
|
last_name: existingData.last_name || entry.contact.last_name || "",
|
|
birthdate: entry.contact.birthdate,
|
|
is_birthdate_known: !!entry.contact.birthdate,
|
|
is_deceased: existingData.is_deceased ?? false,
|
|
is_deceased_date_known: existingData.is_deceased_date_known ?? false
|
|
}),
|
|
});
|
|
results.push({ type: "contact_birthdate", ok: true, id: contactId });
|
|
} catch (error) {
|
|
console.error(`[CRM] Failed to update birthdate for contact ${contactId}:`, error.message);
|
|
results.push({ type: "contact_birthdate", ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
// 2. Email enrichment
|
|
if (entry.contact?.email) {
|
|
try {
|
|
console.log(`[CRM] Adding email to contact ${contactId}: ${entry.contact.email}`);
|
|
const field = await monicaFetch("/api/contactfields", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
contact_field_type_id: 1, // Email
|
|
data: entry.contact.email,
|
|
}),
|
|
});
|
|
results.push({ type: "contact_email", ok: true, id: field.data?.id });
|
|
} catch (error) {
|
|
console.error(`[CRM] Failed to add email for contact ${contactId}:`, error.message);
|
|
results.push({ type: "contact_email", ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
// 3. Phone enrichment
|
|
if (entry.contact?.phone) {
|
|
try {
|
|
console.log(`[CRM] Adding phone to contact ${contactId}: ${entry.contact.phone}`);
|
|
const field = await monicaFetch("/api/contactfields", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
contact_field_type_id: 3, // Cell phone
|
|
data: entry.contact.phone,
|
|
}),
|
|
});
|
|
results.push({ type: "contact_phone", ok: true, id: field.data?.id });
|
|
} catch (error) {
|
|
console.error(`[CRM] Failed to add phone for contact ${contactId}:`, error.message);
|
|
results.push({ type: "contact_phone", ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
// 4. Address enrichment
|
|
if (entry.contact?.address) {
|
|
try {
|
|
console.log(`[CRM] Adding address to contact ${contactId}: ${entry.contact.address}`);
|
|
const addr = await monicaFetch("/api/addresses", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
contact_id: contactId,
|
|
name: "Hauptadresse",
|
|
street: entry.contact.address,
|
|
}),
|
|
});
|
|
results.push({ type: "contact_address", ok: true, id: addr.data?.id });
|
|
} catch (error) {
|
|
console.error(`[CRM] Failed to add address for contact ${contactId}:`, error.message);
|
|
results.push({ type: "contact_address", ok: false, error: error.message });
|
|
}
|
|
}
|
|
|
|
// 5. Contextual Clean Note
|
|
if (entry.note?.enabled && entry.note?.body) {
|
|
results.push(await monicaWrite("note", "/api/notes", {
|
|
contact_id: contactId,
|
|
body: entry.note.body,
|
|
is_favorited: entry.note.isFavorited ? 1 : 0,
|
|
}));
|
|
}
|
|
}
|
|
|
|
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 async function analyzeTextWithGemini(text, mode = "auto", contactsList = []) {
|
|
const geminiKey = process.env.GEMINI_API_KEY;
|
|
if (!geminiKey) return null;
|
|
|
|
const today = new Date();
|
|
const todayStr = today.toISOString().slice(0, 10);
|
|
const weekdayStr = today.toLocaleDateString("de-DE", { weekday: "long" });
|
|
|
|
const systemInstruction = `
|
|
You are an advanced NLP analysis assistant for a personal CRM (Monica) and diary system.
|
|
Analyze the user's text message or transcribed voice input, and extract contact details, notes, journal entries, and reminders.
|
|
|
|
The user's local date/time is: ${todayStr} (today is ${weekdayStr}).
|
|
|
|
We have the following existing contacts in our CRM:
|
|
${JSON.stringify(contactsList.map(c => ({ id: c.id, name: c.name })))}
|
|
|
|
Perform the following tasks:
|
|
1. Contact Matching:
|
|
- Match the input text to one of the existing contacts if the user is talking about or to them.
|
|
- Look for full names, first names, nicknames, or close matches.
|
|
- If matched, set "contact" to:
|
|
{ "is_new": false, "id": <existing_id>, "name": "<existing_name>", "first_name": "<existing_first_name>", "last_name": "<existing_last_name>" }
|
|
(Note: extract the first_name and last_name of the contact if known, or leave them empty/null).
|
|
|
|
2. New Contact Auto-Creation:
|
|
- If NO existing contact matches, and the user mentions a new name with BOTH a First and a Last name (e.g., "Tobias Misch"), OR mentions a single name with explicit contact details (like phone, email, address, or birthdate), set "contact" to:
|
|
{ "is_new": true, "id": null, "name": "<full_name>", "first_name": "<parsed_first_name>", "last_name": "<parsed_last_name>", "birthdate": "<parsed_birthdate>", "phone": "<parsed_phone>", "email": "<parsed_email>", "address": "<parsed_address>" }
|
|
- IMPORTANT: Only create a new contact (is_new: true) if BOTH first and last names are detected, OR if there is a single name accompanied by explicit details (phone, email, address, or birthdate). Do not auto-create contacts for a single first name with no other details (to avoid clutter).
|
|
|
|
3. Contact Details Extraction & Enrichment (for BOTH new and existing contacts):
|
|
- Extract any of the following if mentioned:
|
|
- birthdate: Birthday formatted as "YYYY-MM-DD" or "0000-MM-DD" if the year is unknown/unstated (e.g. "Geburtstag am 14. September" -> "0000-09-14").
|
|
- phone: Cell phone or phone number.
|
|
- email: Email address.
|
|
- address: Address / place of living.
|
|
|
|
4. Note Extraction (Clean Contact Note):
|
|
- If the text is about a contact, extract/write a clean, contextual, grammatically correct German note summarizing the information or interaction.
|
|
- CRITICAL: Keep note fields clean! The note body MUST NOT contain raw commands or instructions (e.g., "Erinnere mich an...", "Trage Geburtstag ein"), nor should it contain raw contact details (phone, email, address, birthdate) that are already captured in the structured fields. The note should be a short, premium German summary of the actual interaction or context (e.g., "Heute getroffen und ein Bier getrunken." or "Ist umgezogen und hat eine neue Telefonnummer.").
|
|
- Set note.enabled = true, note.isFavorited = true if the input feels highly important.
|
|
|
|
5. Reminder Extraction:
|
|
- Extract reminders if the user wants to be reminded of something (e.g., "erinnere mich am Freitag nachzufragen", "erinnere mich morgen anzurufen").
|
|
- Calculate the absolute date based on relative expressions and today's date (${todayStr}).
|
|
- Format: { "enabled": true, "title": "<German short title>", "description": "<full original sentence>", "initial_date": "YYYY-MM-DD", "frequency_type": "one_time", "frequency_number": 1 }
|
|
- CRITICAL: Skip creating reminders for birthdays, since the native birthdate field in Monica CRM automatically triggers birthday reminders.
|
|
|
|
6. Journal / Tagebuch:
|
|
- If the text has a diary/journal character (e.g. "heute war...", "ich fühle...", or general reflection), set journal.enabled = true, write a suitable German journal title, and set the post as the text.
|
|
|
|
7. Mode override:
|
|
- If mode is "journal", force type to "journal", set journal.enabled = true, and contact/note to null/disabled.
|
|
|
|
Output a single valid JSON object following this EXACT schema. Do not output any markdown formatting, backticks, or explanation. Only the raw JSON.
|
|
JSON Schema:
|
|
{
|
|
"type": "contact" | "journal" | "mixed",
|
|
"summary": "Short German summary of the text (max 150 chars)",
|
|
"contact": {
|
|
"is_new": boolean,
|
|
"id": number | null,
|
|
"name": "string" | null,
|
|
"first_name": "string" | null,
|
|
"last_name": "string" | null,
|
|
"birthdate": "YYYY-MM-DD" | "0000-MM-DD" | null,
|
|
"phone": "string" | null,
|
|
"email": "string" | null,
|
|
"address": "string" | null
|
|
} | null,
|
|
"note": {
|
|
"enabled": boolean,
|
|
"body": "string" | null,
|
|
"isFavorited": boolean
|
|
},
|
|
"journal": {
|
|
"enabled": boolean,
|
|
"title": "string" | null,
|
|
"post": "string" | null
|
|
},
|
|
"reminders": [
|
|
{
|
|
"enabled": boolean,
|
|
"title": "string",
|
|
"description": "string",
|
|
"initial_date": "YYYY-MM-DD",
|
|
"frequency_type": "one_time",
|
|
"frequency_number": 1
|
|
}
|
|
],
|
|
"confidence": "low" | "medium" | "high"
|
|
}
|
|
`;
|
|
|
|
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(`[NLP] Trying text analysis with model: ${model}`);
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [
|
|
{
|
|
parts: [
|
|
{
|
|
text: `${systemInstruction}\n\nUser input text to analyze:\n"${text}"`
|
|
}
|
|
]
|
|
}
|
|
],
|
|
generationConfig: {
|
|
responseMimeType: "application/json"
|
|
}
|
|
}),
|
|
});
|
|
|
|
if (res.status === 404) {
|
|
console.warn(`[NLP] 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 responseText = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
const parsed = JSON.parse(responseText.trim());
|
|
|
|
// Ensure rawText is added
|
|
parsed.rawText = text;
|
|
return parsed;
|
|
} catch (err) {
|
|
console.error(`[NLP] Error with model ${model}:`, err.message);
|
|
lastError = err;
|
|
}
|
|
}
|
|
|
|
throw new Error(`All Gemini models failed for NLP analysis. Last error: ${lastError ? lastError.message : "unknown"}`);
|
|
}
|
|
|
|
export async function analyzeTextAI(text, mode = "auto", contactsList = []) {
|
|
if (process.env.GEMINI_API_KEY) {
|
|
try {
|
|
const result = await analyzeTextWithGemini(text, mode, contactsList);
|
|
if (result) return result;
|
|
} catch (err) {
|
|
console.error("[NLP] Gemini analysis failed. Falling back to regex:", err.message);
|
|
}
|
|
}
|
|
return analyzeText(text, mode, contactsList);
|
|
}
|
|
|
|
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;
|
|
|
|
// Clean up parentheses from the name (e.g. "Aaron Lukas Lingel (Fludo Schlingel)" -> "Aaron Lukas Lingel")
|
|
const cleanContactName = contact.name.replace(/\(.*?\)/g, "").replace(/\s+/g, " ").trim();
|
|
if (!cleanContactName) continue;
|
|
|
|
const nameParts = cleanContactName.split(/\s+/).filter(p => p.length > 1);
|
|
if (nameParts.length === 0) continue;
|
|
|
|
const firstName = nameParts[0];
|
|
const lastName = nameParts[nameParts.length - 1];
|
|
|
|
const escFullName = cleanContactName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
const escFirstName = firstName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
|
|
const regexFull = new RegExp(`\\b${escFullName}\\b`, 'i');
|
|
const regexFirst = new RegExp(`\\b${escFirstName}\\b`, 'i');
|
|
|
|
let matches = false;
|
|
if (regexFull.test(text)) {
|
|
matches = true;
|
|
} else if (nameParts.length >= 2) {
|
|
const escLastName = lastName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
const regexLast = new RegExp(`\\b${escLastName}\\b`, 'i');
|
|
if (regexFirst.test(text) && regexLast.test(text)) {
|
|
matches = true;
|
|
}
|
|
} else if (firstName.length > 2 && regexFirst.test(text)) {
|
|
matches = true;
|
|
}
|
|
|
|
if (matches) {
|
|
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)}`;
|
|
}
|
|
|
|
const isMain = process.argv[1] && (path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)));
|
|
if (isMain) {
|
|
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");
|
|
}
|
|
});
|
|
}
|