Files
roboco/roboco/services/telegram_client.py
T
Renn F f1ff149b70 fix(api): unconditional /git/file window cap; defer Telegram sends after commit; docs/map periodic re-index
_compute_file_range now caps any resolved window at _FILE_MAX_LINES
instead of only the exact whole-file shape, closing the near-whole-file
bypass. Telegram sends ride a generalized after-commit outbox
(defer_after_commit over the F107 machinery) so a slow Bot API can no
longer hold the caller's transaction open; TelegramClient grows an
abstract close(). The KB update loop iterates AUTO_INDEX_DIRS so
docs/map edits re-index without a restart. PR-label application
catches all exceptions per its never-raises contract, and pr_merge's
CEO-only message names the resolved branch.
2026-07-15 08:25:20 +02:00

122 lines
3.5 KiB
Python

"""Telegram Bot API client — server-side only, agents never touch it.
Thin httpx wrapper for the one operation the V1 bridge needs: send a message
to a chat. Mirrors ``services/x_client.py``'s ``NullXClient`` shape — a
``NullTelegramClient`` is returned when credentials are unset, so the
notification fan-out degrades gracefully (no-op, no exception) exactly like an
unconfigured X client — never raises into the caller, never makes a network
call.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from roboco.services.telegram_credentials import TelegramCredentialsData
_API_BASE = "https://api.telegram.org"
@dataclass(frozen=True)
class TelegramSendResult:
"""Outcome of a ``send_message`` call."""
sent: bool
detail: str = ""
class TelegramClient(ABC):
"""Abstract Telegram API surface. ``NullTelegramClient`` is the
graceful-degradation stub."""
@property
@abstractmethod
def configured(self) -> bool: ...
@abstractmethod
async def send_message(self, text: str) -> TelegramSendResult: ...
@abstractmethod
async def close(self) -> None:
"""Release transport resources; no-op when no transport exists."""
class NullTelegramClient(TelegramClient):
"""No credentials configured — every call is a no-op, never raises."""
async def close(self) -> None:
return None
@property
def configured(self) -> bool:
return False
async def send_message(self, text: str) -> TelegramSendResult:
_ = text
return TelegramSendResult(sent=False, detail="no credentials configured")
class LiveTelegramClient(TelegramClient):
"""Real Bot API ``sendMessage`` call."""
def __init__(
self,
creds: TelegramCredentialsData,
*,
timeout: float,
client: httpx.AsyncClient | None = None,
) -> None:
self._creds = creds
self._timeout = timeout
self._client = client
self._owns_client = client is None
@property
def configured(self) -> bool:
return True
async def _http(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(timeout=self._timeout)
return self._client
async def close(self) -> None:
if self._owns_client and self._client is not None:
await self._client.aclose()
self._client = None
async def send_message(self, text: str) -> TelegramSendResult:
url = f"{_API_BASE}/bot{self._creds.bot_token}/sendMessage"
client = await self._http()
try:
resp = await client.post(
url,
json={"chat_id": self._creds.chat_id, "text": text},
timeout=self._timeout,
)
except httpx.HTTPError as exc:
return TelegramSendResult(sent=False, detail=f"network error: {exc}")
if not resp.is_success:
return TelegramSendResult(
sent=False,
detail=f"HTTP {resp.status_code}: {resp.text[:200]}",
)
return TelegramSendResult(sent=True)
def build_telegram_client(
creds: TelegramCredentialsData | None,
*,
timeout: float,
client: httpx.AsyncClient | None = None,
) -> TelegramClient:
"""Construct the client — ``NullTelegramClient`` when credentials are unset."""
if creds is None:
return NullTelegramClient()
return LiveTelegramClient(creds, timeout=timeout, client=client)