feat: add Misch & Friends companion app
All checks were successful
Build & Push Friends Image to Gitea Registry / build-and-push (push) Successful in 16s

This commit is contained in:
Kroonk
2026-05-22 17:32:24 +02:00
commit 7afca6561f
13 changed files with 2203 additions and 0 deletions

359
server.js Normal file
View File

@@ -0,0 +1,359 @@
import dotenv from "dotenv";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
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 suffix = query ? `?query=${encodeURIComponent(query)}` : "";
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.post("/api/analyze", async (req, res) => {
const text = String(req.body.text || "").trim();
const mode = String(req.body.mode || "auto");
if (!text) {
res.status(400).json({ error: "Text is required." });
return;
}
res.json(analyzeText(text, mode));
});
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);
});
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;
}
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 };
}
}
function analyzeText(text, mode = "auto") {
const normalized = text.replace(/\s+/g, " ").trim();
const contacts = mode === "journal" ? [] : inferContacts(normalized);
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) {
const candidates = [];
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 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()));
}
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));
}
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}`);
});