feat(telegram): poll, allowlist-filter, parse text/voice/photo, react

This commit is contained in:
beo3000 2026-06-15 17:12:21 +02:00
parent 7aca733735
commit 37a1a58024
2 changed files with 176 additions and 0 deletions

View File

@ -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": ""}],
},
)

View File

@ -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