From 37a1a58024fcf1ba060e9d69c1114827d4e6742e Mon Sep 17 00:00:00 2001 From: beo3000 Date: Mon, 15 Jun 2026 17:12:21 +0200 Subject: [PATCH] feat(telegram): poll, allowlist-filter, parse text/voice/photo, react --- src/journal_bot/telegram_client.py | 105 +++++++++++++++++++++++++++++ tests/test_telegram_client.py | 71 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/journal_bot/telegram_client.py create mode 100644 tests/test_telegram_client.py diff --git a/src/journal_bot/telegram_client.py b/src/journal_bot/telegram_client.py new file mode 100644 index 0000000..1938ad4 --- /dev/null +++ b/src/journal_bot/telegram_client.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional, Literal +import httpx + + +Kind = Literal["text", "voice", "photo", "unknown"] + + +@dataclass +class ParsedUpdate: + update_id: int + message_id: int + chat_id: int + date: datetime + kind: Kind + text: str = "" + caption: str = "" + voice_file_id: Optional[str] = None + photo_file_id: Optional[str] = None + transcribed_text: Optional[str] = None + + +class TelegramClient: + def __init__(self, token: str, allowed_user_id: int, download_dir: Path): + self.token = token + self.allowed_user_id = allowed_user_id + self.download_dir = download_dir + self.base = f"https://api.telegram.org/bot{token}" + self._http = httpx.AsyncClient(timeout=30.0) + + async def aclose(self) -> None: + await self._http.aclose() + + async def get_updates(self, offset: int, timeout: int = 0) -> list[ParsedUpdate]: + r = await self._http.get( + f"{self.base}/getUpdates", + params={"offset": offset, "timeout": timeout, "allowed_updates": ["message"]}, + ) + r.raise_for_status() + raw = r.json()["result"] + out: list[ParsedUpdate] = [] + for entry in raw: + msg = entry.get("message") + if not msg: + continue + sender = msg.get("from", {}).get("id") + if sender != self.allowed_user_id: + continue + out.append(self._parse(entry, msg)) + return out + + def _parse(self, entry: dict, msg: dict) -> ParsedUpdate: + update_id = entry["update_id"] + chat_id = msg["chat"]["id"] + when = datetime.fromtimestamp(msg["date"], tz=timezone.utc) + + if "voice" in msg: + return ParsedUpdate( + update_id=update_id, message_id=msg["message_id"], chat_id=chat_id, + date=when, kind="voice", + voice_file_id=msg["voice"]["file_id"], + caption=msg.get("caption", ""), + transcribed_text=msg["voice"].get("transcribed_text"), + ) + if "photo" in msg: + largest = max(msg["photo"], key=lambda p: p.get("file_size", 0)) + return ParsedUpdate( + update_id=update_id, message_id=msg["message_id"], chat_id=chat_id, + date=when, kind="photo", + photo_file_id=largest["file_id"], + caption=msg.get("caption", ""), + ) + if "text" in msg: + return ParsedUpdate( + update_id=update_id, message_id=msg["message_id"], chat_id=chat_id, + date=when, kind="text", text=msg["text"], + ) + return ParsedUpdate( + update_id=update_id, message_id=msg["message_id"], chat_id=chat_id, + date=when, kind="unknown", + ) + + async def download_file(self, file_id: str, dest: Path) -> Path: + r = await self._http.get(f"{self.base}/getFile", params={"file_id": file_id}) + r.raise_for_status() + path = r.json()["result"]["file_path"] + url = f"https://api.telegram.org/file/bot{self.token}/{path}" + dest.parent.mkdir(parents=True, exist_ok=True) + async with self._http.stream("GET", url) as stream: + with dest.open("wb") as f: + async for chunk in stream.aiter_bytes(): + f.write(chunk) + return dest + + async def react_ok(self, chat_id: int, message_id: int) -> None: + await self._http.post( + f"{self.base}/setMessageReaction", + json={ + "chat_id": chat_id, + "message_id": message_id, + "reaction": [{"type": "emoji", "emoji": "✅"}], + }, + ) diff --git a/tests/test_telegram_client.py b/tests/test_telegram_client.py new file mode 100644 index 0000000..21f31d3 --- /dev/null +++ b/tests/test_telegram_client.py @@ -0,0 +1,71 @@ +import respx +import httpx +import pytest +from journal_bot.telegram_client import TelegramClient, ParsedUpdate + + +@pytest.fixture +def client(tmp_path): + return TelegramClient(token="TKN", allowed_user_id=42, download_dir=tmp_path) + + +@respx.mock +async def test_get_updates_returns_parsed(client): + respx.get("https://api.telegram.org/botTKN/getUpdates").mock( + return_value=httpx.Response(200, json={"ok": True, "result": [ + {"update_id": 100, "message": { + "message_id": 1, + "date": 1718374337, + "from": {"id": 42}, + "chat": {"id": 42}, + "text": "Hallo", + }}, + ]}) + ) + updates = await client.get_updates(offset=0) + assert len(updates) == 1 + assert updates[0].update_id == 100 + assert updates[0].text == "Hallo" + assert updates[0].kind == "text" + + +@respx.mock +async def test_get_updates_filters_by_allowlist(client): + respx.get("https://api.telegram.org/botTKN/getUpdates").mock( + return_value=httpx.Response(200, json={"ok": True, "result": [ + {"update_id": 100, "message": { + "message_id": 1, "date": 1718374337, + "from": {"id": 999}, "chat": {"id": 999}, "text": "fremd"}}, + {"update_id": 101, "message": { + "message_id": 2, "date": 1718374338, + "from": {"id": 42}, "chat": {"id": 42}, "text": "ok"}}, + ]}) + ) + updates = await client.get_updates(offset=0) + assert len(updates) == 1 + assert updates[0].update_id == 101 + + +@respx.mock +async def test_get_updates_parses_voice(client): + respx.get("https://api.telegram.org/botTKN/getUpdates").mock( + return_value=httpx.Response(200, json={"ok": True, "result": [ + {"update_id": 200, "message": { + "message_id": 3, "date": 1718374400, + "from": {"id": 42}, "chat": {"id": 42}, + "voice": {"file_id": "VOICE123", "duration": 5, "mime_type": "audio/ogg"}, + }}, + ]}) + ) + updates = await client.get_updates(offset=0) + assert updates[0].kind == "voice" + assert updates[0].voice_file_id == "VOICE123" + + +@respx.mock +async def test_react_with_check(client): + route = respx.post("https://api.telegram.org/botTKN/setMessageReaction").mock( + return_value=httpx.Response(200, json={"ok": True, "result": True}) + ) + await client.react_ok(chat_id=42, message_id=1) + assert route.called