1"""Thin raw Telegram Bot API transport over httpx.
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.
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)
18from __future__
import annotations
20from collections.abc
import Mapping, Sequence
24from oc_diagnostics
import _dbg
25from option_c_connector_contracts
import REPLY_VOCABULARY
27DEFAULT_API_ROOT =
"https://api.telegram.org"
31ALLOWED_UPDATES: tuple[str, ...] = (
"message",
"callback_query")
35CALLBACK_SEPARATOR =
":"
42 """A sanitized Bot API failure that never includes the request URL."""
46 """Return the callback data for one vocabulary button on one question."""
47 if token
not in REPLY_VOCABULARY:
49 f
"button label {token!r} is not a reply-vocabulary token; buttons may "
50 f
"carry only {', '.join(REPLY_VOCABULARY)}"
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")
62 """Return a one-row ``InlineKeyboardMarkup`` of vocabulary buttons.
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.
70 raise ValueError(
"an inline keyboard needs at least one button")
72 {
"text": token,
"callback_data":
callback_data(token, audit_id)}
75 return {
"inline_keyboard": [row]}
79 """Implement the Bot API methods oc_connect needs, environment trust off."""
85 client: httpx.Client |
None =
None,
86 api_root: str = DEFAULT_API_ROOT,
88 if not raw_value.strip():
89 raise ValueError(
"Telegram credential must not be blank")
92 self.
_client = client
or httpx.Client(trust_env=
False, timeout=10.0)
94 def _request(self, method: str, payload: Mapping[str, Any]) -> Any:
95 url = f
"{self._api_root}/bot{self._raw_value}/{method}"
97 response = self.
_client.post(url, json=dict(payload))
98 except httpx.HTTPError
as exc:
100 f
"Telegram {method} request failed ({type(exc).__name__})"
103 body = response.json()
104 except ValueError
as exc:
106 f
"Telegram {method} returned invalid JSON ({type(exc).__name__})"
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})"
113 "OC_CONNECT.TELEGRAM",
115 f
"Bot API method={method} status={response.status_code}",
118 return body.get(
"result")
123 offset: int |
None =
None,
125 ) -> list[dict[str, Any]]:
126 """Return available updates, optionally long-polling from *offset*.
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.
137 raise ValueError(
"long-poll timeout must not be negative")
138 payload: dict[str, Any] = {
140 "allowed_updates": list(ALLOWED_UPDATES),
142 if offset
is not None:
143 payload[
"offset"] = offset
144 result = self.
_request(
"getUpdates", payload)
145 if not isinstance(result, list):
147 return [item
for item
in result
if isinstance(item, dict)]
154 reply_markup: Mapping[str, Any] |
None =
None,
156 """Send one text message to an already-pinned chat.
158 *reply_markup*, when given, is an already-built keyboard (see
159 :func:`inline_keyboard`); a plain send carries no markup key at all.
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):
170 """Acknowledge one button tap so the operator's client stops spinning.
172 No text, no alert: the acknowledgement carries nothing but the id.
174 if not callback_query_id.strip():
175 raise ValueError(
"a callback acknowledgement needs the callback query id")
177 "answerCallbackQuery", {
"callback_query_id": callback_query_id}
182 """Close the owned or injected HTTP client."""
188 "CALLBACK_SEPARATOR",
192 "TelegramTransportError",
dict[str, Any] send_message(self, str chat_id, str text, *, Mapping[str, Any]|None reply_markup=None)
None __init__(self, str raw_value, *, httpx.Client|None client=None, str api_root=DEFAULT_API_ROOT)
list[dict[str, Any]] get_updates(self, *, int|None offset=None, int timeout=0)
Any _request(self, str method, Mapping[str, Any] payload)
bool answer_callback_query(self, str callback_query_id)
dict[str, Any] inline_keyboard(Sequence[str] tokens, str audit_id)
str callback_data(str token, str audit_id)