diff --git a/.env.example b/.env.example index 73d7430..e4653fc 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,8 @@ API_PORT=8000 # --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) --- WEB_URL=https://kontakte.example.de + +# --- Chat-Archive API (optional, zeigt Chat-Nachrichten in der Kontakt-Detailseite) --- +CHATAPI_ENABLED=false +CHATAPI_URL=http://docker-host-pve.fritz.box:8420 +CHATAPI_KEY=change-me diff --git a/AGENTS.md b/AGENTS.md index 99969d1..d4fb308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,7 @@ See `.env.example` for full list. Key variables: - `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`) - `MAILER_ENABLED` — Feature flag for birthday mailer - `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email +- `CHATAPI_ENABLED` / `CHATAPI_URL` / `CHATAPI_KEY` — Chat-Archive integration (optional) ## Architecture Notes diff --git a/README.md b/README.md index e13e6a3..ea7c630 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`, `apple_email`, `apple_app_password`, `authelia_user` und (optional) `birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url` konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird -(z.B. für Uptime-Monitoring). Diese Datei +(z.B. für Uptime-Monitoring). Optional kann pro Account ein +`chat_sender_name` konfiguriert werden, um den Namen in der +Chat-Archive DB zuzuordnen (Details siehe Abschnitt 12). Diese Datei bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und wird nur als Volume in den Container gemountet. @@ -294,6 +296,12 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich. | `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | +**Chat-Archive (optional):** + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| `GET` | `/api/contacts/{id}/messages` | Chat-Nachrichten eines Kontakts via Chat-Archive API (`?offset=0&limit=50`) | + Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres eigenen Accounts. @@ -304,6 +312,40 @@ eigenen Accounts. curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts ``` +## 💬 12. Chat-Archive Integration (optional) + +Zeigt Chat-Nachrichten (Instagram/Facebook) direkt in der +Kontakt-Detailseite, mit Infinite Scroll und Messenger-Style Bubbles. + +### Voraussetzung + +- Eine laufende [Chat-Archive API](https://github.com/stefan-koelle/chat-archive) + mit importierten Nachrichten. + +### Konfiguration + +In `.env`: + +``` +CHATAPI_ENABLED=true +CHATAPI_URL=http://docker-host-pve.fritz.box:8420 +CHATAPI_KEY=change-me +``` + +In `config/accounts.json` pro Account das `chat_sender_name` setzen +(Name wie in der Chat-Archive DB als `sender_name` gespeichert): + +```json +{ + "name": "iCloud Stefan", + "authelia_user": "stefan", + "chat_sender_name": "Stefan Koelle" +} +``` + +Der Name wird umlaut-normalisiert verglichen: "Koelle" und "Kölle" +werden als identisch erkannt. + ## 📄 License Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) diff --git a/SPEC.md b/SPEC.md index 8f234f4..f8d9d1c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -39,7 +39,7 @@ außerhalb des Apple-Ökosystems. ```json { "accounts": [ - { "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123" }, + { "name": "markus", "apple_email": "markus@icloud.com", "apple_app_password": "xxxx-xxxx-xxxx-xxxx", "authelia_user": "mmustermann", "birthday_mail_to": "markus@example.de", "healthcheck_url": "https://healthchecks.example.de/ping/abc123", "chat_sender_name": "Markus Mustermann" }, { "name": "partner", "apple_email": "partner@icloud.com", "apple_app_password": "yyyy-yyyy-yyyy-yyyy", "authelia_user": "pmustermann", "birthday_mail_to": "partner@example.de" } ], "admins": ["mmustermann"] @@ -152,6 +152,9 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1: | AUTH_REMOTE_USER_HEADER | nein | Default: Remote-User, Header-Name für Authelia-User | | API_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services | | API_PORT | nein | Default: 8000, Port des API-Services | +| CHATAPI_ENABLED | nein | Default: false, aktiviert Chat-Archive Integration | +| CHATAPI_URL | nein | Basis-URL der Chat-Archive API | +| CHATAPI_KEY | nein | API-Key für Chat-Archive Authentifizierung | Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account in `accounts.json` (keine globale Umgebungsvariable mehr nötig). @@ -279,6 +282,7 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. | `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) | | `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) | | `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | +| `GET /api/contacts/{id}/messages` | Chat-Nachrichten via Chat-Archive API (optional, `?offset=0&limit=50`) | ### 12.5 Netzwerkkontext @@ -287,3 +291,27 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. - Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf `127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt. + +### 12.6 Chat-Archive Integration (optional) + +- Feature-Flag `CHATAPI_ENABLED` (Default: `false`). +- Bei aktivierter Integration zeigt die Kontakt-Detailseite + (`/contacts/{id}`) Chat-Nachrichten des Kontakts aus einer externen + [Chat-Archive API](https://github.com/stefan-koelle/chat-archive). +- Der API-Container agiert als Proxy: der Browser ruft + `GET /api/contacts/{id}/messages` auf, der Server liest den + `full_name` des Kontakts aus der DB und leitet die Anfrage an + `CHATAPI_URL/conversation?contact_names={full_name}&order=desc` weiter. +- Der API-Key wird serverseitig aus `CHATAPI_KEY` gelesen, der Browser + erhält nie Zugriff auf das Geheimnis. +- **Namens-Matching**: Das optionale Feld `chat_sender_name` pro Account + in `accounts.json` gibt den Namen an, der als eigene Nachricht + erkannt wird (z.B. "Stefan Koelle"). Der Vergleich erfolgt + umlaut-normalisiert: "Koelle" und "Kölle" werden als identisch + erkannt. +- **Infinite Scroll**: Das Frontend lädt initial 50 Nachrichten + (neueste zuerst) und lädt bei Bedarf weitere Batches nach, indem + ein Intersection Observer den `offset`-Parameter erhöht. +- **Darstellung**: Chat-Bubbles im Messenger-Style, eigene Nachrichten + rechts (blau), Kontaktnachrichten links (grau). Plattform-Badge + (Instagram/Facebook) und Zeitstempel werden angezeigt. diff --git a/src/api/main.py b/src/api/main.py index 83bec91..a2ed712 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -13,8 +13,9 @@ import secrets from datetime import datetime from urllib.parse import quote_plus +import requests from fastapi import Depends, FastAPI, Query, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.middleware.sessions import SessionMiddleware @@ -211,6 +212,51 @@ def contact_count(current_user: str = Depends(get_current_user)): return {"total": total} +@app.get("/api/contacts/{contact_id}/messages") +def get_contact_messages( + contact_id: int, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + current_user: str = Depends(get_current_user), +): + if not Config.CHATAPI_ENABLED: + return JSONResponse(status_code=404, content={"detail": "Chat-Archive nicht aktiviert"}) + + with db.get_connection() as conn: + where_clause, params = _account_filter_clause( + resolve_account_for_user(current_user)[0] + ) + id_clause = "AND id = %s" if where_clause else "WHERE id = %s" + with conn.cursor() as cur: + cur.execute( + f"SELECT full_name FROM contacts {where_clause} {id_clause}", + params + [contact_id], + ) + row = cur.fetchone() + + if not row or not row.get("full_name"): + return JSONResponse(status_code=404, content={"detail": "Kontakt nicht gefunden"}) + + try: + resp = requests.get( + f"{Config.CHATAPI_URL.rstrip('/')}/conversation", + params={ + "contact_names": [row["full_name"]], + "order": "desc", + "offset": offset, + "limit": limit, + }, + headers={"X-API-Key": Config.CHATAPI_KEY}, + timeout=10, + ) + resp.raise_for_status() + except requests.RequestException as e: + logger.warning("Chat-Archive API Fehler: %s", e) + return JSONResponse(status_code=502, content={"detail": "Chat-Archive nicht erreichbar"}) + + return resp.json() + + @app.get("/api/sync-runs", response_model=list[SyncRunOut]) def list_sync_runs(current_user: str = Depends(get_current_user)): account_name, is_admin = resolve_account_for_user(current_user) @@ -677,12 +723,14 @@ def web_contact( workcity = city custom_links = [] + chat_sender_name = "" contact_account = contact.get("account") if contact_account: accounts = Config.load_accounts() for acc in accounts: if acc.name == contact_account: custom_links = acc.custom_links + chat_sender_name = acc.chat_sender_name break resolved_links = [] @@ -708,5 +756,8 @@ def web_contact( "groups": groups, "search": search or "", "custom_links": resolved_links, + "chat_enabled": Config.CHATAPI_ENABLED, + "chat_sender_name": chat_sender_name, + "contact_id": contact_id, }, ) diff --git a/src/api/templates/contact.html b/src/api/templates/contact.html index 146d18a..6d255e9 100644 --- a/src/api/templates/contact.html +++ b/src/api/templates/contact.html @@ -320,6 +320,114 @@ display: none; } } + + .chat-section { + margin-top: 1.5rem; + border-top: 1px solid #eee; + padding-top: 1.5rem; + } + + .chat-messages { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 500px; + overflow-y: auto; + padding: 0.5rem 0; + } + + .chat-msg { + display: flex; + flex-direction: column; + max-width: 75%; + } + + .chat-msg.own { + align-self: flex-end; + align-items: flex-end; + } + + .chat-msg.other { + align-self: flex-start; + align-items: flex-start; + } + + .chat-msg-sender { + font-size: 0.65rem; + color: #888; + margin-bottom: 0.15rem; + padding: 0 0.5rem; + } + + .chat-msg-bubble { + padding: 0.5rem 0.75rem; + border-radius: 12px; + font-size: 0.85rem; + line-height: 1.4; + word-break: break-word; + } + + .chat-msg.own .chat-msg-bubble { + background: #0066cc; + color: #fff; + border-bottom-right-radius: 4px; + } + + .chat-msg.other .chat-msg-bubble { + background: #e9ecef; + color: #333; + border-bottom-left-radius: 4px; + } + + .chat-msg-meta { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.2rem; + padding: 0 0.5rem; + } + + .chat-msg-time { + font-size: 0.65rem; + color: #999; + } + + .chat-msg-platform { + font-size: 0.55rem; + padding: 0.05rem 0.3rem; + border-radius: 3px; + background: #f0f0f0; + color: #888; + text-transform: uppercase; + } + + .chat-msg-type { + font-size: 0.65rem; + color: #999; + font-style: italic; + } + + .chat-loading { + text-align: center; + padding: 1rem; + color: #888; + font-size: 0.85rem; + } + + .chat-empty { + text-align: center; + padding: 1.5rem; + color: #888; + font-style: italic; + font-size: 0.85rem; + } + + .chat-end { + text-align: center; + padding: 0.5rem; + color: #aaa; + font-size: 0.75rem; + }
@@ -480,6 +588,16 @@