commit c7f694d820ffee00c34cfde81e92693e95f3d77c Author: Andre Beging Date: Tue Oct 7 12:51:31 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ce4bb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +.env +.env.* +.vscode/ +*.sqlite3 +*.db +*.session +.data/ +pytestcache/ +.coverage +htmlcov/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..204887f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app + +WORKDIR /app + +# Install system dependencies required by Telethon and FastAPI +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p data + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea77b73 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Telegram Message Hook + +A container-friendly FastAPI service that sends predefined Telegram messages when specific webhook URLs are triggered. It offers a modern single-page interface for managing hook entries and handles user authentication with Telegram (via Telethon) directly in the browser. + +## ✨ Features + +- User session login through the web UI using Telethon (no CLI prompts). +- Create, list, trigger, edit IDs, and delete hook entries that map to Telegram chat IDs and Markdown-enabled messages. +- Each hook automatically receives a short unique identifier and an action URL. +- Persistent storage backed by a prettified JSON file (stored in `/data/hooks.json` inside the container). +- Docker image and Traefik-ready `docker-compose.yml` (uses an external `proxy` network and dummy host values). +- Environment-driven configuration for all sensitive credentials. + +## ⚙️ Configuration + +| Variable | Required | Description | +| --- | --- | --- | +| `TELEGRAM_API_ID` | ✅ | Telegram API ID (from https://my.telegram.org). | +| `TELEGRAM_API_HASH` | ✅ | Telegram API hash. | +| `TELEGRAM_PHONE` | ➖ | Default phone number to prefill in the login form. | +| `TELEGRAM_SESSION_NAME` | ➖ | Logical name for the session file (default `telegram`). | +| `TELEGRAM_SESSION_PATH` | ➖ | Absolute path to the Telethon session file (default `data/telegram.session`). | +| `DATABASE_PATH` | ➖ | Absolute path to the hooks JSON file (default `data/hooks.json`). | +| `BASE_URL` | ➖ | Public base URL used to render action links (default `http://localhost:8000`). | +| `HOOK_ID_LENGTH` | ➖ | Length of generated hook IDs (default `8`). | + +Create an `.env` file (optional) to keep these together locally; the application loads it automatically. + +## 🚀 Local Development + +### Windows quick start + +You can bootstrap everything with the PowerShell helper: + +```powershell +scripts\run_local.ps1 +``` + +The script ensures the `data` folder exists, sets default paths for the hooks JSON file and Telethon session, and then launches Uvicorn with hot reload. Provide real Telegram credentials via environment variables or an `.env` file before running it. + +### Manual setup + +1. Install dependencies: + +```powershell +pip install -r requirements-dev.txt +``` + +2. Export your credentials (or create an `.env`). For PowerShell: + +```powershell +$env:TELEGRAM_API_ID = "123456" +$env:TELEGRAM_API_HASH = "your_api_hash" +# Optional tweaks +$env:BASE_URL = "http://localhost:8000" +``` + +3. Launch the API server: + +```powershell +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +4. Open http://localhost:8000 to log in and manage hooks. + +## 🧪 Test Suite + +Run the unit tests (Telethon interactions are safely stubbed): + +```powershell +python -m pytest +``` + +## 🐳 Docker + +Build the image: + +```powershell +docker build -t telegram-hook . +``` + +Run with environment variables and a bind-mounted `data` folder: + +```powershell +docker run --rm -p 8000:8000 ` + -e TELEGRAM_API_ID=123456 ` + -e TELEGRAM_API_HASH=your_api_hash ` + -e TELEGRAM_PHONE=+491234567890 ` + -e BASE_URL=https://hook.example.com ` + -v ${PWD}\data:/data ` + telegram-hook +``` + +### Docker Compose + Traefik + +`docker-compose.yml` is pre-configured with Traefik labels and an external network called `proxy`: + +```yaml +services: + telegram-hook: + labels: + traefik.http.routers.telegramhook.rule: "Host(`hook.example.com`)" + traefik.http.routers.telegramhook.entrypoints: "websecure" + traefik.http.routers.telegramhook.tls: "true" +``` + +Update the hostnames/entrypoints as needed and ensure the `proxy` network exists: + +```powershell +docker network create proxy +``` + +Then boot the stack (with a populated `.env` file): + +```powershell +docker compose up -d +``` + +## 🔐 Telegram Login Flow + +1. Supply your phone number in the UI and send a verification code. +2. Enter the code (and optional 2FA password) to complete the sign-in. +3. The session file is stored at `TELEGRAM_SESSION_PATH`; persist it (e.g., volume mount `/data`). +4. Once authorized, hitting `GET /action/{hook_id}` triggers the configured message. + +## 📡 HTTP Endpoints (excerpt) + +- `GET /` – Single-page dashboard. +- `GET /api/status` – Telegram session state. +- `POST /api/login/start` – Send login code to phone. +- `POST /api/login/verify` – Finish login with code (+ optional password). +- `POST /api/hooks` – Create a hook `{ chat_id, message }`. +- `GET /api/hooks` – List hooks with action URLs. +- `DELETE /api/hooks/{hook_id}` – Remove a hook. +- `GET /action/{hook_id}` – Trigger message delivery. + +## 📝 Notes & Next Steps + +- Messages accept Markdown (Telethon `md`) and multi-line content. +- The UI surfaces the generated action URL and offers quick copy/delete controls. +- Consider enabling HTTPS on Traefik (e.g., via ACME) before exposing publicly. +- For production, store the `/data` volume on persistent storage and secure the host with a firewall. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..69bbb7c --- /dev/null +++ b/app/config.py @@ -0,0 +1,32 @@ +from functools import lru_cache +from pathlib import Path +from typing import Optional + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + populate_by_name=True, + extra="ignore", + ) + + api_id: int = Field(..., alias="TELEGRAM_API_ID") + api_hash: str = Field(..., alias="TELEGRAM_API_HASH") + phone_number: Optional[str] = Field(default=None, alias="TELEGRAM_PHONE") + session_name: str = Field(default="telegram", alias="TELEGRAM_SESSION_NAME") + session_path: Path = Field(default=Path("data") / "telegram.session", alias="TELEGRAM_SESSION_PATH") + database_path: Path = Field(default=Path("data") / "hooks.json", alias="DATABASE_PATH") + base_url: str = Field(default="http://localhost:8000", alias="BASE_URL") + hook_id_length: int = Field(default=8, alias="HOOK_ID_LENGTH") + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + settings.session_path.parent.mkdir(parents=True, exist_ok=True) + settings.database_path.parent.mkdir(parents=True, exist_ok=True) + return settings diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a96e979 --- /dev/null +++ b/app/main.py @@ -0,0 +1,184 @@ +from contextlib import asynccontextmanager +from pathlib import Path +from typing import List + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +from .config import get_settings +from .models import ( + HookCreate, + HookResponse, + HookUpdateId, + LoginStartRequest, + LoginVerifyRequest, + MessageTriggerResponse, + StatusResponse, +) +from .storage import ( + create_hook_async, + delete_hook_async, + get_hook_async, + list_hooks_async, + update_hook_id_async, +) +from .telegram_service import telegram_service + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(_: FastAPI): + await telegram_service.ensure_connected() + try: + yield + finally: + await telegram_service.disconnect() + + +app = FastAPI(title="Telegram Message Hook", lifespan=lifespan) + + +static_directory = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=static_directory), name="static") + +index_path = Path(__file__).parent / "templates" / "index.html" + + +@app.get("/", response_class=HTMLResponse) +async def index() -> HTMLResponse: + if not index_path.exists(): + raise HTTPException(status_code=500, detail="Frontend not found") + return HTMLResponse(index_path.read_text(encoding="utf-8")) + + +@app.get("/api/status", response_model=StatusResponse) +async def status() -> StatusResponse: + authorized = await telegram_service.is_authorized() + user = await telegram_service.get_user() if authorized else None + login_state = telegram_service.get_login_state() + return StatusResponse( + authorized=authorized, + user=user, + session_active=telegram_service.is_connected(), + phone_number=login_state.phone_number, + code_sent=login_state.code_sent, + ) + + +@app.post("/api/login/start", response_model=StatusResponse) +async def login_start(payload: LoginStartRequest) -> StatusResponse: + try: + await telegram_service.start_login(payload.phone_number) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + login_state = telegram_service.get_login_state() + return StatusResponse( + authorized=False, + user=None, + session_active=telegram_service.is_connected(), + phone_number=login_state.phone_number, + code_sent=login_state.code_sent, + ) + + +@app.post("/api/login/verify", response_model=StatusResponse) +async def login_verify(payload: LoginVerifyRequest) -> StatusResponse: + try: + await telegram_service.verify_code( + code=payload.code, + password=payload.password, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + authorized = await telegram_service.is_authorized() + user = await telegram_service.get_user() if authorized else None + login_state = telegram_service.get_login_state() + return StatusResponse( + authorized=authorized, + user=user, + session_active=telegram_service.is_connected(), + phone_number=login_state.phone_number, + code_sent=login_state.code_sent, + ) + + +@app.post("/api/logout", response_model=StatusResponse) +async def logout() -> StatusResponse: + await telegram_service.logout() + login_state = telegram_service.get_login_state() + return StatusResponse( + authorized=False, + user=None, + session_active=telegram_service.is_connected(), + phone_number=login_state.phone_number, + code_sent=login_state.code_sent, + ) + + +@app.get("/api/hooks", response_model=List[HookResponse]) +async def list_hooks() -> List[HookResponse]: + hooks = await list_hooks_async() + return [ + HookResponse( + hook_id=hook.hook_id, + chat_id=hook.chat_id, + message=hook.message, + created_at=hook.created_at, + action_url=f"{settings.base_url}{hook.action_path}", + ) + for hook in hooks + ] + + +@app.post("/api/hooks", response_model=HookResponse, status_code=201) +async def create_hook(payload: HookCreate) -> HookResponse: + hook = await create_hook_async(payload) + return HookResponse( + hook_id=hook.hook_id, + chat_id=hook.chat_id, + message=hook.message, + created_at=hook.created_at, + action_url=f"{settings.base_url}{hook.action_path}", + ) + + +@app.delete("/api/hooks/{hook_id}", status_code=204) +async def delete_hook(hook_id: str) -> None: + deleted = await delete_hook_async(hook_id) + if not deleted: + raise HTTPException(status_code=404, detail="Hook not found") + + +@app.patch("/api/hooks/{hook_id}", response_model=HookResponse) +async def update_hook(hook_id: str, payload: HookUpdateId) -> HookResponse: + try: + updated = await update_hook_id_async(hook_id, payload.hook_id) + except KeyError as exc: + raise HTTPException(status_code=404, detail="Hook not found") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HookResponse( + hook_id=updated.hook_id, + chat_id=updated.chat_id, + message=updated.message, + created_at=updated.created_at, + action_url=f"{settings.base_url}{updated.action_path}", + ) + + +@app.get("/action/{hook_id}", response_model=MessageTriggerResponse) +async def trigger_hook(hook_id: str) -> MessageTriggerResponse: + hook = await get_hook_async(hook_id) + if not hook: + raise HTTPException(status_code=404, detail="Hook not found") + try: + await telegram_service.send_message(hook.chat_id, hook.message) + except PermissionError as exc: + raise HTTPException(status_code=401, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + return MessageTriggerResponse(status="sent", hook_id=hook.hook_id, chat_id=hook.chat_id) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..9b517f6 --- /dev/null +++ b/app/models.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class HookCreate(BaseModel): + message: str = Field(..., min_length=1, description="Message body supporting Markdown") + chat_id: str = Field(..., min_length=1, description="Target chat ID or username") + + +class HookRead(BaseModel): + hook_id: str + message: str + chat_id: str + created_at: datetime + + @property + def action_path(self) -> str: + return f"/action/{self.hook_id}" + + +class HookResponse(HookRead): + action_url: str + + +class HookUpdateId(BaseModel): + hook_id: str = Field( + ..., + min_length=3, + max_length=64, + pattern=r"^[A-Za-z0-9_-]+$", + description="New hook identifier", + ) + + +class LoginStartRequest(BaseModel): + phone_number: Optional[str] = None + + +class LoginVerifyRequest(BaseModel): + code: str + phone_number: Optional[str] = None + password: Optional[str] = None + + +class MessageTriggerResponse(BaseModel): + status: str + hook_id: str + chat_id: str + + +class StatusResponse(BaseModel): + authorized: bool + user: Optional[str] + session_active: bool + phone_number: Optional[str] + code_sent: bool = False diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..f7b2828 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,298 @@ +const sessionStatusEl = document.querySelector("#session-status"); +const sessionUserEl = document.querySelector("#session-user"); +const loginFeedbackEl = document.querySelector("#login-feedback"); +const startLoginForm = document.querySelector("#start-login-form"); +const verifyLoginForm = document.querySelector("#verify-login-form"); +const logoutButton = document.querySelector("#logout-button"); +const hooksCountEl = document.querySelector("#hooks-count"); +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"); + +let createFormVisible = false; +let createFormTouched = false; + +function setCreateFormVisibility(show, { fromUser = false } = {}) { + if (!toggleCreateBtn) return; + createFormVisible = show; + if (fromUser) { + createFormTouched = true; + } + createHookForm.classList.toggle("hidden", !show); + toggleCreateBtn.setAttribute("aria-expanded", String(show)); + toggleCreateBtn.textContent = show ? "Hide form" : "New Hook"; + if (show) { + const chatInput = document.querySelector("#hook-chat-id"); + if (chatInput) { + chatInput.focus(); + } + } +} + +if (toggleCreateBtn) { + toggleCreateBtn.addEventListener("click", () => { + setCreateFormVisibility(!createFormVisible, { fromUser: true }); + }); + setCreateFormVisibility(false); +} + +async function fetchJSON(url, options = {}) { + const response = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...options, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || "Request failed"); + } + return response.status === 204 ? null : response.json(); +} + +function updateSessionUI(status) { + if (status.authorized) { + sessionStatusEl.textContent = "Authorized"; + sessionStatusEl.style.background = "rgba(100, 221, 155, 0.12)"; + sessionStatusEl.style.color = "#64dd9b"; + sessionUserEl.textContent = `Logged in as ${status.user}`; + loginFeedbackEl.textContent = "Session ready. You can trigger hooks."; + startLoginForm.classList.add("hidden"); + verifyLoginForm.classList.add("hidden"); + logoutButton.classList.remove("hidden"); + } else { + sessionStatusEl.textContent = status.code_sent ? "Awaiting code" : "Not authorized"; + sessionStatusEl.style.background = "rgba(79, 140, 255, 0.15)"; + sessionStatusEl.style.color = "#4f8cff"; + sessionUserEl.textContent = status.phone_number + ? `Phone number: ${status.phone_number}` + : "Set a phone number to begin"; + logoutButton.classList.add("hidden"); + + if (status.code_sent) { + verifyLoginForm.classList.remove("hidden"); + loginFeedbackEl.textContent = "Code sent. Check your Telegram messages."; + } else { + verifyLoginForm.classList.add("hidden"); + loginFeedbackEl.textContent = "Start by sending a login code to your phone."; + } + + startLoginForm.classList.remove("hidden"); + } +} + +async function refreshAll() { + try { + const status = await fetchJSON("/api/status"); + updateSessionUI(status); + } catch (error) { + loginFeedbackEl.textContent = `Failed to refresh status: ${error.message}`; + } + await loadHooks(); +} + +async function loadHooks() { + try { + const hooks = await fetchJSON("/api/hooks"); + hooksCountEl.textContent = hooks.length; + hooksListEl.innerHTML = ""; + if (!hooks.length) { + if (!createFormTouched) { + setCreateFormVisibility(true); + } + const empty = document.createElement("p"); + empty.className = "feedback"; + empty.textContent = "No hooks yet. Use the New Hook button above to create one."; + hooksListEl.appendChild(empty); + return; + } + + hooks.forEach((hook) => { + const node = hookTemplate.content.cloneNode(true); + node.querySelector("h3").textContent = hook.chat_id; + node.querySelector(".hook-date").textContent = new Date(hook.created_at).toLocaleString(); + node.querySelector(".hook-message").textContent = hook.message; + node.querySelector(".hook-url").textContent = hook.action_url; + node.querySelector(".hook-id").textContent = hook.hook_id; + const feedbackEl = node.querySelector(".hook-feedback"); + + const copyBtn = node.querySelector(".copy"); + copyBtn.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(hook.action_url); + copyBtn.textContent = "Copied!"; + setTimeout(() => { + copyBtn.textContent = "Copy URL"; + }, 2000); + } catch (err) { + copyBtn.textContent = "Copy failed"; + setTimeout(() => { + copyBtn.textContent = "Copy URL"; + }, 2000); + } + }); + + const triggerBtn = node.querySelector(".trigger"); + triggerBtn.addEventListener("click", async () => { + const originalText = triggerBtn.textContent; + triggerBtn.disabled = true; + triggerBtn.textContent = "Sending…"; + feedbackEl.textContent = ""; + try { + const result = await fetchJSON(`/action/${hook.hook_id}`); + triggerBtn.textContent = "Sent"; + feedbackEl.textContent = `Status: ${result.status}`; + feedbackEl.style.color = "#64dd9b"; + } catch (err) { + triggerBtn.textContent = "Retry"; + feedbackEl.textContent = `Failed: ${err.message}`; + feedbackEl.style.color = "#ffbac7"; + } finally { + setTimeout(() => { + triggerBtn.textContent = originalText; + triggerBtn.disabled = false; + feedbackEl.style.color = ""; + }, 2000); + } + }); + + const editIdBtn = node.querySelector(".edit-id"); + const editIconMarkup = editIdBtn.innerHTML; + editIdBtn.addEventListener("click", async () => { + const newId = prompt("Enter new hook ID", hook.hook_id); + if (newId === null) { + return; + } + const sanitized = newId.trim(); + if (!sanitized || sanitized === hook.hook_id) { + return; + } + editIdBtn.disabled = true; + editIdBtn.textContent = "Saving…"; + feedbackEl.textContent = ""; + try { + await fetchJSON(`/api/hooks/${hook.hook_id}`, { + method: "PATCH", + body: JSON.stringify({ hook_id: sanitized }), + }); + feedbackEl.textContent = "Hook ID updated."; + feedbackEl.style.color = "#64dd9b"; + await loadHooks(); + } catch (err) { + feedbackEl.textContent = `Update failed: ${err.message}`; + feedbackEl.style.color = "#ffbac7"; + } finally { + editIdBtn.disabled = false; + editIdBtn.innerHTML = editIconMarkup; + setTimeout(() => { + feedbackEl.textContent = ""; + feedbackEl.style.color = ""; + }, 2500); + } + }); + + const deleteBtn = node.querySelector(".delete"); + deleteBtn.addEventListener("click", async () => { + if (!confirm("Delete this hook?")) return; + try { + await fetchJSON(`/api/hooks/${hook.hook_id}`, { method: "DELETE" }); + await loadHooks(); + } catch (err) { + alert(`Failed to delete hook: ${err.message}`); + } + }); + + hooksListEl.appendChild(node); + }); + } catch (error) { + hooksListEl.innerHTML = ""; + const para = document.createElement("p"); + para.className = "feedback"; + para.textContent = `Failed to load hooks: ${error.message}`; + hooksListEl.appendChild(para); + } +} + +startLoginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const phoneNumber = document.querySelector("#phone-number").value || null; + loginFeedbackEl.textContent = "Sending code…"; + try { + const status = await fetchJSON("/api/login/start", { + method: "POST", + body: JSON.stringify({ phone_number: phoneNumber }), + }); + updateSessionUI(status); + loginFeedbackEl.textContent = "Code sent successfully."; + } catch (error) { + loginFeedbackEl.textContent = `Failed to send code: ${error.message}`; + } +}); + +verifyLoginForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const code = document.querySelector("#verification-code").value; + const password = document.querySelector("#twofactor-password").value || null; + loginFeedbackEl.textContent = "Verifying…"; + try { + const status = await fetchJSON("/api/login/verify", { + method: "POST", + body: JSON.stringify({ code, password }), + }); + updateSessionUI(status); + loginFeedbackEl.textContent = status.authorized + ? "Login completed." + : "Enter your password to finish."; + } catch (error) { + loginFeedbackEl.textContent = `Verification failed: ${error.message}`; + } +}); + +logoutButton.addEventListener("click", async () => { + try { + const status = await fetchJSON("/api/logout", { method: "POST" }); + updateSessionUI(status); + loginFeedbackEl.textContent = "Logged out."; + } catch (error) { + loginFeedbackEl.textContent = `Logout failed: ${error.message}`; + } +}); + +createHookForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const chatId = document.querySelector("#hook-chat-id").value.trim(); + const message = document.querySelector("#hook-message").value.trim(); + if (!chatId || !message) { + alert("Chat ID and message are required."); + return; + } + const submitBtn = createHookForm.querySelector("button[type='submit']"); + const originalText = submitBtn.textContent; + submitBtn.disabled = true; + submitBtn.textContent = "Creating…"; + try { + await fetchJSON("/api/hooks", { + method: "POST", + body: JSON.stringify({ chat_id: chatId, message }), + }); + createHookForm.reset(); + await loadHooks(); + } catch (error) { + alert(`Failed to create hook: ${error.message}`); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + } +}); + +window.addEventListener("DOMContentLoaded", async () => { + await refreshAll(); + try { + const status = await fetchJSON("/api/status"); + const phoneInput = document.querySelector("#phone-number"); + if (status.phone_number && !phoneInput.value) { + phoneInput.value = status.phone_number; + } + } catch (err) { + console.warn("Unable to preload phone number", err); + } +}); diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..6e6a176 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,303 @@ +:root { + color-scheme: light dark; + --bg: #10131a; + --surface: rgba(255, 255, 255, 0.06); + --surface-light: rgba(10, 14, 25, 0.5); + --text: #f4f6fb; + --muted: #adb5d6; + --accent: #4f8cff; + --accent-dark: #3f6ed6; + --danger: #ff5c7a; + --font-base: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --border-radius: 18px; + --shadow: 0 18px 40px rgba(15, 20, 30, 0.35); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: var(--font-base); + background: radial-gradient(circle at top, #192040, #0a0d18); + color: var(--text); + display: grid; + grid-template-rows: auto 1fr auto; +} + +header, +footer { + padding: 2rem clamp(1rem, 6vw, 4rem); +} + +header h1 { + margin: 0; + font-size: clamp(2rem, 5vw, 3.5rem); + letter-spacing: -0.04em; +} + +.subtitle { + margin-top: 0.5rem; + color: var(--muted); + max-width: 40rem; +} + +main { + display: grid; + gap: 2rem; + padding: 0 clamp(1rem, 6vw, 4rem) 4rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.card { + background: linear-gradient(145deg, rgba(23, 30, 50, 0.92), rgba(10, 12, 22, 0.9)); + border-radius: var(--border-radius); + padding: clamp(1.5rem, 3vw, 2rem); + box-shadow: var(--shadow); + backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; +} + +.header-actions { + display: inline-flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.status-pill { + display: inline-flex; + align-items: center; + padding: 0.35rem 0.9rem; + border-radius: 999px; + background: rgba(79, 140, 255, 0.15); + color: var(--accent); + font-weight: 600; + margin-bottom: 0.75rem; +} + +.user-info { + margin-bottom: 1.5rem; + color: var(--muted); +} + +.form-grid { + display: grid; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +label { + font-size: 0.9rem; + color: var(--muted); +} + +input, +textarea, +button { + font-family: inherit; + font-size: 1rem; + border-radius: 12px; +} + +input, +textarea { + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(9, 13, 24, 0.65); + color: var(--text); + padding: 0.75rem 1rem; + transition: border 0.2s ease, background 0.2s ease; +} + +input:focus, +textarea:focus { + outline: none; + border-color: rgba(79, 140, 255, 0.5); + background: rgba(15, 20, 35, 0.85); +} + +textarea { + resize: vertical; + min-height: 150px; +} + +button { + padding: 0.8rem 1.2rem; + border: none; + cursor: pointer; + font-weight: 600; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +button:active { + transform: translateY(2px); +} + +.icon-button { + padding: 0.6rem; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +.icon { + width: 1rem; + height: 1rem; + display: block; + fill: currentColor; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: white; + box-shadow: 0 14px 30px rgba(79, 140, 255, 0.35); +} + +.secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.danger { + background: rgba(255, 92, 122, 0.2); + color: #ffbac7; + border: 1px solid rgba(255, 92, 122, 0.4); +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.5rem; + padding: 0.35rem 0.65rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); +} + +.feedback { + min-height: 1.25rem; + color: var(--muted); +} + +.hook-list { + display: grid; + gap: 1rem; +} + +.hook-card { + padding: 1rem 1.25rem; + background: rgba(10, 13, 24, 0.75); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.04); + display: grid; + gap: 0.75rem; +} + +.hook-id-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.hook-id-label { + font-size: 0.85rem; + color: var(--muted); +} + +.hook-id { + padding: 0.3rem 0.6rem; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; +} + +.hook-meta { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 1rem; +} + +.hook-meta h3 { + margin: 0; + font-size: 1rem; + color: var(--text); +} + +.hook-date { + font-size: 0.85rem; + color: var(--muted); +} + +.hook-message { + white-space: pre-wrap; + font-family: "JetBrains Mono", "Fira Code", monospace; + background: rgba(255, 255, 255, 0.04); + padding: 0.75rem; + border-radius: 12px; + margin: 0; +} + +.hook-url { + background: rgba(255, 255, 255, 0.04); + border-radius: 12px; + padding: 0.6rem 0.8rem; + display: inline-block; + user-select: all; +} + +.hook-feedback { + margin: 0.35rem 0 0; + min-height: 1rem; + font-size: 0.85rem; + color: var(--muted); +} + +.hook-actions { + display: flex; + gap: 0.75rem; +} + +.hidden { + display: none !important; +} + +.mono { + font-family: "JetBrains Mono", "Fira Code", monospace; +} + +@media (max-width: 640px) { + header, + footer { + text-align: center; + } + + .hook-actions { + flex-direction: column; + } +} diff --git a/app/storage.py b/app/storage.py new file mode 100644 index 0000000..6f41c8d --- /dev/null +++ b/app/storage.py @@ -0,0 +1,167 @@ +import json +import re +import secrets +import string +import threading +from datetime import UTC, datetime +from pathlib import Path +from typing import List, Optional, Set + +from fastapi.concurrency import run_in_threadpool + +from .config import get_settings +from .models import HookCreate, HookRead + + +HOOK_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,64}$") + + +class HookStore: + def __init__(self, storage_path: Path, hook_id_length: int) -> None: + self.storage_path = storage_path + self.hook_id_length = hook_id_length + self._lock = threading.Lock() + self._initialize() + + def _initialize(self) -> None: + if not self.storage_path.exists(): + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self.storage_path.write_text("[]\n", encoding="utf-8") + else: + try: + self._load_raw() + except (json.JSONDecodeError, UnicodeDecodeError): + # Corrupted or non-UTF file; back it up and start fresh + backup = self.storage_path.with_suffix(self.storage_path.suffix + ".bak") + self.storage_path.replace(backup) + self.storage_path.write_text("[]\n", encoding="utf-8") + + def _load_raw(self) -> List[dict]: + text = self.storage_path.read_text(encoding="utf-8") + data = json.loads(text or "[]") + if not isinstance(data, list): + raise json.JSONDecodeError("Hook store must contain a list", text, 0) + return data + + def _save_raw(self, data: List[dict]) -> None: + payload = json.dumps(data, indent=2, ensure_ascii=False) + tmp_path = self.storage_path.with_suffix(self.storage_path.suffix + ".tmp") + tmp_path.write_text(payload + "\n", encoding="utf-8") + tmp_path.replace(self.storage_path) + + def _generate_hook_id(self, existing_ids: Set[str]) -> str: + alphabet = string.ascii_lowercase + string.digits + while True: + candidate = "".join(secrets.choice(alphabet) for _ in range(self.hook_id_length)) + if candidate not in existing_ids: + return candidate + + def list_hooks(self) -> List[HookRead]: + raw_hooks = self._load_raw() + hooks = [ + HookRead( + hook_id=item["hook_id"], + chat_id=item["chat_id"], + message=item["message"], + created_at=datetime.fromisoformat(item["created_at"]), + ) + for item in raw_hooks + ] + hooks.sort(key=lambda h: h.created_at, reverse=True) + return hooks + + def create_hook(self, payload: HookCreate) -> HookRead: + created_at = datetime.now(UTC).replace(microsecond=0).isoformat() + with self._lock: + raw_hooks = self._load_raw() + existing_ids = {item["hook_id"] for item in raw_hooks} + hook_id = self._generate_hook_id(existing_ids) + raw_hooks.append( + { + "hook_id": hook_id, + "chat_id": payload.chat_id, + "message": payload.message, + "created_at": created_at, + } + ) + self._save_raw(raw_hooks) + return HookRead( + hook_id=hook_id, + chat_id=payload.chat_id, + message=payload.message, + created_at=datetime.fromisoformat(created_at), + ) + + def get_hook(self, hook_id: str) -> Optional[HookRead]: + raw_hooks = self._load_raw() + for item in raw_hooks: + if item.get("hook_id") == hook_id: + return HookRead( + hook_id=item["hook_id"], + chat_id=item["chat_id"], + message=item["message"], + created_at=datetime.fromisoformat(item["created_at"]), + ) + return None + + def delete_hook(self, hook_id: str) -> bool: + with self._lock: + raw_hooks = self._load_raw() + new_hooks = [item for item in raw_hooks if item.get("hook_id") != hook_id] + if len(new_hooks) == len(raw_hooks): + return False + self._save_raw(new_hooks) + return True + + def update_hook_id(self, current_id: str, new_id: str) -> HookRead: + normalized_new_id = new_id.strip() + if not normalized_new_id: + raise ValueError("Hook ID cannot be empty") + if not HOOK_ID_PATTERN.fullmatch(normalized_new_id): + raise ValueError("Hook ID must be 3-64 characters of letters, numbers, '_' or '-' only") + with self._lock: + raw_hooks = self._load_raw() + exists = next((item for item in raw_hooks if item.get("hook_id") == current_id), None) + if not exists: + raise KeyError("Hook not found") + if normalized_new_id == current_id: + return HookRead( + hook_id=exists["hook_id"], + chat_id=exists["chat_id"], + message=exists["message"], + created_at=datetime.fromisoformat(exists["created_at"]), + ) + if any(item.get("hook_id") == normalized_new_id for item in raw_hooks): + raise ValueError("Hook ID already exists") + exists["hook_id"] = normalized_new_id + self._save_raw(raw_hooks) + return HookRead( + hook_id=normalized_new_id, + chat_id=exists["chat_id"], + message=exists["message"], + created_at=datetime.fromisoformat(exists["created_at"]), + ) + + +settings = get_settings() +store = HookStore(settings.database_path, settings.hook_id_length) + + +async def list_hooks_async() -> List[HookRead]: + return await run_in_threadpool(store.list_hooks) + + +async def create_hook_async(payload: HookCreate) -> HookRead: + return await run_in_threadpool(store.create_hook, payload) + + +async def get_hook_async(hook_id: str) -> Optional[HookRead]: + return await run_in_threadpool(store.get_hook, hook_id) + + +async def delete_hook_async(hook_id: str) -> bool: + return await run_in_threadpool(store.delete_hook, hook_id) + + +async def update_hook_id_async(current_hook_id: str, new_hook_id: str) -> HookRead: + return await run_in_threadpool(store.update_hook_id, current_hook_id, new_hook_id) diff --git a/app/telegram_service.py b/app/telegram_service.py new file mode 100644 index 0000000..1a78ee0 --- /dev/null +++ b/app/telegram_service.py @@ -0,0 +1,128 @@ +import asyncio +from dataclasses import dataclass +from typing import Optional + +from telethon import TelegramClient, errors + +from .config import get_settings + + +@dataclass +class LoginState: + phone_number: Optional[str] = None + phone_code_hash: Optional[str] = None + code_sent: bool = False + + +class TelegramService: + def __init__(self) -> None: + self.settings = get_settings() + self.client = TelegramClient( + str(self.settings.session_path), + self.settings.api_id, + self.settings.api_hash, + ) + self._lock = asyncio.Lock() + self._login_state = LoginState(phone_number=self.settings.phone_number) + self._connected = False + + async def connect(self) -> None: + async with self._lock: + if not self._connected: + await self.client.connect() + self._connected = True + + async def ensure_connected(self) -> None: + if not self.client.is_connected(): + async with self._lock: + if not self.client.is_connected(): + await self.client.connect() + + async def disconnect(self) -> None: + async with self._lock: + if self.client.is_connected(): + await self.client.disconnect() + self._connected = False + + def is_connected(self) -> bool: + return self.client.is_connected() + + async def is_authorized(self) -> bool: + await self.ensure_connected() + result = self.client.is_user_authorized() + if asyncio.iscoroutine(result): + result = await result + return bool(result) + + async def get_user(self) -> Optional[str]: + await self.ensure_connected() + authorized = self.client.is_user_authorized() + if asyncio.iscoroutine(authorized): + authorized = await authorized + if not authorized: + return None + me = await self.client.get_me() + if not me: + return None + full_name = " ".join(filter(None, [me.first_name, me.last_name])).strip() + return full_name or me.username or str(me.id) + + async def start_login(self, phone_number: Optional[str]) -> None: + phone = phone_number or self.settings.phone_number + if not phone: + raise ValueError("Phone number is required to start login") + await self.ensure_connected() + sent_code = await self.client.send_code_request(phone) + self._login_state = LoginState( + phone_number=phone, + phone_code_hash=sent_code.phone_code_hash, + code_sent=True, + ) + + async def verify_code(self, code: str, password: Optional[str] = None) -> None: + if not self._login_state.code_sent or not self._login_state.phone_code_hash: + raise ValueError("No login code has been requested") + await self.ensure_connected() + success = False + try: + await self.client.sign_in( + phone=self._login_state.phone_number, + code=code, + phone_code_hash=self._login_state.phone_code_hash, + ) + success = True + except errors.SessionPasswordNeededError: + if not password: + raise + await self.client.sign_in(password=password) + success = True + except errors.PhoneCodeInvalidError as exc: + raise ValueError("Invalid verification code") from exc + except errors.PhoneCodeExpiredError as exc: + raise ValueError("Verification code has expired") from exc + finally: + if success: + self._login_state = LoginState( + phone_number=self._login_state.phone_number, + code_sent=False, + ) + + async def logout(self) -> None: + await self.ensure_connected() + await self.client.log_out() + self._login_state = LoginState(phone_number=self.settings.phone_number) + + async def send_message(self, chat_id: str, message: str) -> None: + 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") + await self.client.send_message(entity=chat_id, message=message, parse_mode="md") + + def get_login_state(self) -> LoginState: + return self._login_state + + +telegram_service = TelegramService() diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..b3e190c --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,98 @@ + + + + + + Telegram Message Hook + + + +
+

