From ddf29c1d36b15a99db02b3709f157526725e4bc6 Mon Sep 17 00:00:00 2001 From: Andre Beging Date: Tue, 7 Oct 2025 15:08:33 +0200 Subject: [PATCH] feat: Implement recent chats feature with API endpoint and UI integration --- app/main.py | 9 ++ app/models.py | 9 ++ app/static/app.js | 310 ++++++++++++++++++++++++++++++++++++++- app/static/style.css | 187 +++++++++++++++++++++++ app/telegram_service.py | 63 +++++++- app/templates/index.html | 47 ++++++ tests/test_app.py | 60 ++++++++ 7 files changed, 682 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 2206dd9..28dbe67 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ from .models import ( LoginStartRequest, LoginVerifyRequest, MessageTriggerResponse, + RecentChat, StatusResponse, ) from .storage import ( @@ -136,6 +137,14 @@ async def list_hooks() -> List[HookResponse]: ] +@app.get("/api/recent-chats", response_model=List[RecentChat]) +async def recent_chats() -> List[RecentChat]: + if not await telegram_service.is_authorized(): + raise HTTPException(status_code=401, detail="Session not authorized") + chats = await telegram_service.fetch_recent_chats() + return chats + + @app.post("/api/hooks", response_model=HookResponse, status_code=201) async def create_hook(payload: HookCreate) -> HookResponse: hook = await create_hook_async(payload) diff --git a/app/models.py b/app/models.py index 6d187ce..738860f 100644 --- a/app/models.py +++ b/app/models.py @@ -103,3 +103,12 @@ class StatusResponse(BaseModel): session_active: bool phone_number: Optional[str] code_sent: bool = False + + +class RecentChat(BaseModel): + chat_id: str + display_name: str + chat_type: str + username: Optional[str] = None + phone_number: Optional[str] = None + last_used_at: Optional[datetime] = None diff --git a/app/static/app.js b/app/static/app.js index b4c0db6..61da78a 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -12,11 +12,32 @@ const hooksListEl = document.querySelector("#hooks-list"); const hookTemplate = document.querySelector("#hook-template"); const createHookForm = document.querySelector("#create-hook-form"); const toggleCreateBtn = document.querySelector("#toggle-create"); +const recentChatsBtn = document.querySelector("#recent-chats-btn"); +const recentChatsModal = document.querySelector("#recent-chats-modal"); +const recentChatsListEl = document.querySelector("#recent-chats-list"); +const closeRecentChatsBtn = document.querySelector("#close-recent-chats"); +const recentChatsSearchEl = document.querySelector("#recent-chats-search"); let sessionDetailsVisible = false; let sessionDetailsTouched = false; let createFormVisible = false; let createFormTouched = false; +let lastFocusedElement = null; +let recentChatsData = []; + +if (recentChatsSearchEl) { + recentChatsSearchEl.disabled = true; + recentChatsSearchEl.addEventListener("input", () => { + applyRecentChatsFilter(); + }); + recentChatsSearchEl.addEventListener("keydown", (event) => { + if (event.key === "Escape" && recentChatsSearchEl.value) { + event.preventDefault(); + recentChatsSearchEl.value = ""; + applyRecentChatsFilter(); + } + }); +} function setSessionDetailsVisibility(show, { fromUser = false } = {}) { if (!toggleSessionBtn || !sessionDetailsEl) return; @@ -66,10 +87,29 @@ async function fetchJSON(url, options = {}) { ...options, }); if (!response.ok) { - const message = await response.text(); + const contentType = response.headers.get("content-type") || ""; + let message = "Request failed"; + if (contentType.includes("application/json")) { + try { + const payload = await response.json(); + if (payload && typeof payload === "object") { + message = payload.detail || JSON.stringify(payload); + } + } catch { + message = await response.text(); + } + } else { + const text = await response.text(); + if (text) { + message = text; + } + } throw new Error(message || "Request failed"); } - return response.status === 204 ? null : response.json(); + if (response.status === 204) { + return null; + } + return response.json(); } function updateSessionUI(status) { @@ -116,6 +156,10 @@ function updateSessionUI(status) { } else if (shouldShowDetails && !sessionDetailsVisible) { setSessionDetailsVisibility(true); } + setRecentChatsAvailability(status.authorized); + if (!status.authorized) { + closeRecentChatsDialog(); + } } async function refreshAll() { @@ -128,6 +172,268 @@ async function refreshAll() { await loadHooks(); } +function setRecentChatsAvailability(isAuthorized) { + if (!recentChatsBtn) return; + recentChatsBtn.disabled = !isAuthorized; + recentChatsBtn.title = isAuthorized + ? "Browse your recent Telegram chats" + : "Authorize the session to view recent chats"; + if (!isAuthorized && recentChatsSearchEl) { + recentChatsSearchEl.value = ""; + recentChatsSearchEl.disabled = true; + } +} + +function applyRecentChatsFilter() { + if (!recentChatsListEl) return; + const query = recentChatsSearchEl ? recentChatsSearchEl.value.trim().toLowerCase() : ""; + const source = Array.isArray(recentChatsData) ? recentChatsData : []; + const filtered = !query + ? source + : source.filter((chat) => { + const parts = [ + chat.display_name, + chat.chat_id, + chat.username ? `@${chat.username}` : null, + chat.phone_number, + chat.chat_type, + ] + .filter(Boolean) + .map((part) => String(part).toLowerCase()); + return parts.some((part) => part.includes(query)); + }); + renderRecentChats(filtered); +} + +async function openRecentChatsDialog() { + if (!recentChatsModal || !recentChatsListEl) return; + if (recentChatsModal.classList.contains("hidden") && document.activeElement instanceof HTMLElement) { + lastFocusedElement = document.activeElement; + } + recentChatsModal.classList.remove("hidden"); + recentChatsModal.scrollTop = 0; + recentChatsListEl.innerHTML = ""; + recentChatsData = []; + if (recentChatsSearchEl) { + recentChatsSearchEl.value = ""; + recentChatsSearchEl.disabled = true; + } + const loading = document.createElement("p"); + loading.className = "feedback"; + loading.textContent = "Loading recent chats…"; + recentChatsListEl.appendChild(loading); + try { + const chats = await fetchJSON("/api/recent-chats"); + recentChatsData = Array.isArray(chats) ? chats : []; + if (recentChatsSearchEl) { + recentChatsSearchEl.disabled = false; + recentChatsSearchEl.focus(); + } + applyRecentChatsFilter(); + } catch (error) { + recentChatsListEl.innerHTML = ""; + const message = document.createElement("p"); + message.className = "feedback"; + const text = typeof error?.message === "string" ? error.message : "Unable to load recent chats."; + message.textContent = text.includes("Session not authorized") + ? "Authorize the session to view recent chats." + : `Unable to load recent chats: ${text}`; + recentChatsListEl.appendChild(message); + recentChatsData = []; + if (recentChatsSearchEl) { + recentChatsSearchEl.disabled = true; + } + } + document.addEventListener("keydown", handleRecentChatsKeydown); + if (recentChatsModal && (!recentChatsSearchEl || recentChatsSearchEl.disabled)) { + const focusTarget = recentChatsModal.querySelector("button, [href], input, textarea, [tabindex]:not([tabindex='-1'])"); + if (focusTarget instanceof HTMLElement) { + focusTarget.focus(); + } + } +} + +function renderRecentChats(chats) { + if (!recentChatsListEl) return; + recentChatsListEl.innerHTML = ""; + if (!Array.isArray(chats) || !chats.length) { + const empty = document.createElement("p"); + empty.className = "feedback"; + empty.textContent = "No recent chats available. Start a conversation in Telegram to see it here."; + recentChatsListEl.appendChild(empty); + return; + } + + chats.forEach((chat) => { + const item = document.createElement("div"); + item.className = "recent-chat-item"; + + const details = document.createElement("div"); + details.className = "recent-chat-details"; + + const nameEl = document.createElement("span"); + nameEl.className = "recent-chat-name"; + nameEl.textContent = chat.display_name || chat.chat_id; + + const metaEl = document.createElement("span"); + metaEl.className = "recent-chat-meta"; + const metaParts = []; + if (chat.chat_type) { + metaParts.push(String(chat.chat_type).toUpperCase()); + } + if (chat.last_used_at) { + metaParts.push(`Last activity ${new Date(chat.last_used_at).toLocaleString()}`); + } + metaEl.textContent = metaParts.length ? metaParts.join(" • ") : "Unknown chat"; + + const feedbackEl = document.createElement("span"); + feedbackEl.className = "recent-chat-feedback"; + let feedbackTimeout; + + const setFeedback = (message, isError = false) => { + feedbackEl.textContent = message; + feedbackEl.style.color = isError ? "#ffbac7" : message ? "#64dd9b" : ""; + if (feedbackTimeout) { + clearTimeout(feedbackTimeout); + feedbackTimeout = undefined; + } + if (message) { + feedbackTimeout = window.setTimeout(() => { + feedbackEl.textContent = ""; + feedbackEl.style.color = ""; + feedbackTimeout = undefined; + }, 2400); + } + }; + + details.appendChild(nameEl); + details.appendChild(metaEl); + details.appendChild(feedbackEl); + item.appendChild(details); + + const appendRow = (labelText, displayValue, copyValue, options = {}) => { + const { required = false, valueClasses = [] } = options; + const rawValue = displayValue ?? ""; + const valueString = rawValue !== null && rawValue !== undefined ? String(rawValue) : ""; + const hasValue = valueString.trim() !== ""; + if (!required && !hasValue) { + return; + } + + const row = document.createElement("div"); + row.className = "recent-chat-row"; + + const labelEl = document.createElement("span"); + labelEl.className = "recent-chat-label"; + labelEl.textContent = labelText; + + const actionsEl = document.createElement("div"); + actionsEl.className = "recent-chat-actions"; + + const valueButton = document.createElement("button"); + valueButton.type = "button"; + valueButton.className = "recent-chat-value-button"; + if (Array.isArray(valueClasses)) { + valueClasses.forEach((cls) => valueButton.classList.add(cls)); + } else if (typeof valueClasses === "string" && valueClasses) { + valueButton.classList.add(valueClasses); + } + valueButton.setAttribute("aria-label", `Copy ${labelText.toLowerCase()} for ${nameEl.textContent}`); + + const valueTextEl = document.createElement("span"); + valueTextEl.className = "recent-chat-value"; + valueTextEl.textContent = hasValue ? valueString : "—"; + valueButton.appendChild(valueTextEl); + + const iconEl = document.createElement("span"); + iconEl.className = "recent-chat-copy-icon"; + iconEl.innerHTML = ` + + `; + valueButton.appendChild(iconEl); + + const canCopy = hasValue && copyValue !== null && copyValue !== undefined && copyValue !== ""; + if (canCopy) { + valueButton.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(String(copyValue)); + setFeedback(`${labelText} copied to clipboard.`); + } catch (err) { + setFeedback(`Copy failed: ${err.message}`, true); + } + }); + } else { + valueButton.disabled = true; + valueButton.setAttribute("aria-disabled", "true"); + } + + actionsEl.appendChild(valueButton); + row.appendChild(labelEl); + row.appendChild(actionsEl); + item.appendChild(row); + }; + + appendRow( + "Chat ID", + typeof chat.chat_id === "number" || typeof chat.chat_id === "bigint" ? String(chat.chat_id) : chat.chat_id, + chat.chat_id, + { required: true, valueClasses: ["mono"] }, + ); + appendRow( + "Username", + chat.username ? `@${chat.username}` : "", + chat.username ? `@${chat.username}` : "", + {}, + ); + appendRow( + "Phone", + chat.phone_number ? String(chat.phone_number) : "", + chat.phone_number ? String(chat.phone_number) : "", + {}, + ); + + recentChatsListEl.appendChild(item); + }); +} + +function closeRecentChatsDialog() { + if (!recentChatsModal || recentChatsModal.classList.contains("hidden")) return; + recentChatsModal.classList.add("hidden"); + document.removeEventListener("keydown", handleRecentChatsKeydown); + if (lastFocusedElement && typeof lastFocusedElement.focus === "function") { + lastFocusedElement.focus(); + } +} + +function handleRecentChatsKeydown(event) { + if (event.key === "Escape") { + event.preventDefault(); + closeRecentChatsDialog(); + } +} + +if (recentChatsBtn) { + recentChatsBtn.addEventListener("click", () => { + openRecentChatsDialog(); + }); +} + +if (closeRecentChatsBtn) { + closeRecentChatsBtn.addEventListener("click", () => { + closeRecentChatsDialog(); + }); +} + +if (recentChatsModal) { + recentChatsModal.addEventListener("click", (event) => { + if (event.target === recentChatsModal) { + closeRecentChatsDialog(); + } + }); +} + async function loadHooks() { try { const hooks = await fetchJSON("/api/hooks"); diff --git a/app/static/style.css b/app/static/style.css index 4810b70..596579c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -68,6 +68,18 @@ main { padding: 0 clamp(1rem, 6vw, 4rem) 4rem; } +.helper-row { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; +} + +.helper-button { + min-width: 14rem; + font-weight: 600; +} + .card { background: linear-gradient(145deg, rgba(23, 30, 50, 0.92), rgba(10, 12, 22, 0.9)); border-radius: var(--border-radius); @@ -402,6 +414,172 @@ button:active { gap: 0.75rem; } +.recent-chats-modal { + position: fixed; + inset: 0; + background: rgba(10, 13, 24, 0.78); + display: flex; + align-items: center; + justify-content: center; + padding: clamp(1rem, 6vw, 3rem); + z-index: 1000; +} + +.modal-card { + background: linear-gradient(145deg, rgba(22, 28, 48, 0.95), rgba(9, 11, 22, 0.92)); + border-radius: var(--border-radius); + border: 1px solid rgba(255, 255, 255, 0.08); + max-width: min(34rem, 100%); + width: 100%; + padding: clamp(1.25rem, 4vw, 2.5rem); + box-shadow: 0 28px 60px rgba(8, 12, 24, 0.55); + display: grid; + gap: 1.25rem; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.modal-header h2 { + margin: 0; + font-size: 1.35rem; +} + +.modal-close { + width: 2.5rem; + height: 2.5rem; +} + +.recent-chats-description { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.recent-chats-search-wrapper { + display: flex; +} + +.recent-chats-search { + width: 100%; + margin-top: 0.5rem; +} + +.recent-chats-list { + display: grid; + gap: 1rem; + max-height: min(24rem, 60vh); + overflow-y: auto; + padding-right: 0.5rem; +} + +.recent-chat-item { + background: rgba(255, 255, 255, 0.04); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.06); + padding: 0.85rem clamp(0.75rem, 4vw, 1rem); + display: grid; + gap: 0.75rem; +} + +.recent-chat-details { + display: grid; + gap: 0.35rem; +} + +.recent-chat-name { + font-weight: 600; + font-size: 1rem; +} + +.recent-chat-meta { + font-size: 0.85rem; + color: var(--muted); +} + +.recent-chat-extra { + font-size: 0.85rem; + color: var(--muted); + opacity: 0.85; +} + +.recent-chat-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.recent-chat-label { + font-size: 0.85rem; + color: var(--muted); + flex: 0 0 auto; + min-width: 5.5rem; +} + +.recent-chat-actions { + display: flex; + align-items: center; + flex: 1 1 auto; +} + +.recent-chat-value-button { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0.55rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.9rem; + font-family: inherit; + color: inherit; + min-height: 2.25rem; + width: 100%; + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.recent-chat-value-button:hover:not(:disabled), +.recent-chat-value-button:focus-visible { + background: rgba(79, 140, 255, 0.12); + border-color: rgba(79, 140, 255, 0.35); + box-shadow: 0 0 0 3px rgba(79, 140, 255, 0.15); + outline: none; +} + +.recent-chat-value-button:disabled, +.recent-chat-value-button[aria-disabled="true"] { + cursor: not-allowed; + opacity: 0.65; +} + +.recent-chat-value { + flex: 1 1 auto; + word-break: break-all; + user-select: all; + text-align: left; +} + +.recent-chat-copy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + color: var(--muted); +} + +.recent-chat-feedback { + min-height: 1rem; + font-size: 0.8rem; + color: var(--muted); +} + .hidden { display: none !important; } @@ -416,6 +594,15 @@ button:active { text-align: center; } + .recent-chats-modal { + padding: 0.75rem; + } + + .modal-card { + max-height: 90vh; + padding: 1rem; + } + .site-identity { flex-direction: column; gap: 1rem; diff --git a/app/telegram_service.py b/app/telegram_service.py index 1a78ee0..161abee 100644 --- a/app/telegram_service.py +++ b/app/telegram_service.py @@ -1,10 +1,13 @@ import asyncio from dataclasses import dataclass -from typing import Optional +from typing import List, Optional from telethon import TelegramClient, errors +from telethon.tl import types +from telethon.utils import get_display_name, get_peer_id from .config import get_settings +from .models import RecentChat @dataclass @@ -124,5 +127,63 @@ class TelegramService: def get_login_state(self) -> LoginState: return self._login_state + async def fetch_recent_chats(self, limit: int = 50) -> List[RecentChat]: + await self.ensure_connected() + authorized = self.client.is_user_authorized() + if asyncio.iscoroutine(authorized): + authorized = await authorized + if not authorized: + raise PermissionError("Telegram session is not authorized") + + dialogs = await self.client.get_dialogs(limit=limit) + results: List[RecentChat] = [] + for dialog in dialogs: + entity = dialog.entity + if entity is None: + chat_id = str(getattr(dialog, "id", "")) + display_name = getattr(dialog, "name", chat_id) or chat_id + chat_type = "unknown" + username = None + phone = None + else: + try: + chat_id = str(get_peer_id(entity)) + except Exception: # noqa: BLE001 + chat_id = str(getattr(entity, "id", "")) + + try: + display_name = get_display_name(entity) or chat_id + except Exception: # noqa: BLE001 + display_name = chat_id + + chat_type = self._classify_entity(entity) + username = getattr(entity, "username", None) + phone = getattr(entity, "phone", None) if isinstance(entity, types.User) else None + + results.append( + RecentChat( + chat_id=chat_id, + display_name=display_name, + chat_type=chat_type, + username=username, + phone_number=phone, + last_used_at=getattr(dialog, "date", None), + ) + ) + + return results + + @staticmethod + def _classify_entity(entity: object) -> str: + if isinstance(entity, types.User): + if getattr(entity, "bot", False): + return "bot" + return "user" + if isinstance(entity, types.Chat): + return "group" + if isinstance(entity, types.Channel): + return "channel" if getattr(entity, "broadcast", False) else "group" + return "unknown" + telegram_service = TelegramService() diff --git a/app/templates/index.html b/app/templates/index.html index a514a14..de6464d 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -53,6 +53,18 @@ +
+ +
+

Hooks

@@ -144,6 +156,41 @@ + + diff --git a/tests/test_app.py b/tests/test_app.py index 4381713..e3675ad 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -57,6 +57,7 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: importlib.reload(importlib.import_module("app.storage")) telegram_service_module = importlib.reload(importlib.import_module("app.telegram_service")) + models_module = importlib.reload(importlib.import_module("app.models")) main_module = importlib.reload(importlib.import_module("app.main")) # Stub out Telegram interactions @@ -74,6 +75,11 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: async def fake_send_message(chat_id: str, message: str) -> None: call_log.append((chat_id, message)) + recent_chats_state: List[models_module.RecentChat] = [] + + async def fake_fetch_recent_chats(limit: int = 50) -> List[models_module.RecentChat]: + return recent_chats_state[:limit] + async def fake_disconnect() -> None: return None @@ -86,10 +92,13 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: monkeypatch.setattr(main_module.telegram_service, "send_message", fake_send_message) monkeypatch.setattr(main_module.telegram_service, "disconnect", fake_disconnect) monkeypatch.setattr(main_module.telegram_service, "verify_code", fake_verify_code) + monkeypatch.setattr(main_module.telegram_service, "fetch_recent_chats", fake_fetch_recent_chats) monkeypatch.setattr(main_module.telegram_service, "is_connected", lambda: True) test_client = TestClient(main_module.app) test_client.call_log = call_log # type: ignore[attr-defined] + test_client.recent_chats_state = recent_chats_state # type: ignore[attr-defined] + test_client.RecentChatModel = models_module.RecentChat # type: ignore[attr-defined] return test_client @@ -124,6 +133,26 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None: assert last_triggered_value is not None first_triggered_at = datetime.fromisoformat(last_triggered_value) + client.recent_chats_state[:] = [ + client.RecentChatModel( + chat_id=payload["chat_id"], + display_name=f"Chat {payload['chat_id']}", + chat_type="user", + username="exampleuser", + phone_number="123456", + last_used_at=datetime.now(), + ) + ] + + recent_resp = client.get("/api/recent-chats") + assert recent_resp.status_code == 200 + recent_data = recent_resp.json() + assert len(recent_data) == 1 + assert recent_data[0]["chat_id"] == payload["chat_id"] + assert recent_data[0]["display_name"] == f"Chat {payload['chat_id']}" + assert recent_data[0]["username"] == "exampleuser" + assert recent_data[0]["phone_number"] == "123456" + new_id = "customid123" patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id}) assert patch_resp.status_code == 200 @@ -164,6 +193,25 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None: second_triggered_at = datetime.fromisoformat(second_triggered_value) assert second_triggered_at >= first_triggered_at + client.recent_chats_state[:] = [ + client.RecentChatModel( + chat_id=update_payload["chat_id"], + display_name=f"Chat {update_payload['chat_id']}", + chat_type="group", + username="updateduser", + phone_number=None, + last_used_at=datetime.now(), + ) + ] + + history_resp = client.get("/api/recent-chats") + assert history_resp.status_code == 200 + history_data = history_resp.json() + assert history_data[0]["chat_id"] == update_payload["chat_id"] + assert history_data[0]["display_name"] == f"Chat {update_payload['chat_id']}" + assert history_data[0]["username"] == "updateduser" + assert history_data[0]["phone_number"] is None + def test_login_verify_without_phone_number(client: TestClient) -> None: response = client.post("/api/login/verify", json={"code": "123456"}) @@ -171,3 +219,15 @@ def test_login_verify_without_phone_number(client: TestClient) -> None: payload = response.json() assert payload["authorized"] is True assert payload["user"] == "Test User" + + +def test_recent_chats_requires_authorization(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + from app import main as main_module + + async def not_authorized() -> bool: + return False + + monkeypatch.setattr(main_module.telegram_service, "is_authorized", not_authorized) + + response = client.get("/api/recent-chats") + assert response.status_code == 401