mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-18 07:50:25 +00:00
Compare commits
6
Commits
f307f103cf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a7d19948 | ||
|
|
0594a86894 | ||
|
|
43ce0bcfe1 | ||
|
|
113527a4a0 | ||
|
|
5f39d276d6 | ||
|
|
7e2b29a950 |
@@ -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://chat-archive-host:8420
|
||||
CHATAPI_KEY=change-me
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -144,6 +145,19 @@ See `.env.example` for full list. Key variables:
|
||||
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>`
|
||||
|
||||
### 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
|
||||
1. Add column to `contacts` table in `sql/schema.sql`
|
||||
2. Update `src/vcard_parser.py` to extract the field
|
||||
|
||||
@@ -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://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
|
||||
|
||||
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -58,6 +58,9 @@ services:
|
||||
MAIL_FROM: "${MAIL_FROM}"
|
||||
TIMEZONE: "${TIMEZONE:-Europe/Berlin}"
|
||||
WEB_URL: "${WEB_URL:-}"
|
||||
CHATAPI_ENABLED: "${CHATAPI_ENABLED:-false}"
|
||||
CHATAPI_URL: "${CHATAPI_URL:-}"
|
||||
CHATAPI_KEY: "${CHATAPI_KEY:-}"
|
||||
volumes:
|
||||
- ./config/accounts.json:/app/config/accounts.json:ro
|
||||
ports:
|
||||
|
||||
+59
-1
@@ -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,58 @@ 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, 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])
|
||||
def list_sync_runs(current_user: str = Depends(get_current_user)):
|
||||
account_name, is_admin = resolve_account_for_user(current_user)
|
||||
@@ -677,12 +730,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 +763,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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -320,6 +320,146 @@
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -480,6 +620,17 @@
|
||||
<div class="notes-content">{{ contact.notes }}</div>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
{% if custom_links %}
|
||||
@@ -501,5 +652,127 @@
|
||||
<img src="{{ contact.photo_url }}" alt="Foto" onerror="this.parentElement.close()">
|
||||
</dialog>
|
||||
{% 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>
|
||||
</html>
|
||||
|
||||
+8
-2
@@ -14,7 +14,7 @@ ICLOUD_BASE_URL = "https://contacts.icloud.com/"
|
||||
class Account:
|
||||
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 = "",
|
||||
birthday_mail_to: str | None = None):
|
||||
birthday_mail_to: str | None = None, chat_sender_name: str = ""):
|
||||
self.name = name
|
||||
self.apple_email = apple_email
|
||||
self.apple_app_password = apple_app_password
|
||||
@@ -22,6 +22,7 @@ class Account:
|
||||
self.custom_links = custom_links or []
|
||||
self.healthcheck_url = healthcheck_url
|
||||
self.birthday_mail_to = birthday_mail_to
|
||||
self.chat_sender_name = chat_sender_name
|
||||
|
||||
|
||||
class Config:
|
||||
@@ -50,6 +51,10 @@ class Config:
|
||||
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
|
||||
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_PORT = int(os.environ.get("API_PORT", "8000"))
|
||||
WEB_URL = os.environ.get("WEB_URL", "")
|
||||
@@ -104,7 +109,8 @@ class Config:
|
||||
custom_links = entry.get("custom_links", [])
|
||||
healthcheck_url = entry.get("healthcheck_url", "")
|
||||
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
|
||||
|
||||
@classmethod
|
||||
|
||||
+5
-2
@@ -69,7 +69,10 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
||||
msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.")
|
||||
return msg
|
||||
|
||||
msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}"
|
||||
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)}"
|
||||
|
||||
plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
|
||||
for b in birthdays:
|
||||
@@ -154,7 +157,7 @@ def build_message(account_name: str, birthdays: list[dict], target_date: date |
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<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">
|
||||
{cards_html}
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user