Telegram Message Hook

+

Trigger curated messages in your chats via simple webhooks.

+
+ +
+
+

Telegram Session

+
Checking status…
+ +
+
+ + + +
+ + +
+ + +
+ +
+
+

Hooks

+
+ 0 + +
+
+ + +
+
+
+ + + + + + + + diff --git a/data/hooks.json b/data/hooks.json new file mode 100644 index 0000000..0fbc970 --- /dev/null +++ b/data/hooks.json @@ -0,0 +1,14 @@ +[ + { + "hook_id": "anmich", + "chat_id": "@troogs", + "message": "Test an mich", + "created_at": "2025-10-07T09:47:50+00:00" + }, + { + "hook_id": "yszramzb", + "chat_id": "@Veleda_B", + "message": ":*", + "created_at": "2025-10-07T09:48:11+00:00" + } +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4c01e15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" + +services: + telegram-hook: + build: . + container_name: telegram-hook + restart: unless-stopped + environment: + TELEGRAM_API_ID: "${TELEGRAM_API_ID}" + TELEGRAM_API_HASH: "${TELEGRAM_API_HASH}" + TELEGRAM_PHONE: "${TELEGRAM_PHONE:-}" + TELEGRAM_SESSION_NAME: "user-session" + TELEGRAM_SESSION_PATH: "/data/telegram.session" + DATABASE_PATH: "/data/hooks.db" + BASE_URL: "https://hook.example.com" + volumes: + - session_data:/data + labels: + traefik.enable: "true" + traefik.http.routers.telegramhook.rule: "Host(`hook.example.com`)" + traefik.http.routers.telegramhook.entrypoints: "websecure" + traefik.http.routers.telegramhook.tls: "true" + traefik.http.services.telegramhook.loadbalancer.server.port: "8000" + networks: + - proxy + +volumes: + session_data: + driver: local + +networks: + proxy: + external: true diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..fa2d94f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.2.1 +httpx==0.27.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ca9bdc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +telethon==1.35.0 +pydantic==2.10.4 +pydantic-settings==2.6.1 +python-multipart==0.0.20 diff --git a/scripts/run_local.ps1 b/scripts/run_local.ps1 new file mode 100644 index 0000000..6285179 --- /dev/null +++ b/scripts/run_local.ps1 @@ -0,0 +1,41 @@ +param( + [string]$BindHost = "127.0.0.1", + [int]$Port = 8000 +) + +$ErrorActionPreference = "Stop" + +function Resolve-RepoRoot { + param([string]$ScriptPath) + $scriptsFolder = Split-Path -Parent $ScriptPath + return Resolve-Path (Join-Path $scriptsFolder "..") +} + +$repoRoot = Resolve-RepoRoot -ScriptPath $MyInvocation.MyCommand.Path +Set-Location $repoRoot + +if (-not (Get-Command python -ErrorAction SilentlyContinue)) { + throw "Python is required on PATH to run this project locally." +} + +$dataDir = Join-Path $repoRoot "data" +if (-not (Test-Path $dataDir)) { + New-Item -ItemType Directory -Path $dataDir | Out-Null +} + +if (-not $env:TELEGRAM_SESSION_PATH) { + $env:TELEGRAM_SESSION_PATH = Join-Path $dataDir "telegram.session" +} + +if (-not $env:DATABASE_PATH) { + $env:DATABASE_PATH = Join-Path $dataDir "hooks.json" +} + +Write-Host "Starting Telegram Message Hook locally..." -ForegroundColor Cyan +Write-Host ("API listening on http://{0}:{1}" -f $BindHost, $Port) -ForegroundColor Green +Write-Host "Session file: $($env:TELEGRAM_SESSION_PATH)" -ForegroundColor DarkGray +Write-Host "Database file: $($env:DATABASE_PATH)" -ForegroundColor DarkGray + +$uvicornArgs = @("app.main:app", "--host", $BindHost, "--port", $Port, "--reload") + +python -m uvicorn @uvicornArgs diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..7297b40 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,141 @@ +import importlib +from pathlib import Path +from types import SimpleNamespace +from typing import List, Tuple + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + monkeypatch.setenv("TELEGRAM_API_ID", "123456") + monkeypatch.setenv("TELEGRAM_API_HASH", "testhash") + monkeypatch.setenv("BASE_URL", "http://testserver") + monkeypatch.setenv("DATABASE_PATH", str(tmp_path / "hooks.json")) + monkeypatch.setenv("TELEGRAM_SESSION_PATH", str(tmp_path / "telegram.session")) + + class DummyTelegramClient: + def __init__(self, *args, **kwargs): + self._connected = False + + async def connect(self) -> None: + self._connected = True + + async def disconnect(self) -> None: + self._connected = False + + def is_connected(self) -> bool: + return self._connected + + async def send_code_request(self, phone: str): # noqa: ANN001 + return SimpleNamespace(phone_code_hash="dummy-hash") + + async def sign_in(self, *args, **kwargs): # noqa: ANN001, ANN002 + return None + + def is_user_authorized(self) -> bool: + return True + + async def get_me(self): # noqa: ANN201 + return SimpleNamespace(first_name="Test", last_name="User", username="tester", id=1) + + async def log_out(self) -> None: + self._connected = False + + async def send_message(self, *args, **kwargs) -> None: # noqa: ANN001, ANN002 + return None + + monkeypatch.setattr("telethon.client.telegramclient.TelegramClient", DummyTelegramClient) + monkeypatch.setattr("telethon.TelegramClient", DummyTelegramClient) + + # Clear cached settings and reload modules to pick up new paths + from app import config + + config.get_settings.cache_clear() # type: ignore[attr-defined] + + importlib.reload(importlib.import_module("app.storage")) + telegram_service_module = importlib.reload(importlib.import_module("app.telegram_service")) + main_module = importlib.reload(importlib.import_module("app.main")) + + # Stub out Telegram interactions + call_log: List[Tuple[str, str]] = [] + + async def fake_ensure_connected() -> None: + return None + + async def fake_is_authorized() -> bool: + return True + + async def fake_get_user() -> str: + return "Test User" + + async def fake_send_message(chat_id: str, message: str) -> None: + call_log.append((chat_id, message)) + + async def fake_disconnect() -> None: + return None + + async def fake_verify_code(*args, **kwargs) -> None: # noqa: ANN002 + return None + + monkeypatch.setattr(main_module.telegram_service, "ensure_connected", fake_ensure_connected) + monkeypatch.setattr(main_module.telegram_service, "is_authorized", fake_is_authorized) + monkeypatch.setattr(main_module.telegram_service, "get_user", fake_get_user) + 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, "is_connected", lambda: True) + + test_client = TestClient(main_module.app) + test_client.call_log = call_log # type: ignore[attr-defined] + return test_client + + +def test_create_list_and_trigger_hook(client: TestClient) -> None: + payload = { + "chat_id": "@example", + "message": "Hello **Markdown**", + } + create_resp = client.post("/api/hooks", json=payload) + assert create_resp.status_code == 201 + data = create_resp.json() + assert data["chat_id"] == payload["chat_id"] + assert payload["message"] in data["message"] + assert data["action_url"].endswith(f"/action/{data['hook_id']}") + + list_resp = client.get("/api/hooks") + assert list_resp.status_code == 200 + hooks = list_resp.json() + assert len(hooks) == 1 + + trigger_resp = client.get(f"/action/{data['hook_id']}") + assert trigger_resp.status_code == 200 + assert trigger_resp.json()["status"] == "sent" + + call_log = getattr(client, "call_log") + assert call_log == [(payload["chat_id"], payload["message"])] + + new_id = "customid123" + patch_resp = client.patch(f"/api/hooks/{data['hook_id']}", json={"hook_id": new_id}) + assert patch_resp.status_code == 200 + patched = patch_resp.json() + assert patched["hook_id"] == new_id + assert patched["action_url"].endswith(f"/action/{new_id}") + + # Old ID should now be gone + old_trigger = client.get(f"/action/{data['hook_id']}") + assert old_trigger.status_code == 404 + + call_log.clear() + new_trigger = client.get(f"/action/{new_id}") + assert new_trigger.status_code == 200 + assert getattr(client, "call_log") == [(payload["chat_id"], payload["message"])] + + +def test_login_verify_without_phone_number(client: TestClient) -> None: + response = client.post("/api/login/verify", json={"code": "123456"}) + assert response.status_code == 200 + payload = response.json() + assert payload["authorized"] is True + assert payload["user"] == "Test User"