1"""Inbound transport gates: pinned sender, flood bound, hash-only drop log.
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:
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.
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.
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)
41from __future__
import annotations
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
52from oc_diagnostics
import _dbg
53from option_c_connector_contracts
import (
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
68FLOOD_WINDOW_SECONDS = 60.0
70INBOUND_LANE =
"inbound-telegram"
71OUTBOUND_LANE =
"outbound-telegram"
74CALLBACK_SEPARATOR =
":"
77CALLBACK_REFUSALS: tuple[str, ...] = (
80 "uncorrelated_callback",
81 "callback_message_mismatch",
87CALLBACK_ACK_KEY_PREFIX =
"callback_ack-"
91 """The Bot API methods the inbound path needs."""
96 offset: int |
None =
None,
98 ) -> list[dict[str, Any]]: ...
105@dataclass(frozen=True)
107 """What one poll did, in terms an operator-facing CLI can print."""
109 stored: tuple[InboundMessage, ...]
115 """Return how many messages this poll admitted to the inbox."""
120 """Return ``(token, audit_id)`` for well-formed callback data, else None.
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.
126 if not isinstance(data, str):
128 token, separator, audit_id = data.partition(CALLBACK_SEPARATOR)
129 if not separator
or token
not in REPLY_VOCABULARY:
131 if not audit_id
or audit_id != audit_id.strip()
or any(
132 character.isspace()
for character
in audit_id
135 if CALLBACK_SEPARATOR
in audit_id:
137 return token, audit_id
140@dataclass(frozen=True)
142 """The outbound send a tap resolved to: its outbox item and SENT record."""
149 """Fetch, authenticate, bound, classify, and store inbound updates."""
155 destination: TelegramDestination,
156 transport: InboundTransport,
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,
166 if flood_limit < 1
or flood_window_seconds <= 0:
167 raise ValueError(
"flood bound values must be positive")
181 path = self.
bus.cursor_path
182 if not path.is_file():
185 payload = json.loads(path.read_text(encoding=
"utf-8"))
186 except (OSError, UnicodeDecodeError, json.JSONDecodeError):
188 "OC_CONNECT.INBOUND",
190 f
"unreadable cursor at {path}; restarting from the transport default",
195 offset = payload.get(
"offset")
if isinstance(payload, dict)
else None
196 return offset
if isinstance(offset, int)
else None
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)}")
205 with temporary.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
206 json.dump({
"version": 1,
"offset": offset}, handle, sort_keys=
True)
209 os.fsync(handle.fileno())
210 os.replace(temporary, path)
212 if temporary.exists():
219 account = message.get(
"from")
220 chat = message.get(
"chat")
221 if not isinstance(account, dict)
or not isinstance(chat, dict):
223 if account.get(
"is_bot")
is True:
226 str(account.get(
"id")) == self.
destination.operator_account_id
227 and str(chat.get(
"id")) == self.
destination.chat_id
239 connector=
"telegram",
244 idempotency_key=f
"update-{update_id}",
246 error_code=error_code,
249 risk_class=lane(INBOUND_LANE).risk_ceiling,
252 "OC_CONNECT.INBOUND",
254 f
"SECURITY_ERROR: dropped inbound update={update_id} reason={error_code} "
262 message: dict[str, Any],
267 text = message.get(
"text")
268 chat = message.get(
"chat")
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,
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)
285 return InboundCorrelation(
286 reply_to_transport_ref=transport_ref, audit_id=
None
288 item = self.
outbox.find_by_transport_ref(transport_ref)
290 return InboundCorrelation(
291 reply_to_transport_ref=transport_ref, audit_id=
None
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,
299 def _build(self, message: dict[str, Any], received: datetime) -> InboundMessage:
300 text = message[
"text"]
302 classification, token = classify(
303 text, correlated_audit_id=correlation.audit_id
306 account = message[
"from"]
307 chat = message[
"chat"]
308 return InboundMessage(
309 message_id=new_ulid(received, entropy=entropy),
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),
319 content_hash=content_hash(text),
320 classification=classification,
321 vocabulary_token=token,
322 correlation=correlation,
328 """Return the SENT outbound send an audit id belongs to, or None.
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".
339 (record
for record
in self.
audit.read_all()
if record.audit_id == audit_id),
342 if named
is None or named.direction !=
"outbound":
344 sent = self.
audit.latest(named.idempotency_key, outcome=
"sent")
348 item = self.
outbox.get(named.idempotency_key)
351 if item.state !=
"sent" or item.transport_ref
is None:
357 callback: dict[str, Any],
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 "")
368 def refuse(reason: str) ->
None:
370 digest=digest, chat_id=chat_id, error_code=reason, update_id=update_id
373 if not self.
_is_pinned({
"from": callback.get(
"from"),
"chat": chat}):
374 refuse(
"unpinned_sender")
378 refuse(
"malformed_callback")
380 token, audit_id = parsed
383 refuse(
"uncorrelated_callback")
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")
389 return token, question
393 callback: dict[str, Any],
399 classification, vocabulary_token = classify(
400 token, correlated_audit_id=question.sent.audit_id
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),
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),
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,
425 callback_query_id=callback_id,
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
434 self.
transport.answer_callback_query(callback_id)
435 except Exception
as exc:
436 error_code = type(exc).__name__
438 "OC_CONNECT.INBOUND",
441 f
"EXTERNAL_ERROR: answerCallbackQuery failed for message="
442 f
"{stored.message_id} error={error_code}; the tap is kept",
447 connector=
"telegram",
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}",
454 error_code=error_code,
456 direction=
"outbound",
457 risk_class=lane(OUTBOUND_LANE).risk_ceiling,
460 def _store(self, built: InboundMessage) ->
None:
461 self.
inbox.store(built)
463 connector=
"telegram",
465 destination=built.chat_id,
467 digest=built.content_hash,
468 idempotency_key=built.message_id,
471 direction=built.direction,
472 risk_class=built.risk_class,
476 """Run one fetch-and-admit cycle and return what it did."""
485 for update
in updates
486 if isinstance(update, dict)
and isinstance(update.get(
"update_id"), int)
488 key=
lambda update: update[
"update_id"],
490 received = self.
_now().astimezone(timezone.utc)
492 admitted_in_window = self.
inbox.count_since(cutoff)
493 stored: list[InboundMessage] = []
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):
506 next_offset = update_id + 1
509 deferred = len(ordered) - index
512 token, question = admitted
514 callback, received, token=token, question=question
516 elif isinstance(message, dict):
519 message, error_code=
"unpinned_sender", update_id=update_id
522 next_offset = update_id + 1
524 if not isinstance(message.get(
"text"), str):
527 error_code=
"unsupported_message_kind",
531 next_offset = update_id + 1
534 deferred = len(ordered) - index
537 built = self.
_build(message, received)
539 next_offset = update_id + 1
543 admitted_in_window += 1
544 next_offset = update_id + 1
545 if built.callback_query_id
is not None:
549 "OC_CONNECT.INBOUND",
551 f
"poll admitted={len(stored)} dropped={dropped} deferred={deferred}",
554 return PollResult(stored=tuple(stored), dropped=dropped, deferred=deferred)
558 connector=
"telegram",
562 digest=content_hash(
""),
563 idempotency_key=f
"update-{update_id}",
565 error_code=
"inbound_flood_cap",
568 risk_class=lane(INBOUND_LANE).risk_ceiling,
571 "OC_CONNECT.INBOUND",
573 f
"inbound flood cap reached; deferred={deferred} from update={update_id}",
580 "CALLBACK_ACK_KEY_PREFIX",
582 "CALLBACK_SEPARATOR",
584 "FLOOD_WINDOW_SECONDS",
589 "parse_callback_data",
None _refuse(self, *, str digest, str chat_id, str error_code, int update_id)
int|None _read_cursor(self)
bool _is_pinned(self, dict[str, Any] message)
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)
None _write_cursor(self, int|None offset)
None _store(self, InboundMessage built)
InboundMessage _build_callback(self, dict[str, Any] callback, datetime received, *, str token, _Question question)
InboundMessage _build(self, dict[str, Any] message, datetime received)
InboundCorrelation _correlate(self, dict[str, Any] message)
tuple[str, _Question]|None _admit_callback(self, dict[str, Any] callback, *, int update_id)
None _acknowledge(self, InboundMessage stored)
_Question|None _resolve_question(self, str audit_id)
None _log_overflow(self, int deferred, *, int update_id)
None _log_refusal(self, dict[str, Any] message, *, str error_code, int update_id)
bool answer_callback_query(self, str callback_query_id)
list[dict[str, Any]] get_updates(self, *, int|None offset=None, int timeout=0)
tuple[str, str]|None parse_callback_data(Any data)