oc_connect
Loading...
Searching...
No Matches
delivery.py
Go to the documentation of this file.
1"""Grant-checked, filtered, durable outbound delivery orchestration.
2
3Dependencies
4------------
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)
15"""
16from __future__ import annotations
17
18from collections.abc import Callable, Mapping, Sequence
19from typing import Any, Protocol
20
21from oc_diagnostics import _dbg
22from option_c_connector_contracts import REPLY_VOCABULARY, DeliveryReceipt
23
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
32
33#: The authority sentence for ``--choices`` (plan 236 P5). It is printed in
34#: the ``send`` help and is the rule the orchestrator applies when deciding
35#: which questions get buttons.
36CHOICES_RULE = (
37 "Security rulings (scanner waivers, thresholds, M8 files) are NEVER sent "
38 "with --choices; buttons carry operational decisions only."
39)
40
41
42class DeliveryConnector(Protocol):
43 """Runtime adapter that can deliver one outbox item.
44
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.
51 """
52
54 self,
55 item: OutboxItem,
56 *,
57 reply_markup: Mapping[str, Any] | None = None,
58 ) -> str | None: ...
59
60
61def parse_choices(raw: Sequence[str]) -> tuple[str, ...]:
62 """Return the vocabulary tokens a keyboard may carry, or refuse by name.
63
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.
68 """
69 accepted: list[str] = []
70 for label in raw:
71 candidate = label.strip()
72 if candidate not in REPLY_VOCABULARY:
73 raise ValueError(
74 f"choice {candidate!r} is not a reply-vocabulary token; buttons "
75 f"may carry only {', '.join(REPLY_VOCABULARY)}"
76 )
77 if candidate in accepted:
78 raise ValueError(f"choice {candidate!r} is listed more than once")
79 accepted.append(candidate)
80 if not accepted:
81 raise ValueError("--choices needs at least one reply-vocabulary token")
82 return tuple(accepted)
83
84
86 """Enforce the complete outbound authorization and delivery pipeline."""
87
89 self,
90 *,
91 broker: CredentialBroker,
92 destinations: DestinationStore,
93 grants: GrantStore,
94 outbox: Outbox,
95 audit: AuditStore,
96 connectors: ConnectorRegistry | None = None,
97 ) -> None:
98 self.broker = broker
99 self.destinations = destinations
100 self.grants = grants
101 self.outbox = outbox
102 self.audit = audit
103 self.connectors = connectors or load_builtin_connectors()
104
105 def _connector(self, name: str) -> DeliveryConnector:
106 factory = self.connectors.get(name)
107 connector = (
108 factory(self.broker, self.destinations)
109 if callable(factory)
110 else factory
111 )
112 if not hasattr(connector, "deliver"):
113 raise TypeError(f"connector {name!r} does not implement delivery")
114 return connector # type: ignore[return-value]
115
117 self,
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.
123
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).
130 """
131 if tokens is None:
132 return connector.deliver
133 question = self.audit.latest(idempotency_key)
134 if question is None:
135 raise RuntimeError(
136 f"no audit record for {idempotency_key!r}; a keyboard needs the "
137 "send's audit id"
138 )
139 markup = inline_keyboard(tokens, question.audit_id)
140
141 def deliver(item: OutboxItem) -> str | None:
142 return connector.deliver(item, reply_markup=markup)
143
144 return deliver
145
146 def send(
147 self,
148 *,
149 grantee: str,
150 destination: str,
151 text: str,
152 grant_id: str,
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.
158
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.
163
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.
168 """
169 tokens = parse_choices(choices) if choices is not None else None
170 target = self.destinations.get(destination)
171 if risk_class is None:
172 risk_class = self.grants.resolve_send_risk(grant_id)
173 self.grants.require_send_grant(
174 grant_id=grant_id,
175 grantee=grantee,
176 connector=target.connector,
177 destination=destination,
178 risk_class=risk_class,
179 )
180 try:
181 require_safe(text)
182 except EgressBlockedError:
183 self.audit.append(
184 connector=target.connector,
185 grant_id=grant_id,
186 destination=destination,
187 outcome="blocked",
188 digest=content_hash(text),
189 idempotency_key=idempotency_key,
190 attempts=0,
191 error_code="egress_finding",
192 risk_class=risk_class,
193 )
194 _dbg(
195 "OC_CONNECT.SEND",
196 "send",
197 f"connector={target.connector} grant={grant_id} "
198 f"destination={destination} outcome=blocked",
199 1,
200 override=True,
201 )
202 raise
203 item = self.outbox.enqueue(
204 connector=target.connector,
205 grant_id=grant_id,
206 destination=destination,
207 content=text,
208 idempotency_key=idempotency_key,
209 risk_class=risk_class,
210 )
211 receipt = self.outbox.process(
212 idempotency_key,
213 self._deliver_with(
214 self._connector(target.connector), item.idempotency_key, tokens
215 ),
216 )
217 _dbg(
218 "OC_CONNECT.SEND",
219 "send",
220 f"connector={target.connector} grant={grant_id} "
221 f"destination={destination} outcome={receipt.outcome} "
222 f"choices={len(tokens) if tokens else 0}",
223 2,
224 )
225 return receipt
226
227
228__all__ = ["CHOICES_RULE", "DeliveryConnector", "OutboundSender", "parse_choices"]
str|None deliver(self, OutboxItem item, *, Mapping[str, Any]|None reply_markup=None)
Definition delivery.py:58
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)
Definition delivery.py:156
Callable[[OutboxItem], str|None] _deliver_with(self, DeliveryConnector connector, str idempotency_key, tuple[str,...]|None tokens)
Definition delivery.py:121
None __init__(self, *, CredentialBroker broker, DestinationStore destinations, GrantStore grants, Outbox outbox, AuditStore audit, ConnectorRegistry|None connectors=None)
Definition delivery.py:97
DeliveryConnector _connector(self, str name)
Definition delivery.py:105
tuple[str,...] parse_choices(Sequence[str] raw)
Definition delivery.py:61