oc_connect
Loading...
Searching...
No Matches
transport.py
Go to the documentation of this file.
1"""Thin raw Telegram Bot API transport over httpx.
2
3Plan 236 adds the three pieces an inline-keyboard decision needs, and nothing
4else: ``getUpdates`` names ``callback_query`` in ``allowed_updates`` (the Bot
5API remembers the last filter a caller set, so leaving it implicit would let
6an earlier caller's filter silently drop taps); ``sendMessage`` accepts an
7already-built ``reply_markup``; and ``answerCallbackQuery`` exists so a stored
8tap can be acknowledged. :func:`inline_keyboard` is the only builder of a
9keyboard and refuses any label outside the reply vocabulary: buttons are the
10vocabulary, nothing else.
11
12Dependencies
13------------
14- oc_diagnostics (_dbg structured logger)
15- option_c_connector_contracts (REPLY_VOCABULARY - the only labels a keyboard button may carry)
16- httpx (Client - the HTTP client for Bot API calls, environment trust off)
17"""
18from __future__ import annotations
19
20from collections.abc import Mapping, Sequence
21from typing import Any
22
23import httpx
24from oc_diagnostics import _dbg
25from option_c_connector_contracts import REPLY_VOCABULARY
26
27DEFAULT_API_ROOT = "https://api.telegram.org"
28
29#: The update kinds the inbound poller handles. Sent on every ``getUpdates``
30#: so the server-side filter is always the one this code expects.
31ALLOWED_UPDATES: tuple[str, ...] = ("message", "callback_query")
32
33#: Callback data is ``<TOKEN><separator><audit_id>``; the poller splits on
34#: the first separator and refuses anything that is not exactly that.
35CALLBACK_SEPARATOR = ":"
36
37#: Bot API bound on ``callback_data`` (bytes).
38MAX_CALLBACK_DATA = 64
39
40
41class TelegramTransportError(RuntimeError):
42 """A sanitized Bot API failure that never includes the request URL."""
43
44
45def callback_data(token: str, audit_id: str) -> str:
46 """Return the callback data for one vocabulary button on one question."""
47 if token not in REPLY_VOCABULARY:
48 raise ValueError(
49 f"button label {token!r} is not a reply-vocabulary token; buttons may "
50 f"carry only {', '.join(REPLY_VOCABULARY)}"
51 )
52 reference = audit_id.strip()
53 if not reference or reference != audit_id or CALLBACK_SEPARATOR in reference:
54 raise ValueError("inline keyboard callback data needs the send's audit id")
55 data = f"{token}{CALLBACK_SEPARATOR}{reference}"
56 if len(data.encode("utf-8")) > MAX_CALLBACK_DATA:
57 raise ValueError("inline keyboard callback data exceeds the Bot API bound")
58 return data
59
60
61def inline_keyboard(tokens: Sequence[str], audit_id: str) -> dict[str, Any]:
62 """Return a one-row ``InlineKeyboardMarkup`` of vocabulary buttons.
63
64 Every button's callback data is ``<TOKEN>:<audit_id>`` for the SAME audit
65 id, so a tap names the question it answers and nothing else. Labels are
66 the tokens themselves; no URL, web-app, or free-label button can be built
67 here, which is what keeps the structured-only ruling mechanical.
68 """
69 if not tokens:
70 raise ValueError("an inline keyboard needs at least one button")
71 row = [
72 {"text": token, "callback_data": callback_data(token, audit_id)}
73 for token in tokens
74 ]
75 return {"inline_keyboard": [row]}
76
77
79 """Implement the Bot API methods oc_connect needs, environment trust off."""
80
82 self,
83 raw_value: str,
84 *,
85 client: httpx.Client | None = None,
86 api_root: str = DEFAULT_API_ROOT,
87 ) -> None:
88 if not raw_value.strip():
89 raise ValueError("Telegram credential must not be blank")
90 self._raw_value = raw_value.strip()
91 self._api_root = api_root.rstrip("/")
92 self._client = client or httpx.Client(trust_env=False, timeout=10.0)
93
94 def _request(self, method: str, payload: Mapping[str, Any]) -> Any:
95 url = f"{self._api_root}/bot{self._raw_value}/{method}"
96 try:
97 response = self._client.post(url, json=dict(payload))
98 except httpx.HTTPError as exc:
100 f"Telegram {method} request failed ({type(exc).__name__})"
101 ) from None
102 try:
103 body = response.json()
104 except ValueError as exc:
106 f"Telegram {method} returned invalid JSON ({type(exc).__name__})"
107 ) from None
108 if response.status_code >= 400 or not isinstance(body, dict) or not body.get("ok"):
110 f"Telegram {method} was rejected (HTTP {response.status_code})"
111 )
112 _dbg(
113 "OC_CONNECT.TELEGRAM",
114 "_request",
115 f"Bot API method={method} status={response.status_code}",
116 3,
117 )
118 return body.get("result")
119
121 self,
122 *,
123 offset: int | None = None,
124 timeout: int = 0,
125 ) -> list[dict[str, Any]]:
126 """Return available updates, optionally long-polling from *offset*.
127
128 *offset* is the standard Bot API cursor: passing it acknowledges every
129 earlier update, which is what makes the inbound poller's deliberate
130 refusal to advance past a deferred message actually defer it. A
131 non-zero *timeout* is the ยง4 long poll; setup and one-shot CLI reads
132 leave it at zero. ``allowed_updates`` is sent explicitly every time
133 (plan 236): the Bot API persists the last filter it was given, so an
134 omitted list would inherit whatever an earlier caller set.
135 """
136 if timeout < 0:
137 raise ValueError("long-poll timeout must not be negative")
138 payload: dict[str, Any] = {
139 "timeout": timeout,
140 "allowed_updates": list(ALLOWED_UPDATES),
141 }
142 if offset is not None:
143 payload["offset"] = offset
144 result = self._request("getUpdates", payload)
145 if not isinstance(result, list):
146 raise TelegramTransportError("Telegram getUpdates returned a non-list result")
147 return [item for item in result if isinstance(item, dict)]
148
150 self,
151 chat_id: str,
152 text: str,
153 *,
154 reply_markup: Mapping[str, Any] | None = None,
155 ) -> dict[str, Any]:
156 """Send one text message to an already-pinned chat.
157
158 *reply_markup*, when given, is an already-built keyboard (see
159 :func:`inline_keyboard`); a plain send carries no markup key at all.
160 """
161 payload: dict[str, Any] = {"chat_id": chat_id, "text": text}
162 if reply_markup is not None:
163 payload["reply_markup"] = dict(reply_markup)
164 result = self._request("sendMessage", payload)
165 if not isinstance(result, dict):
166 raise TelegramTransportError("Telegram sendMessage returned a non-object result")
167 return result
168
169 def answer_callback_query(self, callback_query_id: str) -> bool:
170 """Acknowledge one button tap so the operator's client stops spinning.
171
172 No text, no alert: the acknowledgement carries nothing but the id.
173 """
174 if not callback_query_id.strip():
175 raise ValueError("a callback acknowledgement needs the callback query id")
176 result = self._request(
177 "answerCallbackQuery", {"callback_query_id": callback_query_id}
178 )
179 return bool(result)
180
181 def close(self) -> None:
182 """Close the owned or injected HTTP client."""
183 self._client.close()
184
185
186__all__ = [
187 "ALLOWED_UPDATES",
188 "CALLBACK_SEPARATOR",
189 "DEFAULT_API_ROOT",
190 "MAX_CALLBACK_DATA",
191 "TelegramTransport",
192 "TelegramTransportError",
193 "callback_data",
194 "inline_keyboard",
195]
dict[str, Any] send_message(self, str chat_id, str text, *, Mapping[str, Any]|None reply_markup=None)
Definition transport.py:155
None __init__(self, str raw_value, *, httpx.Client|None client=None, str api_root=DEFAULT_API_ROOT)
Definition transport.py:87
list[dict[str, Any]] get_updates(self, *, int|None offset=None, int timeout=0)
Definition transport.py:125
Any _request(self, str method, Mapping[str, Any] payload)
Definition transport.py:94
bool answer_callback_query(self, str callback_query_id)
Definition transport.py:169
dict[str, Any] inline_keyboard(Sequence[str] tokens, str audit_id)
Definition transport.py:61
str callback_data(str token, str audit_id)
Definition transport.py:45