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