feat: Implement recent chats feature with API endpoint and UI integration

This commit is contained in:
Andre Beging
2025-10-07 15:08:33 +02:00
parent 85094f8683
commit ddf29c1d36
7 changed files with 682 additions and 3 deletions

View File

@@ -14,6 +14,7 @@ from .models import (
LoginStartRequest, LoginStartRequest,
LoginVerifyRequest, LoginVerifyRequest,
MessageTriggerResponse, MessageTriggerResponse,
RecentChat,
StatusResponse, StatusResponse,
) )
from .storage import ( 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) @app.post("/api/hooks", response_model=HookResponse, status_code=201)
async def create_hook(payload: HookCreate) -> HookResponse: async def create_hook(payload: HookCreate) -> HookResponse:
hook = await create_hook_async(payload) hook = await create_hook_async(payload)

View File

@@ -103,3 +103,12 @@ class StatusResponse(BaseModel):
session_active: bool session_active: bool
phone_number: Optional[str] phone_number: Optional[str]
code_sent: bool = False 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

View File

@@ -12,11 +12,32 @@ const hooksListEl = document.querySelector("#hooks-list");
const hookTemplate = document.querySelector("#hook-template"); const hookTemplate = document.querySelector("#hook-template");
const createHookForm = document.querySelector("#create-hook-form"); const createHookForm = document.querySelector("#create-hook-form");
const toggleCreateBtn = document.querySelector("#toggle-create"); 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 sessionDetailsVisible = false;
let sessionDetailsTouched = false; let sessionDetailsTouched = false;
let createFormVisible = false; let createFormVisible = false;
let createFormTouched = 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 } = {}) { function setSessionDetailsVisibility(show, { fromUser = false } = {}) {
if (!toggleSessionBtn || !sessionDetailsEl) return; if (!toggleSessionBtn || !sessionDetailsEl) return;
@@ -66,10 +87,29 @@ async function fetchJSON(url, options = {}) {
...options, ...options,
}); });
if (!response.ok) { 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"); 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) { function updateSessionUI(status) {
@@ -116,6 +156,10 @@ function updateSessionUI(status) {
} else if (shouldShowDetails && !sessionDetailsVisible) { } else if (shouldShowDetails && !sessionDetailsVisible) {
setSessionDetailsVisibility(true); setSessionDetailsVisibility(true);
} }
setRecentChatsAvailability(status.authorized);
if (!status.authorized) {
closeRecentChatsDialog();
}
} }
async function refreshAll() { async function refreshAll() {
@@ -128,6 +172,268 @@ async function refreshAll() {
await loadHooks(); 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 = `
<svg class="icon icon-copy" viewBox="0 0 24 24" aria-hidden="true">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v16h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 18H8V7h11v16z" />
</svg>
`;
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() { async function loadHooks() {
try { try {
const hooks = await fetchJSON("/api/hooks"); const hooks = await fetchJSON("/api/hooks");

View File

@@ -68,6 +68,18 @@ main {
padding: 0 clamp(1rem, 6vw, 4rem) 4rem; 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 { .card {
background: linear-gradient(145deg, rgba(23, 30, 50, 0.92), rgba(10, 12, 22, 0.9)); background: linear-gradient(145deg, rgba(23, 30, 50, 0.92), rgba(10, 12, 22, 0.9));
border-radius: var(--border-radius); border-radius: var(--border-radius);
@@ -402,6 +414,172 @@ button:active {
gap: 0.75rem; 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 { .hidden {
display: none !important; display: none !important;
} }
@@ -416,6 +594,15 @@ button:active {
text-align: center; text-align: center;
} }
.recent-chats-modal {
padding: 0.75rem;
}
.modal-card {
max-height: 90vh;
padding: 1rem;
}
.site-identity { .site-identity {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;

View File

@@ -1,10 +1,13 @@
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import List, Optional
from telethon import TelegramClient, errors 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 .config import get_settings
from .models import RecentChat
@dataclass @dataclass
@@ -124,5 +127,63 @@ class TelegramService:
def get_login_state(self) -> LoginState: def get_login_state(self) -> LoginState:
return self._login_state 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() telegram_service = TelegramService()

View File

@@ -53,6 +53,18 @@
</div> </div>
</section> </section>
<div class="helper-row">
<button
type="button"
id="recent-chats-btn"
class="secondary helper-button"
title="Authorize the session to view recent chats"
disabled
>
Browse recent chats
</button>
</div>
<section id="hooks-section" class="card"> <section id="hooks-section" class="card">
<div class="section-header"> <div class="section-header">
<h2>Hooks</h2> <h2>Hooks</h2>
@@ -144,6 +156,41 @@
</article> </article>
</template> </template>
<div
id="recent-chats-modal"
class="recent-chats-modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="recent-chats-title"
>
<div class="modal-card">
<div class="modal-header">
<h2 id="recent-chats-title">Recent chats</h2>
<button type="button" id="close-recent-chats" class="secondary icon-button modal-close" aria-label="Close recent chats">
<svg class="icon icon-close" viewBox="0 0 24 24" aria-hidden="true">
<path d="M18.3 5.71 12 12l6.3 6.29-1.41 1.42L10.59 13.4 4.3 19.71 2.89 18.3 9.17 12 2.89 5.71 4.3 4.3l6.3 6.29 6.29-6.3z" />
</svg>
<span class="sr-only">Close recent chats</span>
</button>
</div>
<p class="recent-chats-description">
These are your most recent Telegram chats. Review chat details or copy an ID to reuse it when creating a hook.
</p>
<div class="recent-chats-search-wrapper" role="search">
<label for="recent-chats-search" class="sr-only">Search recent chats</label>
<input
type="search"
id="recent-chats-search"
class="recent-chats-search"
placeholder="Search chats…"
autocomplete="off"
spellcheck="false"
/>
</div>
<div id="recent-chats-list" class="recent-chats-list" role="document"></div>
</div>
</div>
<footer> <footer>
<p>Need to trigger a message manually? Hit <span class="mono">GET /action/&lt;hook-id&gt;</span>.</p> <p>Need to trigger a message manually? Hit <span class="mono">GET /action/&lt;hook-id&gt;</span>.</p>
</footer> </footer>

View File

@@ -57,6 +57,7 @@ def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
importlib.reload(importlib.import_module("app.storage")) importlib.reload(importlib.import_module("app.storage"))
telegram_service_module = importlib.reload(importlib.import_module("app.telegram_service")) 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")) main_module = importlib.reload(importlib.import_module("app.main"))
# Stub out Telegram interactions # 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: async def fake_send_message(chat_id: str, message: str) -> None:
call_log.append((chat_id, message)) 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: async def fake_disconnect() -> None:
return 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, "send_message", fake_send_message)
monkeypatch.setattr(main_module.telegram_service, "disconnect", fake_disconnect) 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, "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) monkeypatch.setattr(main_module.telegram_service, "is_connected", lambda: True)
test_client = TestClient(main_module.app) test_client = TestClient(main_module.app)
test_client.call_log = call_log # type: ignore[attr-defined] 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 return test_client
@@ -124,6 +133,26 @@ def test_create_list_and_trigger_hook(client: TestClient) -> None:
assert last_triggered_value is not None assert last_triggered_value is not None
first_triggered_at = datetime.fromisoformat(last_triggered_value) 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" new_id = "customid123"
patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id}) patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id})
assert patch_resp.status_code == 200 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) second_triggered_at = datetime.fromisoformat(second_triggered_value)
assert second_triggered_at >= first_triggered_at 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: def test_login_verify_without_phone_number(client: TestClient) -> None:
response = client.post("/api/login/verify", json={"code": "123456"}) 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() payload = response.json()
assert payload["authorized"] is True assert payload["authorized"] is True
assert payload["user"] == "Test User" 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