feat(telegram): poll, allowlist-filter, parse text/voice/photo, react
This commit is contained in:
parent
7aca733735
commit
37a1a58024
|
|
@ -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": "✅"}],
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue