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
This commit is contained in:
2026-09-17 19:01:42 +02:00
parent 7e2b29a950
commit 5f39d276d6
7 changed files with 364 additions and 5 deletions
+52 -1
View File
@@ -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,
},
)
+226
View File
@@ -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;
}
</style>
</head>
<body>
@@ -480,6 +588,16 @@
<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>
<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>
{% endif %}
</div>
{% if custom_links %}
@@ -501,5 +619,113 @@
<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 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 ? escHtml(m.content) : '<span class="chat-msg-type">[' + m.message_type + ']</span>';
html += '<div class="chat-msg-bubble">' + text + '</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) {
chatBox.innerHTML = '<div class="chat-empty">Keine Nachrichten gefunden</div>';
} else {
endEl.textContent = "Alle Nachrichten geladen";
endEl.style.display = "";
}
sentinel.style.display = "none";
return;
}
for (const m of data.messages) {
chatBox.appendChild(renderMsg(m));
}
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();
}, { rootMargin: "200px" });
observer.observe(sentinel);
})();
</script>
{% endif %}
</body>
</html>
+8 -2
View File
@@ -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