Compare commits

...
6 Commits
Author SHA1 Message Date
stefankoelle e6a7d19948 feat: auto-link URLs in chat messages 2026-09-17 19:53:28 +02:00
stefankoelle 0594a86894 feat: show reactions on chat messages, fix infinite scroll
- Add reaction display (emoji + user) below each message bubble
- Move sentinel/loading/end elements inside scrollable chat container
- Use chatBox as IntersectionObserver root for proper scroll detection
- Increase chat container max-height to 600px
2026-09-17 19:51:45 +02:00
stefankoelle 43ce0bcfe1 fix: build full_name from parts in chat messages endpoint
- SELECT prefix, given_name, middle_name, family_name, suffix alongside full_name
- Apply db._build_full_name() fallback when full_name is NULL
- Document the pattern in AGENTS.md to prevent future regressions
2026-09-17 19:41:38 +02:00
stefankoelle 113527a4a0 fix: generic Chat-Archive URL placeholder, add CHATAPI env vars to docker-compose 2026-09-17 19:05:20 +02:00
stefankoelle 5f39d276d6 feat: Chat-Archive integration on contact detail page
- Add proxy endpoint GET /api/contacts/{id}/messages (forwards to Chat-Archive API)
- Add chat_sender_name field to Account config for sender matching
- Add Infinite Scroll chat section with Messenger-style bubbles
- Umlaut-normalized name comparison (ae/oe/ue/ss)
- Add CHATAPI_ENABLED/URL/KEY environment variables
- Update README.md, SPEC.md, AGENTS.md
2026-09-17 19:01:42 +02:00
stefankoelle 7e2b29a950 birthday mail fullname when only 1 contact 2026-08-22 10:09:51 +02:00
9 changed files with 439 additions and 7 deletions
+5
View File
@@ -29,3 +29,8 @@ API_PORT=8000
# --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) --- # --- Web-URL (optional, wird in Geburtstags-Mails verlinkt) ---
WEB_URL=https://kontakte.example.de WEB_URL=https://kontakte.example.de
# --- Chat-Archive API (optional, zeigt Chat-Nachrichten in der Kontakt-Detailseite) ---
CHATAPI_ENABLED=false
CHATAPI_URL=http://chat-archive-host:8420
CHATAPI_KEY=change-me
+14
View File
@@ -122,6 +122,7 @@ See `.env.example` for full list. Key variables:
- `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`) - `AUTH_REMOTE_USER_HEADER` — Authelia header name (default: `Remote-User`)
- `MAILER_ENABLED` — Feature flag for birthday mailer - `MAILER_ENABLED` — Feature flag for birthday mailer
- `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email - `MAIL_SEND_HOUR` — Hour (0-23) for daily birthday email
- `CHATAPI_ENABLED` / `CHATAPI_URL` / `CHATAPI_KEY` — Chat-Archive integration (optional)
## Architecture Notes ## Architecture Notes
@@ -144,6 +145,19 @@ See `.env.example` for full list. Key variables:
4. Update endpoint table in `SPEC.md` and `README.md` 4. Update endpoint table in `SPEC.md` and `README.md`
5. Test with: `curl -H "Remote-User: <user>" http://127.0.0.1:8000/<path>` 5. Test with: `curl -H "Remote-User: <user>" http://127.0.0.1:8000/<path>`
### Querying contact names from the DB
**IMPORTANT:** The `full_name` column in `contacts` can be NULL — it is
NOT guaranteed to be set. Always SELECT `prefix, given_name, middle_name,
family_name, suffix` alongside `full_name` and apply the fallback:
```python
if not row.get("full_name"):
row["full_name"] = db._build_full_name(row)
```
This is what `_row_to_contact_out()` does for API responses. Any code
that needs a contact's display name (e.g. proxying to external APIs)
must follow the same pattern. See `get_contact_messages()` in
`src/api/main.py` for a reference implementation.
### Adding a new contact field ### Adding a new contact field
1. Add column to `contacts` table in `sql/schema.sql` 1. Add column to `contacts` table in `sql/schema.sql`
2. Update `src/vcard_parser.py` to extract the field 2. Update `src/vcard_parser.py` to extract the field
+43 -1
View File
@@ -40,7 +40,9 @@ Trage für jede Apple-ID einen Eintrag mit eindeutigem `name`,
`apple_email`, `apple_app_password`, `authelia_user` und (optional) `apple_email`, `apple_app_password`, `authelia_user` und (optional)
`birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url` `birthday_mail_to` ein. Optional kann pro Account eine `healthcheck_url`
konfiguriert werden, die nach jedem erfolgreichen Sync aufgerufen wird 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 bleibt lokal auf dem Host, sie ist in `.gitignore` ausgeschlossen und
wird nur als Volume in den Container gemountet. 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/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) |
| `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | | `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 Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung
über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres über den `Remote-User`-Header. Nicht-Admins sehen nur die Daten ihres
eigenen Accounts. eigenen Accounts.
@@ -304,6 +312,40 @@ eigenen Accounts.
curl -H "Remote-User: mmustermann" http://127.0.0.1:8000/api/contacts 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://chat-archive-host: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 ## 📄 License
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+29 -1
View File
@@ -39,7 +39,7 @@ außerhalb des Apple-Ökosystems.
```json ```json
{ {
"accounts": [ "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" } { "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"] "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 | | 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_HOST | nein | Default: 0.0.0.0, Bindungs-Adresse des API-Services |
| API_PORT | nein | Default: 8000, Port 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 Empfänger-Adresse für Geburtstags-Mails: `birthday_mail_to` pro Account
in `accounts.json` (keine globale Umgebungsvariable mehr nötig). 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}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) |
| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) | | `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/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 ### 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 - Externer Zugriff läuft über deinen bestehenden Reverse-Proxy mit
Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf Authelia im internen Netzwerk (`deinem lokalen Netz`), der intern auf
`127.0.0.1:8000` weiterleitet und den `Remote-User`-Header setzt. `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.
+3
View File
@@ -58,6 +58,9 @@ services:
MAIL_FROM: "${MAIL_FROM}" MAIL_FROM: "${MAIL_FROM}"
TIMEZONE: "${TIMEZONE:-Europe/Berlin}" TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
WEB_URL: "${WEB_URL:-}" WEB_URL: "${WEB_URL:-}"
CHATAPI_ENABLED: "${CHATAPI_ENABLED:-false}"
CHATAPI_URL: "${CHATAPI_URL:-}"
CHATAPI_KEY: "${CHATAPI_KEY:-}"
volumes: volumes:
- ./config/accounts.json:/app/config/accounts.json:ro - ./config/accounts.json:/app/config/accounts.json:ro
ports: ports:
+59 -1
View File
@@ -13,8 +13,9 @@ import secrets
from datetime import datetime from datetime import datetime
from urllib.parse import quote_plus from urllib.parse import quote_plus
import requests
from fastapi import Depends, FastAPI, Query, Request 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -211,6 +212,58 @@ def contact_count(current_user: str = Depends(get_current_user)):
return {"total": total} 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, prefix, given_name, middle_name, family_name, suffix
FROM contacts {where_clause} {id_clause}""",
params + [contact_id],
)
row = cur.fetchone()
if not row:
return JSONResponse(status_code=404, content={"detail": "Kontakt nicht gefunden"})
if not row.get("full_name"):
row["full_name"] = db._build_full_name(row)
if not row.get("full_name"):
return JSONResponse(status_code=404, content={"detail": "Kontakt hat keinen Namen"})
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]) @app.get("/api/sync-runs", response_model=list[SyncRunOut])
def list_sync_runs(current_user: str = Depends(get_current_user)): def list_sync_runs(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user) account_name, is_admin = resolve_account_for_user(current_user)
@@ -677,12 +730,14 @@ def web_contact(
workcity = city workcity = city
custom_links = [] custom_links = []
chat_sender_name = ""
contact_account = contact.get("account") contact_account = contact.get("account")
if contact_account: if contact_account:
accounts = Config.load_accounts() accounts = Config.load_accounts()
for acc in accounts: for acc in accounts:
if acc.name == contact_account: if acc.name == contact_account:
custom_links = acc.custom_links custom_links = acc.custom_links
chat_sender_name = acc.chat_sender_name
break break
resolved_links = [] resolved_links = []
@@ -708,5 +763,8 @@ def web_contact(
"groups": groups, "groups": groups,
"search": search or "", "search": search or "",
"custom_links": resolved_links, "custom_links": resolved_links,
"chat_enabled": Config.CHATAPI_ENABLED,
"chat_sender_name": chat_sender_name,
"contact_id": contact_id,
}, },
) )
+273
View File
@@ -320,6 +320,146 @@
display: none; 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: 600px;
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-bubble a {
color: inherit;
text-decoration: underline;
}
.chat-msg.own .chat-msg-bubble a {
color: #fff;
}
.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;
}
.chat-reactions {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-top: 0.25rem;
padding: 0 0.5rem;
}
.chat-reaction {
display: inline-flex;
align-items: center;
gap: 0.15rem;
padding: 0.1rem 0.35rem;
background: rgba(0,0,0,0.05);
border-radius: 10px;
font-size: 0.65rem;
color: #555;
}
.chat-reaction-emoji {
font-size: 0.75rem;
}
</style> </style>
</head> </head>
<body> <body>
@@ -480,6 +620,17 @@
<div class="notes-content">{{ contact.notes }}</div> <div class="notes-content">{{ contact.notes }}</div>
</div> </div>
{% endif %} {% endif %}
{% if chat_enabled %}
<div class="chat-section">
<div class="section-title">Nachrichten</div>
<div id="chat-messages" class="chat-messages">
<div id="chat-loading" class="chat-loading" style="display:none">Lade Nachrichten…</div>
<div id="chat-end" class="chat-end" style="display:none"></div>
<div id="chat-sentinel" style="height:1px"></div>
</div>
</div>
{% endif %}
</div> </div>
{% if custom_links %} {% if custom_links %}
@@ -501,5 +652,127 @@
<img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()"> <img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()">
</dialog> </dialog>
{% endif %} {% endif %}
{% if chat_enabled %}
<script>
(function() {
const contactId = {{ contact_id }};
const ownName = {{ chat_sender_name|tojson }};
const chatBox = document.getElementById("chat-messages");
const loading = document.getElementById("chat-loading");
const endEl = document.getElementById("chat-end");
const sentinel = document.getElementById("chat-sentinel");
let offset = 0;
const batchSize = 50;
let total = null;
let loading_ = false;
function fmtTime(ms) {
const d = new Date(ms);
const pad = n => String(n).padStart(2, "0");
return pad(d.getDate()) + "." + pad(d.getMonth()+1) + "." + d.getFullYear() +
" " + pad(d.getHours()) + ":" + pad(d.getMinutes());
}
function norm(s) {
return s.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue")
.replace(/ß/g, "ss").replace(/é/g, "e").replace(/è/g, "e")
.replace(/ê/g, "e").replace(/ë/g, "e").replace(/á/g, "a")
.replace(/à/g, "a").replace(/â/g, "a").replace(/í/g, "i")
.replace(/ì/g, "i").replace(/î/g, "i").replace(/ó/g, "o")
.replace(/ò/g, "o").replace(/ô/g, "o").replace(/ú/g, "u")
.replace(/ù/g, "u").replace(/û/g, "u");
}
const ownNorm = norm(ownName);
function linkify(text) {
return text.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
}
function renderMsg(m) {
const isOwn = norm(m.sender_name) === ownNorm;
const div = document.createElement("div");
div.className = "chat-msg " + (isOwn ? "own" : "other");
let html = "";
if (!isOwn && total !== null && total > batchSize) {
html += '<div class="chat-msg-sender">' + escHtml(m.sender_name) + '</div>';
}
const text = m.content ? linkify(escHtml(m.content)) : '<span class="chat-msg-type">[' + m.message_type + ']</span>';
html += '<div class="chat-msg-bubble">' + text + '</div>';
if (m.reactions && m.reactions.length > 0) {
html += '<div class="chat-reactions">';
for (const r of m.reactions) {
html += '<span class="chat-reaction"><span class="chat-reaction-emoji">' + escHtml(r.reaction || r.emoji || "") + '</span>' + (r.user ? " " + escHtml(r.user) : "") + '</span>';
}
html += '</div>';
}
html += '<div class="chat-msg-meta">';
html += '<span class="chat-msg-time">' + fmtTime(m.timestamp_ms) + '</span>';
if (m.platform) {
html += '<span class="chat-msg-platform">' + escHtml(m.platform) + '</span>';
}
html += '</div>';
div.innerHTML = html;
return div;
}
function escHtml(s) {
const el = document.createElement("span");
el.textContent = s;
return el.innerHTML;
}
async function loadMore() {
if (loading_) return;
loading_ = true;
loading.style.display = "";
try {
const resp = await fetch("/api/contacts/" + contactId + "/messages?offset=" + offset + "&limit=" + batchSize);
if (!resp.ok) {
loading.style.display = "none";
loading_ = false;
return;
}
const data = await resp.json();
if (total === null) total = data.total;
loading.style.display = "none";
if (!data.messages || data.messages.length === 0) {
if (total === 0) {
const empty = document.createElement("div");
empty.className = "chat-empty";
empty.textContent = "Keine Nachrichten gefunden";
chatBox.insertBefore(empty, sentinel);
} else {
endEl.textContent = "Alle Nachrichten geladen";
endEl.style.display = "";
}
sentinel.style.display = "none";
return;
}
for (const m of data.messages) {
chatBox.insertBefore(renderMsg(m), sentinel);
}
offset += data.messages.length;
if (offset >= total) {
endEl.textContent = "Alle " + total + " Nachrichten geladen";
endEl.style.display = "";
sentinel.style.display = "none";
}
} catch(e) {
loading.style.display = "none";
}
loading_ = false;
}
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && !loading_) loadMore();
}, { root: chatBox, rootMargin: "200px" });
observer.observe(sentinel);
})();
</script>
{% endif %}
</body> </body>
</html> </html>
+8 -2
View File
@@ -14,7 +14,7 @@ ICLOUD_BASE_URL = "https://contacts.icloud.com/"
class Account: class Account:
def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None, def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None,
custom_links: list[dict] | None = None, healthcheck_url: str = "", custom_links: list[dict] | None = None, healthcheck_url: str = "",
birthday_mail_to: str | None = None): birthday_mail_to: str | None = None, chat_sender_name: str = ""):
self.name = name self.name = name
self.apple_email = apple_email self.apple_email = apple_email
self.apple_app_password = apple_app_password self.apple_app_password = apple_app_password
@@ -22,6 +22,7 @@ class Account:
self.custom_links = custom_links or [] self.custom_links = custom_links or []
self.healthcheck_url = healthcheck_url self.healthcheck_url = healthcheck_url
self.birthday_mail_to = birthday_mail_to self.birthday_mail_to = birthday_mail_to
self.chat_sender_name = chat_sender_name
class Config: class Config:
@@ -50,6 +51,10 @@ class Config:
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird. # vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User") AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User")
CHATAPI_ENABLED = os.environ.get("CHATAPI_ENABLED", "false").lower() == "true"
CHATAPI_URL = os.environ.get("CHATAPI_URL", "")
CHATAPI_KEY = os.environ.get("CHATAPI_KEY", "")
API_HOST = os.environ.get("API_HOST", "0.0.0.0") API_HOST = os.environ.get("API_HOST", "0.0.0.0")
API_PORT = int(os.environ.get("API_PORT", "8000")) API_PORT = int(os.environ.get("API_PORT", "8000"))
WEB_URL = os.environ.get("WEB_URL", "") WEB_URL = os.environ.get("WEB_URL", "")
@@ -104,7 +109,8 @@ class Config:
custom_links = entry.get("custom_links", []) custom_links = entry.get("custom_links", [])
healthcheck_url = entry.get("healthcheck_url", "") healthcheck_url = entry.get("healthcheck_url", "")
birthday_mail_to = entry.get("birthday_mail_to") or None birthday_mail_to = entry.get("birthday_mail_to") or None
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to)) chat_sender_name = entry.get("chat_sender_name", "")
accounts.append(Account(name, email, pwd, authelia_user, custom_links, healthcheck_url, birthday_mail_to, chat_sender_name))
return accounts return accounts
@classmethod @classmethod
+4 -1
View File
@@ -69,6 +69,9 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.") msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.")
return msg return msg
if len(birthdays) == 1:
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {birthdays[0]['full_name']}"
else:
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}" msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""] plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
@@ -154,7 +157,7 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
<tr> <tr>
<td style="padding:16px;"> <td style="padding:16px;">
<h1 style="font-size:20px;font-weight:600;color:#222;margin:0;">Geburtstage heute</h1> <h1 style="font-size:20px;font-weight:600;color:#222;margin:0;">Geburtstage heute</h1>
<p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakte</p> <p style="font-size:13px;color:#666;margin:8px 0 16px 0;">{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakt{"e" if len(birthdays) != 1 else ""}</p>
<table width="100%" cellpadding="0" cellspacing="0"> <table width="100%" cellpadding="0" cellspacing="0">
{cards_html} {cards_html}
</table> </table>