1"""Grant-checked, filtered, durable outbound delivery orchestration.
5- oc_connect.audit (AuditStore, content_hash - audits a blocked send and finds the audit id a keyboard names)
6- oc_connect.connectors.telegram.destinations (DestinationStore - resolves the named destination and its connector)
7- oc_connect.connectors.telegram.transport (inline_keyboard - builds the reply-vocabulary keyboard for --choices)
8- oc_connect.credentials (CredentialBroker - handed to the connector factory)
9- oc_connect.egress (require_safe, EgressBlockedError - the egress filter every send passes)
10- oc_connect.grants (GrantStore - resolves the send risk and requires a matching grant)
11- oc_connect.outbox (Outbox, OutboxItem - queues and processes the durable send)
12- oc_connect.registry (ConnectorRegistry, load_builtin_connectors - finds the connector for a destination)
13- oc_diagnostics (_dbg structured logger)
14- option_c_connector_contracts (REPLY_VOCABULARY, DeliveryReceipt - allowed choices and the send receipt)
16from __future__
import annotations
18from collections.abc
import Callable, Mapping, Sequence
19from typing
import Any, Protocol
21from oc_diagnostics
import _dbg
22from option_c_connector_contracts
import REPLY_VOCABULARY, DeliveryReceipt
24from .audit
import AuditStore, content_hash
25from .connectors.telegram.destinations
import DestinationStore
26from .connectors.telegram.transport
import inline_keyboard
27from .credentials
import CredentialBroker
28from .egress
import EgressBlockedError, require_safe
29from .grants
import GrantStore
30from .outbox
import Outbox, OutboxItem
31from .registry
import ConnectorRegistry, load_builtin_connectors
37 "Security rulings (scanner waivers, thresholds, M8 files) are NEVER sent "
38 "with --choices; buttons carry operational decisions only."
43 """Runtime adapter that can deliver one outbox item.
45 ``deliver`` returns the connector's own message identifier when it has
46 one. That reference is what an inbound reply correlates against (§4);
47 returning ``None`` simply means replies to this message cannot be
48 correlated, never that the send failed. ``reply_markup`` (plan 236) is
49 only ever passed when a send carries choices, so a connector that
50 predates it still delivers every plain send.
57 reply_markup: Mapping[str, Any] |
None =
None,
62 """Return the vocabulary tokens a keyboard may carry, or refuse by name.
64 Structured-only ruling (plan 236 P1): a label is accepted only when it is
65 exactly a :data:`REPLY_VOCABULARY` token. Case is not folded, a URL is a
66 label like any other and is refused the same way, and a repeated token is
67 refused because a keyboard with two identical buttons answers nothing.
69 accepted: list[str] = []
71 candidate = label.strip()
72 if candidate
not in REPLY_VOCABULARY:
74 f
"choice {candidate!r} is not a reply-vocabulary token; buttons "
75 f
"may carry only {', '.join(REPLY_VOCABULARY)}"
77 if candidate
in accepted:
78 raise ValueError(f
"choice {candidate!r} is listed more than once")
79 accepted.append(candidate)
81 raise ValueError(
"--choices needs at least one reply-vocabulary token")
82 return tuple(accepted)
86 """Enforce the complete outbound authorization and delivery pipeline."""
91 broker: CredentialBroker,
92 destinations: DestinationStore,
96 connectors: ConnectorRegistry |
None =
None,
112 if not hasattr(connector,
"deliver"):
113 raise TypeError(f
"connector {name!r} does not implement delivery")
118 connector: DeliveryConnector,
119 idempotency_key: str,
120 tokens: tuple[str, ...] |
None,
121 ) -> Callable[[OutboxItem], str |
None]:
122 """Return the delivery callable, with the keyboard bound when asked.
124 The keyboard's callback data names an audit record of THIS send: the
125 record the outbox wrote when it queued the item under this key. The
126 ``sent`` record does not exist until the transport has returned, so it
127 cannot be inside the message it describes; the poller resolves either
128 id through the key to the SENT record, which is the id a typed reply
129 also reads (plan 236 P2).
132 return connector.deliver
133 question = self.
audit.latest(idempotency_key)
136 f
"no audit record for {idempotency_key!r}; a keyboard needs the "
139 markup = inline_keyboard(tokens, question.audit_id)
141 def deliver(item: OutboxItem) -> str |
None:
142 return connector.deliver(item, reply_markup=markup)
153 risk_class: str |
None =
None,
154 idempotency_key: str,
155 choices: Sequence[str] |
None =
None,
156 ) -> DeliveryReceipt:
157 """Authorize, scan, queue, deliver, and audit one outbound message.
159 A ``risk_class`` of ``None`` means "use the operator grant's recorded
160 class" (CONNECT-02 §7.2). Resolution is downward only: the resolved
161 value is still checked by :meth:`GrantStore.require_send_grant`, so no
162 send is ever authorized by the default rather than by the grant.
164 *choices* (plan 236) attaches a one-row keyboard of reply-vocabulary
165 buttons; anything outside the vocabulary is refused by name before the
166 grant, the filter, or the outbox is touched. The text itself still
167 passes the egress filter unchanged.
169 tokens =
parse_choices(choices)
if choices
is not None else None
171 if risk_class
is None:
172 risk_class = self.
grants.resolve_send_risk(grant_id)
173 self.
grants.require_send_grant(
176 connector=target.connector,
177 destination=destination,
178 risk_class=risk_class,
182 except EgressBlockedError:
184 connector=target.connector,
186 destination=destination,
188 digest=content_hash(text),
189 idempotency_key=idempotency_key,
191 error_code=
"egress_finding",
192 risk_class=risk_class,
197 f
"connector={target.connector} grant={grant_id} "
198 f
"destination={destination} outcome=blocked",
203 item = self.
outbox.enqueue(
204 connector=target.connector,
206 destination=destination,
208 idempotency_key=idempotency_key,
209 risk_class=risk_class,
211 receipt = self.
outbox.process(
214 self.
_connector(target.connector), item.idempotency_key, tokens
220 f
"connector={target.connector} grant={grant_id} "
221 f
"destination={destination} outcome={receipt.outcome} "
222 f
"choices={len(tokens) if tokens else 0}",
228__all__ = [
"CHOICES_RULE",
"DeliveryConnector",
"OutboundSender",
"parse_choices"]
str|None deliver(self, OutboxItem item, *, Mapping[str, Any]|None reply_markup=None)
DeliveryReceipt send(self, *, str grantee, str destination, str text, str grant_id, str|None risk_class=None, str idempotency_key, Sequence[str]|None choices=None)
Callable[[OutboxItem], str|None] _deliver_with(self, DeliveryConnector connector, str idempotency_key, tuple[str,...]|None tokens)
None __init__(self, *, CredentialBroker broker, DestinationStore destinations, GrantStore grants, Outbox outbox, AuditStore audit, ConnectorRegistry|None connectors=None)
DeliveryConnector _connector(self, str name)
tuple[str,...] parse_choices(Sequence[str] raw)