oc_connect
Loading...
Searching...
No Matches
setup_flow.py
Go to the documentation of this file.
1"""Corrected operator-initiated Telegram setup flow.
2
3Dependencies
4------------
5- oc_connect.connectors.telegram.destinations (DestinationStore, TelegramDestination - records the pinned operator destination)
6- oc_connect.connectors.telegram.transport (TelegramTransport - the default transport for update discovery and the test send)
7- oc_connect.credentials (CredentialBroker - stores the bot token inside a rollback transaction)
8- oc_connect.egress (require_safe - the default egress check on the test message)
9- oc_diagnostics (_dbg structured logger)
10"""
11from __future__ import annotations
12
13import getpass
14from collections.abc import Callable
15from typing import Any, Protocol
16
17from oc_diagnostics import _dbg
18
19from ...credentials import CredentialBroker
20from ...egress import require_safe
21from .destinations import DestinationStore, TelegramDestination
22from .transport import TelegramTransport
23
24TELEGRAM_CREDENTIAL_NAME = "telegram-operator-bot"
25TEST_MESSAGE = "OC Connect is live."
26
27
28class SetupCancelled(RuntimeError):
29 """The operator cancelled setup; all local writes were rolled back."""
30
31
32class TelegramSetupError(RuntimeError):
33 """Setup could not identify a valid operator-initiated update."""
34
35
36class SetupTransport(Protocol):
37 """The two Bot API methods needed during setup."""
38
39 def get_updates(self) -> list[dict[str, Any]]: ...
40
41 def send_message(self, chat_id: str, text: str) -> dict[str, Any]: ...
42
43 def close(self) -> None: ...
44
45
46def _default_transport(raw_value: str) -> TelegramTransport:
47 return TelegramTransport(raw_value)
48
49
51 """Capture, pin, test, and commit one operator destination atomically."""
52
54 self,
55 *,
56 broker: CredentialBroker,
57 destinations: DestinationStore,
58 transport_factory: Callable[[str], SetupTransport] = _default_transport,
59 token_reader: Callable[[str], str] = getpass.getpass,
60 input_reader: Callable[[str], str] = input,
61 output: Callable[[str], None] = print,
62 egress_check: Callable[[str], None] = require_safe,
63 ) -> None:
64 self.broker = broker
65 self.destinations = destinations
66 self.transport_factory = transport_factory
67 self.token_reader = token_reader
68 self.input_reader = input_reader
69 self.output = output
70 self.egress_check = egress_check
71
72 def _read_token(self) -> str:
73 try:
74 raw_value = self.token_reader(
75 "Telegram bot token (input hidden, Enter to cancel): "
76 )
77 except (EOFError, KeyboardInterrupt):
78 raise SetupCancelled("Telegram setup cancelled at credential capture") from None
79 if raw_value is None or not raw_value.strip():
80 raise SetupCancelled("Telegram setup cancelled at credential capture")
81 return raw_value.strip()
82
83 def _answer(self, prompt: str) -> str:
84 try:
85 return self.input_reader(prompt).strip()
86 except (EOFError, KeyboardInterrupt):
87 raise SetupCancelled("Telegram setup cancelled") from None
88
89 @staticmethod
90 def _operator_update(updates: list[dict[str, Any]]) -> tuple[str, str]:
91 for update in reversed(updates):
92 message = update.get("message")
93 if not isinstance(message, dict):
94 continue
95 account = message.get("from")
96 chat = message.get("chat")
97 if not isinstance(account, dict) or not isinstance(chat, dict):
98 continue
99 if account.get("is_bot") is True:
100 continue
101 account_id = account.get("id")
102 chat_id = chat.get("id")
103 if account_id is not None and chat_id is not None:
104 return str(account_id), str(chat_id)
105 raise TelegramSetupError(
106 "no operator-authored Telegram message was found; message the bot and retry"
107 )
108
109 def run(self) -> TelegramDestination:
110 """Run setup, committing local state only after the test send succeeds."""
111 self.output(
112 "Create the bot with BotFather, then provide its token. The bot cannot "
113 "start a conversation or create a group."
114 )
115 raw_value = self._read_token()
116 with self.broker.transaction():
117 self.broker.store(TELEGRAM_CREDENTIAL_NAME, raw_value)
118 transport = self.transport_factory(raw_value)
119 try:
120 self.output(
121 "Now message the bot from your operator account. A private direct "
122 "chat is the Phase 1 default."
123 )
124 answer = self._answer(
125 "Press Enter after sending the message, or type cancel: "
126 )
127 if answer.lower() == "cancel":
128 raise SetupCancelled("Telegram setup cancelled before discovery")
129 account_id, chat_id = self._operator_update(transport.get_updates())
130 self.output(
131 f"Found operator account {account_id} in chat {chat_id}."
132 )
133 confirmed = self._answer("Pin this account and chat? [y/N]: ")
134 if confirmed.lower() not in ("y", "yes"):
135 raise SetupCancelled("Telegram setup cancelled before pinning")
136 self.egress_check(TEST_MESSAGE)
137 transport.send_message(chat_id, TEST_MESSAGE)
138 destination = self.destinations.record(
139 name="operator",
140 chat_id=chat_id,
141 operator_account_id=account_id,
142 credential_name=TELEGRAM_CREDENTIAL_NAME,
143 )
144 _dbg(
145 "OC_CONNECT.TELEGRAM",
146 "run",
147 f"setup complete destination={destination.name} "
148 f"account={account_id} chat={chat_id}",
149 2,
150 )
151 return destination
152 finally:
153 transport.close()
154
155
156__all__ = [
157 "SetupCancelled",
158 "TELEGRAM_CREDENTIAL_NAME",
159 "TEST_MESSAGE",
160 "TelegramSetup",
161 "TelegramSetupError",
162]
dict[str, Any] send_message(self, str chat_id, str text)
Definition setup_flow.py:41
None __init__(self, *, CredentialBroker broker, DestinationStore destinations, Callable[[str], SetupTransport] transport_factory=_default_transport, Callable[[str], str] token_reader=getpass.getpass, Callable[[str], str] input_reader=input, Callable[[str], None] output=print, Callable[[str], None] egress_check=require_safe)
Definition setup_flow.py:63
tuple[str, str] _operator_update(list[dict[str, Any]] updates)
Definition setup_flow.py:90
TelegramTransport _default_transport(str raw_value)
Definition setup_flow.py:46