oc_connect
Loading...
Searching...
No Matches
poller.py
Go to the documentation of this file.
1"""Inbound transport gates: pinned sender, flood bound, hash-only drop log.
2
3Proposal V1 §4 and §10.4. The poller is the only thing that turns a Telegram
4update into a stored message, and it refuses far more than it accepts:
5
6* **Pinned sender only.** The account id *and* the chat id must both match the
7 destination pinned at setup, and the sender must not be a bot. Everything
8 else is dropped, hash-logged, and never written to the inbox. Dropped
9 updates still advance the cursor: re-reading a hostile sender's message on
10 every poll would be a self-inflicted flood.
11* **Text only.** A v1 message without text is dropped the same way. Widening
12 to attachments is new attack surface and needs its own ruling.
13* **Flood bound.** At most :data:`FLOOD_LIMIT` accepted messages per
14 :data:`FLOOD_WINDOW_SECONDS`. Updates are processed oldest-first, and once
15 the window is full the poller *stops* and leaves the cursor where it is, so
16 the overflow is deferred rather than discarded and arrives in order on a
17 later poll. The overflow itself is logged.
18* **Taps are bound twice (plan 236).** A ``callback_query`` update is
19 admitted only when its sender passes the same pinned check as a message
20 AND its callback data names a SENT question whose transport message is the
21 one the button sits under. Each failed binding is refused by name
22 (:data:`CALLBACK_REFUSALS`), hash-logged, never stored, and never
23 acknowledged. An admitted tap is stored as a structured reply, counts
24 against the flood window like a message, and is acknowledged afterwards;
25 a failed acknowledgement is logged and never loses the tap.
26
27The transport is always injected. There is no code path in this module that
28constructs an HTTP client, which is what keeps the suite free of live calls.
29
30Dependencies
31------------
32- oc_connect.audit (AuditStore, AuditRecord, content_hash, timestamp, utc_now - hash-only received, dropped, overflow and tap-acknowledgement records)
33- oc_connect.comms (CommsBus, new_ulid - the poll cursor path and inbound message ids)
34- oc_connect.connectors.telegram.destinations (TelegramDestination - the pinned account and chat a sender must match)
35- oc_connect.inbound.classify (classify - mechanical classification of each admitted message)
36- oc_connect.inbound.inbox (InboxStore - stores admitted messages and counts the flood window)
37- oc_connect.outbox (Outbox, OutboxItem - correlates replies and taps to the sent question)
38- oc_diagnostics (_dbg structured logger)
39- option_c_connector_contracts (InboundMessage, InboundCorrelation, REPLY_VOCABULARY, lane - message model, correlation and lane risk)
40"""
41from __future__ import annotations
42
43import json
44import os
45import secrets as _secrets
46from collections.abc import Callable
47from dataclasses import dataclass
48from datetime import datetime, timedelta, timezone
49from pathlib import Path
50from typing import Any, Protocol
51
52from oc_diagnostics import _dbg
53from option_c_connector_contracts import (
54 REPLY_VOCABULARY,
55 InboundCorrelation,
56 InboundMessage,
57 lane,
58)
59
60from ..audit import AuditRecord, AuditStore, content_hash, timestamp, utc_now
61from ..comms import CommsBus, new_ulid
62from ..connectors.telegram.destinations import TelegramDestination
63from ..outbox import Outbox, OutboxItem
64from .classify import classify
65from .inbox import InboxStore
66
67FLOOD_LIMIT = 30
68FLOOD_WINDOW_SECONDS = 60.0
69
70INBOUND_LANE = "inbound-telegram"
71OUTBOUND_LANE = "outbound-telegram"
72
73#: Callback data is ``<TOKEN>:<audit_id>``; mirrors the transport's builder.
74CALLBACK_SEPARATOR = ":"
75
76#: Every named reason a tap can be refused for (plan 236 P2).
77CALLBACK_REFUSALS: tuple[str, ...] = (
78 "unpinned_sender",
79 "malformed_callback",
80 "uncorrelated_callback",
81 "callback_message_mismatch",
82)
83
84#: Audit key prefix of the outbound ``answerCallbackQuery`` row for one stored
85#: tap: ``callback_ack-<inbound message id>``. The row's digest is the hash of
86#: the callback id; it carries no content.
87CALLBACK_ACK_KEY_PREFIX = "callback_ack-"
88
89
90class InboundTransport(Protocol):
91 """The Bot API methods the inbound path needs."""
92
94 self,
95 *,
96 offset: int | None = None,
97 timeout: int = 0,
98 ) -> list[dict[str, Any]]: ...
99
100 def answer_callback_query(self, callback_query_id: str) -> bool: ...
101
102 def close(self) -> None: ...
103
104
105@dataclass(frozen=True)
107 """What one poll did, in terms an operator-facing CLI can print."""
108
109 stored: tuple[InboundMessage, ...]
110 dropped: int
111 deferred: int
112
113 @property
114 def accepted(self) -> int:
115 """Return how many messages this poll admitted to the inbox."""
116 return len(self.stored)
117
118
119def parse_callback_data(data: Any) -> tuple[str, str] | None:
120 """Return ``(token, audit_id)`` for well-formed callback data, else None.
121
122 Exact match only: the token must be a :data:`REPLY_VOCABULARY` member as
123 written, and the audit id must be a single non-blank run with no
124 whitespace and no second separator. Nothing is normalised.
125 """
126 if not isinstance(data, str):
127 return None
128 token, separator, audit_id = data.partition(CALLBACK_SEPARATOR)
129 if not separator or token not in REPLY_VOCABULARY:
130 return None
131 if not audit_id or audit_id != audit_id.strip() or any(
132 character.isspace() for character in audit_id
133 ):
134 return None
135 if CALLBACK_SEPARATOR in audit_id:
136 return None
137 return token, audit_id
138
139
140@dataclass(frozen=True)
142 """The outbound send a tap resolved to: its outbox item and SENT record."""
143
144 item: OutboxItem
145 sent: AuditRecord
146
147
149 """Fetch, authenticate, bound, classify, and store inbound updates."""
150
152 self,
153 *,
154 bus: CommsBus,
155 destination: TelegramDestination,
156 transport: InboundTransport,
157 inbox: InboxStore,
158 audit: AuditStore,
159 outbox: Outbox | None = None,
160 now: Callable[[], datetime] = utc_now,
161 flood_limit: int = FLOOD_LIMIT,
162 flood_window_seconds: float = FLOOD_WINDOW_SECONDS,
163 long_poll_seconds: int = 0,
164 entropy: Callable[[], bytes] | None = None,
165 ) -> None:
166 if flood_limit < 1 or flood_window_seconds <= 0:
167 raise ValueError("flood bound values must be positive")
168 self.bus = bus
169 self.destination = destination
170 self.transport = transport
171 self.inbox = inbox
172 self.audit = audit
173 self.outbox = outbox
174 self._now = now
175 self.flood_limit = flood_limit
176 self.flood_window_seconds = flood_window_seconds
177 self.long_poll_seconds = long_poll_seconds
178 self._entropy = entropy
179
180 def _read_cursor(self) -> int | None:
181 path = self.bus.cursor_path
182 if not path.is_file():
183 return None
184 try:
185 payload = json.loads(path.read_text(encoding="utf-8"))
186 except (OSError, UnicodeDecodeError, json.JSONDecodeError):
187 _dbg(
188 "OC_CONNECT.INBOUND",
189 "_read_cursor",
190 f"unreadable cursor at {path}; restarting from the transport default",
191 1,
192 override=True,
193 )
194 return None
195 offset = payload.get("offset") if isinstance(payload, dict) else None
196 return offset if isinstance(offset, int) else None
197
198 def _write_cursor(self, offset: int | None) -> None:
199 if offset is None:
200 return
201 path = self.bus.cursor_path
202 path.parent.mkdir(parents=True, exist_ok=True)
203 temporary = path.with_name(f".tmp-{path.name}-{_secrets.token_hex(8)}")
204 try:
205 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
206 json.dump({"version": 1, "offset": offset}, handle, sort_keys=True)
207 handle.write("\n")
208 handle.flush()
209 os.fsync(handle.fileno())
210 os.replace(temporary, path)
211 finally:
212 if temporary.exists():
213 try:
214 temporary.unlink()
215 except OSError:
216 pass
217
218 def _is_pinned(self, message: dict[str, Any]) -> bool:
219 account = message.get("from")
220 chat = message.get("chat")
221 if not isinstance(account, dict) or not isinstance(chat, dict):
222 return False
223 if account.get("is_bot") is True:
224 return False
225 return (
226 str(account.get("id")) == self.destination.operator_account_id
227 and str(chat.get("id")) == self.destination.chat_id
228 )
229
231 self,
232 *,
233 digest: str,
234 chat_id: str,
235 error_code: str,
236 update_id: int,
237 ) -> None:
238 self.audit.append(
239 connector="telegram",
240 grant_id=None,
241 destination=chat_id,
242 outcome="dropped",
243 digest=digest,
244 idempotency_key=f"update-{update_id}",
245 attempts=0,
246 error_code=error_code,
247 lane=INBOUND_LANE,
248 direction="inbound",
249 risk_class=lane(INBOUND_LANE).risk_ceiling,
250 )
251 _dbg(
252 "OC_CONNECT.INBOUND",
253 "_refuse",
254 f"SECURITY_ERROR: dropped inbound update={update_id} reason={error_code} "
255 f"digest={digest}",
256 1,
257 override=True,
258 )
259
261 self,
262 message: dict[str, Any],
263 *,
264 error_code: str,
265 update_id: int,
266 ) -> None:
267 text = message.get("text")
268 chat = message.get("chat")
269 self._refuse(
270 digest=content_hash(text if isinstance(text, str) else ""),
271 chat_id=str(chat.get("id")) if isinstance(chat, dict) else "unknown",
272 error_code=error_code,
273 update_id=update_id,
274 )
275
276 def _correlate(self, message: dict[str, Any]) -> InboundCorrelation:
277 reply_to = message.get("reply_to_message")
278 if not isinstance(reply_to, dict):
279 return InboundCorrelation(reply_to_transport_ref=None, audit_id=None)
280 reference = reply_to.get("message_id")
281 if reference is None:
282 return InboundCorrelation(reply_to_transport_ref=None, audit_id=None)
283 transport_ref = str(reference)
284 if self.outbox is None:
285 return InboundCorrelation(
286 reply_to_transport_ref=transport_ref, audit_id=None
287 )
288 item = self.outbox.find_by_transport_ref(transport_ref)
289 if item is None:
290 return InboundCorrelation(
291 reply_to_transport_ref=transport_ref, audit_id=None
292 )
293 record = self.audit.latest(item.idempotency_key, outcome="sent")
294 return InboundCorrelation(
295 reply_to_transport_ref=transport_ref,
296 audit_id=record.audit_id if record is not None else None,
297 )
298
299 def _build(self, message: dict[str, Any], received: datetime) -> InboundMessage:
300 text = message["text"]
301 correlation = self._correlate(message)
302 classification, token = classify(
303 text, correlated_audit_id=correlation.audit_id
304 )
305 entropy = self._entropy() if self._entropy is not None else None
306 account = message["from"]
307 chat = message["chat"]
308 return InboundMessage(
309 message_id=new_ulid(received, entropy=entropy),
310 lane=INBOUND_LANE,
311 direction="inbound",
312 risk_class=lane(INBOUND_LANE).risk_ceiling,
313 connector="telegram",
314 transport_ref=str(message.get("message_id", "")) or "unknown",
315 source_account_id=str(account.get("id")),
316 chat_id=str(chat.get("id")),
317 received_at_utc=timestamp(received),
318 text=text,
319 content_hash=content_hash(text),
320 classification=classification,
321 vocabulary_token=token,
322 correlation=correlation,
323 )
324
325 # -- plan 236: callback_query admission ---------------------------------
326
327 def _resolve_question(self, audit_id: str) -> _Question | None:
328 """Return the SENT outbound send an audit id belongs to, or None.
329
330 The id may name any audit record of the send (the keyboard carries the
331 record that existed when the message left; a typed reply reads the
332 ``sent`` one). Resolution is: audit id -> outbound record -> its
333 idempotency key -> a SENT record for that key -> the outbox item in
334 state ``sent``. Any missing link is "uncorrelated".
335 """
336 if self.outbox is None:
337 return None
338 named = next(
339 (record for record in self.audit.read_all() if record.audit_id == audit_id),
340 None,
341 )
342 if named is None or named.direction != "outbound":
343 return None
344 sent = self.audit.latest(named.idempotency_key, outcome="sent")
345 if sent is None:
346 return None
347 try:
348 item = self.outbox.get(named.idempotency_key)
349 except KeyError:
350 return None
351 if item.state != "sent" or item.transport_ref is None:
352 return None
353 return _Question(item=item, sent=sent)
354
356 self,
357 callback: dict[str, Any],
358 *,
359 update_id: int,
360 ) -> tuple[str, _Question] | None:
361 """Apply the double binding; refuse by name or return (token, question)."""
362 message = callback.get("message")
363 chat = message.get("chat") if isinstance(message, dict) else None
364 chat_id = str(chat.get("id")) if isinstance(chat, dict) else "unknown"
365 data = callback.get("data")
366 digest = content_hash(data if isinstance(data, str) else "")
367
368 def refuse(reason: str) -> None:
369 self._refuse(
370 digest=digest, chat_id=chat_id, error_code=reason, update_id=update_id
371 )
372
373 if not self._is_pinned({"from": callback.get("from"), "chat": chat}):
374 refuse("unpinned_sender")
375 return None
376 parsed = parse_callback_data(data)
377 if parsed is None:
378 refuse("malformed_callback")
379 return None
380 token, audit_id = parsed
381 question = self._resolve_question(audit_id)
382 if question is None:
383 refuse("uncorrelated_callback")
384 return None
385 tapped = message.get("message_id") if isinstance(message, dict) else None
386 if tapped is None or str(tapped) != question.item.transport_ref:
387 refuse("callback_message_mismatch")
388 return None
389 return token, question
390
392 self,
393 callback: dict[str, Any],
394 received: datetime,
395 *,
396 token: str,
397 question: _Question,
398 ) -> InboundMessage:
399 classification, vocabulary_token = classify(
400 token, correlated_audit_id=question.sent.audit_id
401 )
402 entropy = self._entropy() if self._entropy is not None else None
403 account = callback["from"]
404 chat = callback["message"]["chat"]
405 callback_id = str(callback.get("id", "")) or "unknown"
406 return InboundMessage(
407 message_id=new_ulid(received, entropy=entropy),
408 lane=INBOUND_LANE,
409 direction="inbound",
410 risk_class=lane(INBOUND_LANE).risk_ceiling,
411 connector="telegram",
412 transport_ref=callback_id,
413 source_account_id=str(account.get("id")),
414 chat_id=str(chat.get("id")),
415 received_at_utc=timestamp(received),
416 text=token,
417 content_hash=content_hash(token),
418 classification=classification,
419 vocabulary_token=vocabulary_token,
420 correlation=InboundCorrelation(
421 reply_to_transport_ref=question.item.transport_ref,
422 audit_id=question.sent.audit_id,
423 source="callback",
424 ),
425 callback_query_id=callback_id,
426 )
427
428 def _acknowledge(self, stored: InboundMessage) -> None:
429 """Answer one stored tap; a failure is logged and never unstores it."""
430 func = "_acknowledge"
431 callback_id = stored.callback_query_id or ""
432 error_code: str | None = None
433 try:
434 self.transport.answer_callback_query(callback_id)
435 except Exception as exc: # noqa: BLE001 - any transport failure is logged
436 error_code = type(exc).__name__
437 _dbg(
438 "OC_CONNECT.INBOUND",
439 func,
440 # OCT-LINT: disable=no_hardcoded_secrets reason="Diagnostic prose naming the Bot API method answerCallbackQuery and the S6 class EXTERNAL_ERROR; scores 4.69 bits/char and carries no credential. Operator-ruled 2026-09-05 (plan 236 escalation, line-scoped)"
441 f"EXTERNAL_ERROR: answerCallbackQuery failed for message="
442 f"{stored.message_id} error={error_code}; the tap is kept",
443 1,
444 override=True,
445 )
446 self.audit.append(
447 connector="telegram",
448 grant_id=None,
449 destination=stored.chat_id,
450 outcome="failed" if error_code else "sent",
451 digest=content_hash(callback_id),
452 idempotency_key=f"{CALLBACK_ACK_KEY_PREFIX}{stored.message_id}",
453 attempts=1,
454 error_code=error_code,
455 lane=OUTBOUND_LANE,
456 direction="outbound",
457 risk_class=lane(OUTBOUND_LANE).risk_ceiling,
458 )
459
460 def _store(self, built: InboundMessage) -> None:
461 self.inbox.store(built)
462 self.audit.append(
463 connector="telegram",
464 grant_id=None,
465 destination=built.chat_id,
466 outcome="received",
467 digest=built.content_hash,
468 idempotency_key=built.message_id,
469 attempts=0,
470 lane=built.lane,
471 direction=built.direction,
472 risk_class=built.risk_class,
473 )
474
475 def poll(self) -> PollResult:
476 """Run one fetch-and-admit cycle and return what it did."""
477 func = "poll"
478 offset = self._read_cursor()
479 updates = self.transport.get_updates(
480 offset=offset, timeout=self.long_poll_seconds
481 )
482 ordered = sorted(
483 (
484 update
485 for update in updates
486 if isinstance(update, dict) and isinstance(update.get("update_id"), int)
487 ),
488 key=lambda update: update["update_id"],
489 )
490 received = self._now().astimezone(timezone.utc)
491 cutoff = received - timedelta(seconds=self.flood_window_seconds)
492 admitted_in_window = self.inbox.count_since(cutoff)
493 stored: list[InboundMessage] = []
494 dropped = 0
495 deferred = 0
496 next_offset = offset
497 for index, update in enumerate(ordered):
498 update_id = update["update_id"]
499 message = update.get("message")
500 callback = update.get("callback_query")
501 built: InboundMessage | None = None
502 if isinstance(callback, dict):
503 admitted = self._admit_callback(callback, update_id=update_id)
504 if admitted is None:
505 dropped += 1
506 next_offset = update_id + 1
507 continue
508 if admitted_in_window >= self.flood_limit:
509 deferred = len(ordered) - index
510 self._log_overflow(deferred, update_id=update_id)
511 break
512 token, question = admitted
513 built = self._build_callback(
514 callback, received, token=token, question=question
515 )
516 elif isinstance(message, dict):
517 if not self._is_pinned(message):
518 self._log_refusal(
519 message, error_code="unpinned_sender", update_id=update_id
520 )
521 dropped += 1
522 next_offset = update_id + 1
523 continue
524 if not isinstance(message.get("text"), str):
525 self._log_refusal(
526 message,
527 error_code="unsupported_message_kind",
528 update_id=update_id,
529 )
530 dropped += 1
531 next_offset = update_id + 1
532 continue
533 if admitted_in_window >= self.flood_limit:
534 deferred = len(ordered) - index
535 self._log_overflow(deferred, update_id=update_id)
536 break
537 built = self._build(message, received)
538 else:
539 next_offset = update_id + 1
540 continue
541 self._store(built)
542 stored.append(built)
543 admitted_in_window += 1
544 next_offset = update_id + 1
545 if built.callback_query_id is not None:
546 self._acknowledge(built)
547 self._write_cursor(next_offset)
548 _dbg(
549 "OC_CONNECT.INBOUND",
550 func,
551 f"poll admitted={len(stored)} dropped={dropped} deferred={deferred}",
552 2,
553 )
554 return PollResult(stored=tuple(stored), dropped=dropped, deferred=deferred)
555
556 def _log_overflow(self, deferred: int, *, update_id: int) -> None:
557 self.audit.append(
558 connector="telegram",
559 grant_id=None,
560 destination=self.destination.chat_id,
561 outcome="overflow",
562 digest=content_hash(""),
563 idempotency_key=f"update-{update_id}",
564 attempts=deferred,
565 error_code="inbound_flood_cap",
566 lane=INBOUND_LANE,
567 direction="inbound",
568 risk_class=lane(INBOUND_LANE).risk_ceiling,
569 )
570 _dbg(
571 "OC_CONNECT.INBOUND",
572 "_log_overflow",
573 f"inbound flood cap reached; deferred={deferred} from update={update_id}",
574 1,
575 override=True,
576 )
577
578
579__all__ = [
580 "CALLBACK_ACK_KEY_PREFIX",
581 "CALLBACK_REFUSALS",
582 "CALLBACK_SEPARATOR",
583 "FLOOD_LIMIT",
584 "FLOOD_WINDOW_SECONDS",
585 "INBOUND_LANE",
586 "InboundPoller",
587 "InboundTransport",
588 "PollResult",
589 "parse_callback_data",
590]
None _refuse(self, *, str digest, str chat_id, str error_code, int update_id)
Definition poller.py:237
bool _is_pinned(self, dict[str, Any] message)
Definition poller.py:218
None __init__(self, *, CommsBus bus, TelegramDestination destination, InboundTransport transport, InboxStore inbox, AuditStore audit, Outbox|None outbox=None, Callable[[], datetime] now=utc_now, int flood_limit=FLOOD_LIMIT, float flood_window_seconds=FLOOD_WINDOW_SECONDS, int long_poll_seconds=0, Callable[[], bytes]|None entropy=None)
Definition poller.py:165
None _write_cursor(self, int|None offset)
Definition poller.py:198
None _store(self, InboundMessage built)
Definition poller.py:460
InboundMessage _build_callback(self, dict[str, Any] callback, datetime received, *, str token, _Question question)
Definition poller.py:398
InboundMessage _build(self, dict[str, Any] message, datetime received)
Definition poller.py:299
InboundCorrelation _correlate(self, dict[str, Any] message)
Definition poller.py:276
tuple[str, _Question]|None _admit_callback(self, dict[str, Any] callback, *, int update_id)
Definition poller.py:360
None _acknowledge(self, InboundMessage stored)
Definition poller.py:428
_Question|None _resolve_question(self, str audit_id)
Definition poller.py:327
None _log_overflow(self, int deferred, *, int update_id)
Definition poller.py:556
None _log_refusal(self, dict[str, Any] message, *, str error_code, int update_id)
Definition poller.py:266
bool answer_callback_query(self, str callback_query_id)
Definition poller.py:100
list[dict[str, Any]] get_updates(self, *, int|None offset=None, int timeout=0)
Definition poller.py:98
tuple[str, str]|None parse_callback_data(Any data)
Definition poller.py:119