ctrlrelay 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrlrelay/__init__.py +33 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +89 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +740 -0
- ctrlrelay/bridge/telegram_handler.py +136 -0
- ctrlrelay/cli.py +3138 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +778 -0
- ctrlrelay/core/dispatcher.py +372 -0
- ctrlrelay/core/github.py +432 -0
- ctrlrelay/core/network.py +277 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +920 -0
- ctrlrelay/core/pr_verifier.py +230 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/question_expiry.py +193 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +642 -0
- ctrlrelay/core/worktree.py +893 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/gh_protocol.py +47 -0
- ctrlrelay/install.py +203 -0
- ctrlrelay/personalization/__init__.py +26 -0
- ctrlrelay/personalization/manager.py +1183 -0
- ctrlrelay/personalization/paths.py +143 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +1367 -0
- ctrlrelay/pipelines/post_merge.py +411 -0
- ctrlrelay/pipelines/secops.py +1018 -0
- ctrlrelay/pipelines/task.py +713 -0
- ctrlrelay/setup.py +617 -0
- ctrlrelay/templates/__init__.py +0 -0
- ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
- ctrlrelay/templates/launchd/__init__.py +0 -0
- ctrlrelay/templates/launchd/bridge.plist.template +37 -0
- ctrlrelay/templates/launchd/poller.plist.template +44 -0
- ctrlrelay/templates/systemd/__init__.py +0 -0
- ctrlrelay/templates/systemd/bridge.service.template +20 -0
- ctrlrelay/templates/systemd/poller.service.template +25 -0
- ctrlrelay/transports/__init__.py +34 -0
- ctrlrelay/transports/base.py +74 -0
- ctrlrelay/transports/file_mock.py +103 -0
- ctrlrelay/transports/socket_client.py +298 -0
- ctrlrelay-0.10.0.dist-info/METADATA +254 -0
- ctrlrelay-0.10.0.dist-info/RECORD +53 -0
- ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
- ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.10.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""Bridge server for Telegram communication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ctrlrelay.bridge.protocol import (
|
|
16
|
+
BridgeMessage,
|
|
17
|
+
BridgeOp,
|
|
18
|
+
ProtocolError,
|
|
19
|
+
parse_message,
|
|
20
|
+
serialize_message,
|
|
21
|
+
)
|
|
22
|
+
from ctrlrelay.bridge.telegram_handler import (
|
|
23
|
+
TelegramHandler,
|
|
24
|
+
is_ambiguous_delivery,
|
|
25
|
+
)
|
|
26
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ctrlrelay.core.state import StateDB
|
|
30
|
+
|
|
31
|
+
_logger = get_logger("bridge.server")
|
|
32
|
+
_log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Upper bound on the telegram_msg_id -> session_id map. A reply landing
|
|
36
|
+
# further back than this many questions is vanishingly rare, and the map is
|
|
37
|
+
# a routing convenience, not a source of truth — losing an entry degrades to
|
|
38
|
+
# the session_id-substring path, never to a wrong resume.
|
|
39
|
+
_ASKED_SESSIONS_MAX = 500
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_question(
|
|
43
|
+
question: str,
|
|
44
|
+
*,
|
|
45
|
+
repo: str | None = None,
|
|
46
|
+
session_id: str | None = None,
|
|
47
|
+
issue_number: int | None = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Prefix an agent question with the repo and session it belongs to.
|
|
50
|
+
|
|
51
|
+
A secops sweep posts one consolidated question per repo back-to-back,
|
|
52
|
+
and the agent's own text almost never names its repo — the operator sees
|
|
53
|
+
a run of near-identical "approve #387?" messages with nothing to tell
|
|
54
|
+
them apart. The session_id matters just as much: when a reply arrives
|
|
55
|
+
after the ASK socket has timed out, the orphan router can only
|
|
56
|
+
disambiguate between several BLOCKED sessions if the operator can quote
|
|
57
|
+
one, and until now it was never sent to them.
|
|
58
|
+
|
|
59
|
+
Plain text on purpose — TelegramHandler sends without ``parse_mode``, so
|
|
60
|
+
any Markdown here would reach the operator as literal backticks.
|
|
61
|
+
"""
|
|
62
|
+
header: list[str] = []
|
|
63
|
+
if repo:
|
|
64
|
+
line = f"[{repo}]"
|
|
65
|
+
if issue_number is not None:
|
|
66
|
+
line += f" issue #{issue_number}"
|
|
67
|
+
header.append(line)
|
|
68
|
+
if session_id:
|
|
69
|
+
header.append(f"session: {session_id}")
|
|
70
|
+
if not header:
|
|
71
|
+
return question
|
|
72
|
+
header.append("Reply to this message to answer.")
|
|
73
|
+
return "\n".join(header) + "\n\n" + question
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _deadline(received_at: float, timeout: int | None) -> float | None:
|
|
77
|
+
"""Monotonic instant after which a posted question is no longer live.
|
|
78
|
+
|
|
79
|
+
Mirrors the client's own ``ask()`` deadline as closely as the two
|
|
80
|
+
processes allow. The client starts counting when it writes the ASK to
|
|
81
|
+
the socket and ``received_at`` is taken as we read it, so the two clocks
|
|
82
|
+
differ only by unix-socket transit — microseconds. Anchoring here rather
|
|
83
|
+
than after the Telegram post is what matters: that post takes on the
|
|
84
|
+
order of a second, and a deadline set on its far side would leave the
|
|
85
|
+
bridge treating a question as live for that whole second after the
|
|
86
|
+
client had abandoned it.
|
|
87
|
+
|
|
88
|
+
Deliberately no safety margin. Skewing early looks free but is not: a
|
|
89
|
+
reply landing between the bridge's deadline and the client's is routed
|
|
90
|
+
to a ``pending_resumes`` row the still-running pipeline has not written
|
|
91
|
+
yet, so the answer is refused and the operator has to send it again. A
|
|
92
|
+
margin trades a microsecond-wide hazard for a window thousands of times
|
|
93
|
+
wider.
|
|
94
|
+
|
|
95
|
+
``None`` means the caller set no deadline; the entry then lives until
|
|
96
|
+
its socket closes, the pre-existing behaviour. A non-positive timeout
|
|
97
|
+
means the client gave up immediately, so the question is already
|
|
98
|
+
expired rather than eternal.
|
|
99
|
+
"""
|
|
100
|
+
if timeout is None:
|
|
101
|
+
return None
|
|
102
|
+
return received_at + max(float(timeout), 0.0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _PendingQuestion:
|
|
106
|
+
"""Question posted to Telegram, awaiting the operator's reply."""
|
|
107
|
+
|
|
108
|
+
__slots__ = (
|
|
109
|
+
"request_id", "telegram_msg_id", "writer", "expires_at",
|
|
110
|
+
"repo", "session_id",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
request_id: str,
|
|
116
|
+
telegram_msg_id: int,
|
|
117
|
+
writer: asyncio.StreamWriter,
|
|
118
|
+
expires_at: float | None = None,
|
|
119
|
+
repo: str | None = None,
|
|
120
|
+
session_id: str | None = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.request_id = request_id
|
|
123
|
+
self.telegram_msg_id = telegram_msg_id
|
|
124
|
+
self.writer = writer
|
|
125
|
+
self.repo = repo
|
|
126
|
+
self.session_id = session_id
|
|
127
|
+
# Monotonic deadline mirroring the client's own ask() timeout. The
|
|
128
|
+
# client stops waiting at this point and discards the request_id,
|
|
129
|
+
# but it does NOT close the socket — a secops sweep reuses one
|
|
130
|
+
# transport across every repo. Without this the bridge would keep
|
|
131
|
+
# treating the question as live and write the answer into a request
|
|
132
|
+
# nobody is listening for.
|
|
133
|
+
self.expires_at = expires_at
|
|
134
|
+
|
|
135
|
+
def is_expired(self, now: float) -> bool:
|
|
136
|
+
return self.expires_at is not None and now >= self.expires_at
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class BridgeServer:
|
|
140
|
+
"""Unix socket server that bridges to Telegram — bidirectional.
|
|
141
|
+
|
|
142
|
+
Outbound: clients send SEND/ASK over the socket and we hit Telegram.
|
|
143
|
+
Inbound: we long-poll Telegram for messages; when a reply arrives it's
|
|
144
|
+
matched to the oldest outstanding ASK (or by reply_to_message_id if
|
|
145
|
+
available) and we push an ANSWER frame over that client's socket."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
socket_path: Path,
|
|
150
|
+
bot_token: str,
|
|
151
|
+
chat_id: int,
|
|
152
|
+
state_db: "StateDB | None" = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self.socket_path = socket_path
|
|
155
|
+
self.bot_token = bot_token
|
|
156
|
+
self.chat_id = chat_id
|
|
157
|
+
# Optional: when provided, orphan Telegram replies (no live
|
|
158
|
+
# _pending_question to match) are routed to the oldest unanswered
|
|
159
|
+
# BLOCKED session in state_db's pending_resumes table. The poller's
|
|
160
|
+
# pending-resume sweeper then picks up the answer and drives the
|
|
161
|
+
# actual pipeline resume. Without state_db, orphan replies still
|
|
162
|
+
# get a "didn't land" Telegram notice but nothing gets queued.
|
|
163
|
+
self.state_db = state_db
|
|
164
|
+
self._server: asyncio.Server | None = None
|
|
165
|
+
self._running = False
|
|
166
|
+
self._telegram: TelegramHandler | None = None
|
|
167
|
+
# Insertion-ordered so FIFO dispatch is deterministic.
|
|
168
|
+
self._pending_questions: OrderedDict[str, _PendingQuestion] = OrderedDict()
|
|
169
|
+
self._pending_lock = asyncio.Lock()
|
|
170
|
+
# telegram_msg_id -> session_id for every ASK we've posted, kept
|
|
171
|
+
# after the question itself is gone. The pipeline process is what
|
|
172
|
+
# dies on an ASK timeout; this bridge outlives it, so a reply-to on
|
|
173
|
+
# a long-expired question can still name its session exactly instead
|
|
174
|
+
# of falling back to "which of these 12 did you mean?".
|
|
175
|
+
self._asked_sessions: OrderedDict[int, str] = OrderedDict()
|
|
176
|
+
|
|
177
|
+
async def start(self) -> None:
|
|
178
|
+
"""Start the bridge server."""
|
|
179
|
+
self._telegram = TelegramHandler(
|
|
180
|
+
bot_token=self.bot_token,
|
|
181
|
+
chat_id=self.chat_id,
|
|
182
|
+
)
|
|
183
|
+
await self._telegram.start_polling(self._on_telegram_reply)
|
|
184
|
+
|
|
185
|
+
if self.socket_path.exists():
|
|
186
|
+
self.socket_path.unlink()
|
|
187
|
+
|
|
188
|
+
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
self._server = await asyncio.start_unix_server(
|
|
191
|
+
self._handle_client,
|
|
192
|
+
path=str(self.socket_path),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
os.chmod(self.socket_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
196
|
+
|
|
197
|
+
self._running = True
|
|
198
|
+
async with self._server:
|
|
199
|
+
await self._server.serve_forever()
|
|
200
|
+
|
|
201
|
+
async def stop(self) -> None:
|
|
202
|
+
"""Stop the bridge server."""
|
|
203
|
+
self._running = False
|
|
204
|
+
if self._server:
|
|
205
|
+
self._server.close()
|
|
206
|
+
await self._server.wait_closed()
|
|
207
|
+
|
|
208
|
+
if self._telegram:
|
|
209
|
+
await self._telegram.close()
|
|
210
|
+
|
|
211
|
+
if self.socket_path.exists():
|
|
212
|
+
self.socket_path.unlink()
|
|
213
|
+
|
|
214
|
+
async def _handle_client(
|
|
215
|
+
self,
|
|
216
|
+
reader: asyncio.StreamReader,
|
|
217
|
+
writer: asyncio.StreamWriter,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Handle a client connection."""
|
|
220
|
+
try:
|
|
221
|
+
while self._running:
|
|
222
|
+
line = await reader.readline()
|
|
223
|
+
if not line:
|
|
224
|
+
break
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
msg = parse_message(line.decode())
|
|
228
|
+
except ProtocolError:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
response = await self._handle_message(msg, writer)
|
|
232
|
+
if response is None:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# Response write races client disconnect: the transport
|
|
236
|
+
# (SocketTransport) finishes a send/ask round-trip and closes
|
|
237
|
+
# the socket while we're still flushing the ACK. Swallow the
|
|
238
|
+
# expected disconnect errors instead of propagating a
|
|
239
|
+
# traceback into bridge.error.log.
|
|
240
|
+
if writer.is_closing():
|
|
241
|
+
break
|
|
242
|
+
try:
|
|
243
|
+
writer.write(serialize_message(response).encode())
|
|
244
|
+
await writer.drain()
|
|
245
|
+
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
|
246
|
+
_log.debug(
|
|
247
|
+
"bridge: client disconnected mid-response "
|
|
248
|
+
"(op=%s request_id=%s err=%s)",
|
|
249
|
+
msg.op, msg.request_id, e,
|
|
250
|
+
)
|
|
251
|
+
break
|
|
252
|
+
finally:
|
|
253
|
+
# Client disconnected — drop any outstanding questions tied to
|
|
254
|
+
# this writer so we don't try to answer a dead socket later.
|
|
255
|
+
async with self._pending_lock:
|
|
256
|
+
dead = [
|
|
257
|
+
rid for rid, q in self._pending_questions.items()
|
|
258
|
+
if q.writer is writer
|
|
259
|
+
]
|
|
260
|
+
for rid in dead:
|
|
261
|
+
self._pending_questions.pop(rid, None)
|
|
262
|
+
writer.close()
|
|
263
|
+
try:
|
|
264
|
+
await writer.wait_closed()
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
async def _handle_message(
|
|
269
|
+
self,
|
|
270
|
+
msg: BridgeMessage,
|
|
271
|
+
writer: asyncio.StreamWriter,
|
|
272
|
+
) -> BridgeMessage | None:
|
|
273
|
+
"""Handle a single message and return response."""
|
|
274
|
+
if msg.op == BridgeOp.PING:
|
|
275
|
+
return BridgeMessage(op=BridgeOp.PONG)
|
|
276
|
+
|
|
277
|
+
if msg.op == BridgeOp.SEND:
|
|
278
|
+
try:
|
|
279
|
+
assert self._telegram is not None
|
|
280
|
+
telegram_msg_id = await self._telegram.send(msg.text or "")
|
|
281
|
+
# A SEND that names a session is answerable too: the secops
|
|
282
|
+
# sweep fans out one "blocked on <repo>" message per repo
|
|
283
|
+
# after the run, and that message is the most recent — and
|
|
284
|
+
# most prominent — one the operator sees for that repo. If
|
|
285
|
+
# its id isn't recorded, replying to it lands in the
|
|
286
|
+
# ambiguous branch even though the session is unmistakable.
|
|
287
|
+
if msg.session_id:
|
|
288
|
+
async with self._pending_lock:
|
|
289
|
+
self._remember_asked_session(
|
|
290
|
+
telegram_msg_id, msg.session_id
|
|
291
|
+
)
|
|
292
|
+
_log.info("bridge: SEND delivered, request_id=%s", msg.request_id)
|
|
293
|
+
return BridgeMessage(op=BridgeOp.ACK, request_id=msg.request_id, status="sent")
|
|
294
|
+
except Exception as e:
|
|
295
|
+
_log.warning("bridge: SEND failed, request_id=%s err=%s", msg.request_id, e)
|
|
296
|
+
return BridgeMessage(
|
|
297
|
+
op=BridgeOp.ERROR,
|
|
298
|
+
request_id=msg.request_id,
|
|
299
|
+
error="telegram_api_error",
|
|
300
|
+
message=str(e),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if msg.op == BridgeOp.ASK:
|
|
304
|
+
question = msg.question or ""
|
|
305
|
+
# Hash + length only: these records land in the journal and in
|
|
306
|
+
# poller.log, so the question text itself would persist there in
|
|
307
|
+
# plaintext indefinitely. The hash still joins this event to the
|
|
308
|
+
# client's own dev.question.posted. Built outside the try so the
|
|
309
|
+
# failure path can always describe what failed.
|
|
310
|
+
post_fields = {
|
|
311
|
+
"session_id": msg.session_id,
|
|
312
|
+
"repo": msg.repo,
|
|
313
|
+
"issue_number": msg.issue_number,
|
|
314
|
+
"transport": "telegram",
|
|
315
|
+
"destination": f"telegram:chat={self.chat_id}",
|
|
316
|
+
"request_id": msg.request_id,
|
|
317
|
+
"question_length": len(question),
|
|
318
|
+
"question_hash": hash_text(question),
|
|
319
|
+
"options": msg.options,
|
|
320
|
+
}
|
|
321
|
+
try:
|
|
322
|
+
assert self._telegram is not None
|
|
323
|
+
# Start the expiry clock before the Telegram round trip, not
|
|
324
|
+
# after it: the client began counting when it wrote the ASK
|
|
325
|
+
# to the socket. Anchoring on the far side of the post would
|
|
326
|
+
# leave the bridge treating the question as live for a full
|
|
327
|
+
# post latency after the client had abandoned it, and a
|
|
328
|
+
# reply-to in that window writes the answer into a dead
|
|
329
|
+
# request.
|
|
330
|
+
received_at = time.monotonic()
|
|
331
|
+
telegram_msg_id = await self._telegram.ask(
|
|
332
|
+
format_question(
|
|
333
|
+
question,
|
|
334
|
+
repo=msg.repo,
|
|
335
|
+
session_id=msg.session_id,
|
|
336
|
+
issue_number=msg.issue_number,
|
|
337
|
+
),
|
|
338
|
+
options=msg.options,
|
|
339
|
+
)
|
|
340
|
+
async with self._pending_lock:
|
|
341
|
+
if msg.request_id:
|
|
342
|
+
self._pending_questions[msg.request_id] = _PendingQuestion(
|
|
343
|
+
request_id=msg.request_id,
|
|
344
|
+
telegram_msg_id=telegram_msg_id,
|
|
345
|
+
writer=writer,
|
|
346
|
+
expires_at=_deadline(received_at, msg.timeout),
|
|
347
|
+
repo=msg.repo,
|
|
348
|
+
session_id=msg.session_id,
|
|
349
|
+
)
|
|
350
|
+
if msg.session_id:
|
|
351
|
+
self._remember_asked_session(
|
|
352
|
+
telegram_msg_id, msg.session_id
|
|
353
|
+
)
|
|
354
|
+
_log.info(
|
|
355
|
+
"bridge: ASK posted request_id=%s telegram_msg_id=%s",
|
|
356
|
+
msg.request_id, telegram_msg_id,
|
|
357
|
+
)
|
|
358
|
+
# Emitted here, not before the send: until Telegram has
|
|
359
|
+
# accepted the message there is nothing posted to claim.
|
|
360
|
+
log_event(
|
|
361
|
+
_logger,
|
|
362
|
+
"dev.question.posted",
|
|
363
|
+
**post_fields,
|
|
364
|
+
telegram_msg_id=telegram_msg_id,
|
|
365
|
+
)
|
|
366
|
+
return BridgeMessage(
|
|
367
|
+
op=BridgeOp.ACK, request_id=msg.request_id, status="pending",
|
|
368
|
+
)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
_log.warning("bridge: ASK failed, request_id=%s err=%s", msg.request_id, e)
|
|
371
|
+
# Same rule as the transport: only claim a definite
|
|
372
|
+
# failure when Telegram actually answered. A timeout or a
|
|
373
|
+
# bare transport error can be raised after Telegram
|
|
374
|
+
# accepted the message, in which case the question IS on
|
|
375
|
+
# the operator's phone and "post_failed" is a lie.
|
|
376
|
+
#
|
|
377
|
+
# The taxonomy is not intuitive, so it lives next to the
|
|
378
|
+
# library that defines it — see is_ambiguous_delivery.
|
|
379
|
+
unknown = is_ambiguous_delivery(e)
|
|
380
|
+
log_event(
|
|
381
|
+
_logger,
|
|
382
|
+
"dev.question.post_unknown"
|
|
383
|
+
if unknown
|
|
384
|
+
else "dev.question.post_failed",
|
|
385
|
+
**post_fields,
|
|
386
|
+
reason=type(e).__name__,
|
|
387
|
+
error=str(e)[:200],
|
|
388
|
+
)
|
|
389
|
+
return BridgeMessage(
|
|
390
|
+
op=BridgeOp.ERROR,
|
|
391
|
+
request_id=msg.request_id,
|
|
392
|
+
# Carry the classification instead of letting the
|
|
393
|
+
# other side re-derive it: the transport cannot see
|
|
394
|
+
# the Telegram exception, so a generic ERROR forced
|
|
395
|
+
# it to assume the worst and contradict the
|
|
396
|
+
# post_unknown just logged here for the same request.
|
|
397
|
+
error=(
|
|
398
|
+
"telegram_delivery_unknown"
|
|
399
|
+
if unknown
|
|
400
|
+
else "telegram_api_error"
|
|
401
|
+
),
|
|
402
|
+
message=str(e),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
return None
|
|
406
|
+
|
|
407
|
+
def _remember_asked_session(
|
|
408
|
+
self, telegram_msg_id: int, session_id: str
|
|
409
|
+
) -> None:
|
|
410
|
+
"""Record which session a posted question belongs to.
|
|
411
|
+
|
|
412
|
+
Caller must hold ``_pending_lock``."""
|
|
413
|
+
self._asked_sessions[telegram_msg_id] = session_id
|
|
414
|
+
self._asked_sessions.move_to_end(telegram_msg_id)
|
|
415
|
+
while len(self._asked_sessions) > _ASKED_SESSIONS_MAX:
|
|
416
|
+
self._asked_sessions.popitem(last=False)
|
|
417
|
+
|
|
418
|
+
async def _on_telegram_reply(
|
|
419
|
+
self,
|
|
420
|
+
text: str,
|
|
421
|
+
reply_to_message_id: int | None,
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Route an incoming Telegram message to the matching pending question.
|
|
424
|
+
|
|
425
|
+
Routing rules, in order:
|
|
426
|
+
|
|
427
|
+
1. ``reply_to_message_id`` names a live pending question — deliver
|
|
428
|
+
there. This is the only path that can be right when more than one
|
|
429
|
+
question is outstanding, so it takes priority.
|
|
430
|
+
2. The operator replied to *something*, but that question is no
|
|
431
|
+
longer live (its ASK timed out, or the bridge restarted). We do
|
|
432
|
+
NOT guess: fall through to the orphan router, which knows the
|
|
433
|
+
session from ``_asked_sessions`` and can attach the answer to the
|
|
434
|
+
right persisted row.
|
|
435
|
+
3. A fresh message (no reply-to) with exactly one question
|
|
436
|
+
outstanding — deliver there; there is nothing to confuse it with.
|
|
437
|
+
4. A fresh message with several outstanding — refuse, and tell the
|
|
438
|
+
operator to use Telegram's reply.
|
|
439
|
+
|
|
440
|
+
The previous FIFO fallback ran for cases 2 and 4 alike: replying to
|
|
441
|
+
the third question while the first was still open delivered the
|
|
442
|
+
answer to the *first* one, silently, and the operator had no way to
|
|
443
|
+
see it had happened.
|
|
444
|
+
|
|
445
|
+
``_pending_lock`` guards only the decision. Every Telegram send and
|
|
446
|
+
every state_db write happens after it is released — holding it across
|
|
447
|
+
a stalled Telegram call would serialize routing behind the network.
|
|
448
|
+
Telegram updates are dispatched one at a time by
|
|
449
|
+
``TelegramHandler._poll_loop``, so releasing early cannot interleave
|
|
450
|
+
two replies.
|
|
451
|
+
"""
|
|
452
|
+
match: _PendingQuestion | None = None
|
|
453
|
+
hinted_session_id: str | None = None
|
|
454
|
+
live_listing: str | None = None
|
|
455
|
+
ambiguous_notice: str | None = None
|
|
456
|
+
|
|
457
|
+
async with self._pending_lock:
|
|
458
|
+
self._drop_expired_questions()
|
|
459
|
+
if reply_to_message_id is not None:
|
|
460
|
+
for q in self._pending_questions.values():
|
|
461
|
+
if q.telegram_msg_id == reply_to_message_id:
|
|
462
|
+
match = q
|
|
463
|
+
break
|
|
464
|
+
elif len(self._pending_questions) == 1:
|
|
465
|
+
match = next(iter(self._pending_questions.values()))
|
|
466
|
+
elif len(self._pending_questions) > 1:
|
|
467
|
+
ambiguous_notice = (
|
|
468
|
+
"Your message wasn't routed - "
|
|
469
|
+
f"{len(self._pending_questions)} questions are waiting "
|
|
470
|
+
"and a plain message doesn't say which one you "
|
|
471
|
+
"mean.\n\n"
|
|
472
|
+
"Use Telegram's reply on the question you want to "
|
|
473
|
+
"answer.\n\n"
|
|
474
|
+
f"{self._live_question_listing()}"
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if match is not None:
|
|
478
|
+
self._pending_questions.pop(match.request_id, None)
|
|
479
|
+
elif ambiguous_notice is None:
|
|
480
|
+
# Snapshot what the orphan branch needs, so the lock can be
|
|
481
|
+
# released before we touch Telegram or the database.
|
|
482
|
+
hinted_session_id = (
|
|
483
|
+
self._asked_sessions.get(reply_to_message_id)
|
|
484
|
+
if reply_to_message_id is not None
|
|
485
|
+
else None
|
|
486
|
+
)
|
|
487
|
+
live_listing = (
|
|
488
|
+
self._live_question_listing()
|
|
489
|
+
if self._pending_questions
|
|
490
|
+
else None
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if ambiguous_notice is not None:
|
|
494
|
+
await self._send_notice(
|
|
495
|
+
ambiguous_notice,
|
|
496
|
+
log="bridge: ambiguous fresh reply, refusing to guess",
|
|
497
|
+
)
|
|
498
|
+
return
|
|
499
|
+
|
|
500
|
+
if match is None:
|
|
501
|
+
await self._route_orphan_reply(text, hinted_session_id, live_listing)
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
_log.info(
|
|
505
|
+
"bridge: delivering ANSWER request_id=%s len=%d",
|
|
506
|
+
match.request_id, len(text),
|
|
507
|
+
)
|
|
508
|
+
log_event(
|
|
509
|
+
_logger,
|
|
510
|
+
"dev.answer.received",
|
|
511
|
+
transport="telegram",
|
|
512
|
+
source=f"telegram:chat={self.chat_id}",
|
|
513
|
+
request_id=match.request_id,
|
|
514
|
+
telegram_msg_id=match.telegram_msg_id,
|
|
515
|
+
reply_to_message_id=reply_to_message_id,
|
|
516
|
+
answer_length=len(text),
|
|
517
|
+
answer_hash=hash_text(text),
|
|
518
|
+
)
|
|
519
|
+
answer = BridgeMessage(
|
|
520
|
+
op=BridgeOp.ANSWER,
|
|
521
|
+
request_id=match.request_id,
|
|
522
|
+
answer=text,
|
|
523
|
+
answered_at=datetime.now(timezone.utc).isoformat(),
|
|
524
|
+
)
|
|
525
|
+
try:
|
|
526
|
+
match.writer.write(serialize_message(answer).encode())
|
|
527
|
+
await match.writer.drain()
|
|
528
|
+
except Exception as e:
|
|
529
|
+
_log.warning(
|
|
530
|
+
"bridge: failed to deliver ANSWER request_id=%s err=%s",
|
|
531
|
+
match.request_id, e,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
async def _route_orphan_reply(
|
|
535
|
+
self,
|
|
536
|
+
text: str,
|
|
537
|
+
hinted_session_id: str | None,
|
|
538
|
+
live_listing: str | None,
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Handle a reply that matched no live question.
|
|
541
|
+
|
|
542
|
+
Called with ``_pending_lock`` released; ``live_listing`` is the
|
|
543
|
+
snapshot of what was outstanding at decision time.
|
|
544
|
+
"""
|
|
545
|
+
_log.info(
|
|
546
|
+
"bridge: incoming telegram msg with no pending question; "
|
|
547
|
+
"text=%r", text[:80],
|
|
548
|
+
)
|
|
549
|
+
# Try to route to a persisted BLOCKED session in state_db so the
|
|
550
|
+
# operator's reply actually drives a resume. Without this, the reply
|
|
551
|
+
# disappears the instant the session's ASK socket closes — which is
|
|
552
|
+
# exactly what happens when a scheduled secops sweep escalates
|
|
553
|
+
# BLOCKED and exits.
|
|
554
|
+
outcome = await self._queue_orphan_reply_as_resume_answer(
|
|
555
|
+
text, session_id=hinted_session_id
|
|
556
|
+
)
|
|
557
|
+
if outcome["status"] == "queued":
|
|
558
|
+
row = outcome["row"]
|
|
559
|
+
await self._send_notice(
|
|
560
|
+
"✅ Answer queued for BLOCKED session "
|
|
561
|
+
f"`{row['session_id']}` "
|
|
562
|
+
f"(pipeline={row['pipeline']}, repo={row['repo']}).\n"
|
|
563
|
+
"The pending-resume sweeper will drive it on the next "
|
|
564
|
+
"tick — you'll get another message with the result."
|
|
565
|
+
)
|
|
566
|
+
elif outcome["status"] == "ambiguous":
|
|
567
|
+
pending_list = "\n".join(
|
|
568
|
+
f" • `{r['session_id']}` ({r['repo']}): "
|
|
569
|
+
f"{(r['question'] or '')[:80]}"
|
|
570
|
+
for r in outcome["rows"]
|
|
571
|
+
)
|
|
572
|
+
await self._send_notice(
|
|
573
|
+
"⚠️ Your reply wasn't routed — multiple BLOCKED sessions "
|
|
574
|
+
"are unanswered and the reply didn't point at one of "
|
|
575
|
+
"them.\n\n"
|
|
576
|
+
f"Pending:\n{pending_list}\n\n"
|
|
577
|
+
"Use Telegram's reply on the question you mean, or paste "
|
|
578
|
+
"its session_id anywhere in your message."
|
|
579
|
+
)
|
|
580
|
+
elif live_listing is not None:
|
|
581
|
+
# Reply-to pointed at a message this bridge no longer tracks
|
|
582
|
+
# (restarted, or evicted from _asked_sessions) and nothing is
|
|
583
|
+
# persisted for it — but questions ARE live. Saying "no active
|
|
584
|
+
# session is waiting" here would be flatly wrong and would stop
|
|
585
|
+
# the operator retrying, so point them at what is waiting.
|
|
586
|
+
await self._send_notice(
|
|
587
|
+
"⚠️ Your reply wasn't routed — it pointed at a question "
|
|
588
|
+
"this bridge no longer tracks, and nothing is persisted "
|
|
589
|
+
f"for it.\n\n{live_listing}\n\n"
|
|
590
|
+
"Reply to one of those messages to answer it."
|
|
591
|
+
)
|
|
592
|
+
else:
|
|
593
|
+
await self._send_notice(
|
|
594
|
+
"⚠️ Your reply wasn't routed — no active session is "
|
|
595
|
+
"waiting on input and no persisted BLOCKED session is "
|
|
596
|
+
"unanswered. To act manually, re-run the pipeline, e.g. "
|
|
597
|
+
"`ctrlrelay run secops --repo <owner>/<repo>`."
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
def _drop_expired_questions(self) -> None:
|
|
601
|
+
"""Forget questions whose client has already stopped waiting.
|
|
602
|
+
|
|
603
|
+
Caller must hold ``_pending_lock``. An expired entry is worse than
|
|
604
|
+
no entry: it would match a reply-to and swallow the answer, and it
|
|
605
|
+
inflates the "more than one question is live" count so that a plain
|
|
606
|
+
answer to the one real question gets refused. Dropping it here sends
|
|
607
|
+
the reply down the orphan path, which resolves the session's
|
|
608
|
+
``pending_resumes`` row via ``_asked_sessions``.
|
|
609
|
+
"""
|
|
610
|
+
now = time.monotonic()
|
|
611
|
+
expired = [
|
|
612
|
+
rid for rid, q in self._pending_questions.items() if q.is_expired(now)
|
|
613
|
+
]
|
|
614
|
+
for rid in expired:
|
|
615
|
+
self._pending_questions.pop(rid, None)
|
|
616
|
+
if expired:
|
|
617
|
+
_log.info(
|
|
618
|
+
"bridge: dropped %d expired question(s): %s",
|
|
619
|
+
len(expired), ", ".join(expired),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _live_question_listing(self) -> str:
|
|
623
|
+
"""Render the outstanding questions for an operator notice.
|
|
624
|
+
|
|
625
|
+
Uses the same ``[repo] session:`` shape as the question header, so
|
|
626
|
+
the operator can match a line here to a message in their chat by
|
|
627
|
+
eye. Telegram never shows message ids in its UI and ``request_id``
|
|
628
|
+
means nothing to a human, so neither belongs in operator-facing
|
|
629
|
+
text.
|
|
630
|
+
|
|
631
|
+
Caller must hold ``_pending_lock``."""
|
|
632
|
+
rows = "\n".join(
|
|
633
|
+
f" - {q.repo or 'unknown repo'}"
|
|
634
|
+
+ (f" (session: {q.session_id})" if q.session_id else "")
|
|
635
|
+
for q in self._pending_questions.values()
|
|
636
|
+
)
|
|
637
|
+
return f"Still waiting:\n{rows}"
|
|
638
|
+
|
|
639
|
+
async def _send_notice(self, text: str, log: str | None = None) -> None:
|
|
640
|
+
"""Best-effort operator notice. A failure here must never take down
|
|
641
|
+
reply routing — the answer has already been dealt with."""
|
|
642
|
+
if log:
|
|
643
|
+
_log.info("%s", log)
|
|
644
|
+
if self._telegram is None:
|
|
645
|
+
return
|
|
646
|
+
try:
|
|
647
|
+
await self._telegram.send(text)
|
|
648
|
+
except Exception as e:
|
|
649
|
+
_log.warning("bridge: failed to notify operator: %s", e)
|
|
650
|
+
|
|
651
|
+
async def _queue_orphan_reply_as_resume_answer(
|
|
652
|
+
self, text: str, *, session_id: str | None = None
|
|
653
|
+
) -> dict:
|
|
654
|
+
"""Try to route an orphan Telegram reply to a persisted BLOCKED
|
|
655
|
+
session so the pending-resume sweeper can pick it up and drive a
|
|
656
|
+
pipeline resume.
|
|
657
|
+
|
|
658
|
+
Returns a dict with ``status`` set to one of:
|
|
659
|
+
- ``"queued"`` with ``row`` (dict) — answer was attached.
|
|
660
|
+
- ``"ambiguous"`` with ``rows`` (list[dict]) — multiple BLOCKED
|
|
661
|
+
sessions exist and the reply didn't name one, so we refuse to
|
|
662
|
+
guess. The sender is told which session_ids exist so they can
|
|
663
|
+
retry with one included.
|
|
664
|
+
- ``"none"`` — no state_db, no unanswered rows, or DB error.
|
|
665
|
+
|
|
666
|
+
Disambiguation rules, in order:
|
|
667
|
+
|
|
668
|
+
1. ``session_id`` (resolved from the Telegram reply-to) names an
|
|
669
|
+
unanswered row — route there. This is exact: it comes from the
|
|
670
|
+
message the operator actually replied to.
|
|
671
|
+
2. The reply text contains exactly one unanswered session_id as a
|
|
672
|
+
substring — route there.
|
|
673
|
+
3. Exactly one unanswered row exists — route there.
|
|
674
|
+
4. Anything else — ambiguous, and we refuse to guess.
|
|
675
|
+
"""
|
|
676
|
+
if self.state_db is None:
|
|
677
|
+
return {"status": "none"}
|
|
678
|
+
try:
|
|
679
|
+
rows = self.state_db.list_unanswered_pending_resumes()
|
|
680
|
+
except Exception as e:
|
|
681
|
+
log_event(
|
|
682
|
+
_logger,
|
|
683
|
+
"bridge.pending_resume.list_failed",
|
|
684
|
+
reason=type(e).__name__,
|
|
685
|
+
error=str(e)[:200],
|
|
686
|
+
)
|
|
687
|
+
return {"status": "none"}
|
|
688
|
+
|
|
689
|
+
if not rows:
|
|
690
|
+
return {"status": "none"}
|
|
691
|
+
|
|
692
|
+
# A reply-to that resolves to a session we posted beats every
|
|
693
|
+
# heuristic below — the operator pointed at the exact question.
|
|
694
|
+
if session_id is not None:
|
|
695
|
+
hinted = [r for r in rows if r["session_id"] == session_id]
|
|
696
|
+
if hinted:
|
|
697
|
+
return await self._attach_orphan_answer(hinted[0], text)
|
|
698
|
+
|
|
699
|
+
matched_by_id = [r for r in rows if r["session_id"] in text]
|
|
700
|
+
if len(matched_by_id) == 1:
|
|
701
|
+
target = matched_by_id[0]
|
|
702
|
+
elif len(matched_by_id) > 1:
|
|
703
|
+
# Multiple session_ids named in the same reply — refuse to
|
|
704
|
+
# pick one. Let the operator send a single-session reply.
|
|
705
|
+
return {"status": "ambiguous", "rows": matched_by_id}
|
|
706
|
+
elif len(rows) == 1:
|
|
707
|
+
target = rows[0]
|
|
708
|
+
else:
|
|
709
|
+
# Multiple unanswered, no session_id hint — can't route safely.
|
|
710
|
+
return {"status": "ambiguous", "rows": rows}
|
|
711
|
+
|
|
712
|
+
return await self._attach_orphan_answer(target, text)
|
|
713
|
+
|
|
714
|
+
async def _attach_orphan_answer(self, target: dict, text: str) -> dict:
|
|
715
|
+
"""Write an orphan answer onto one pending_resumes row."""
|
|
716
|
+
assert self.state_db is not None
|
|
717
|
+
try:
|
|
718
|
+
if not self.state_db.answer_pending_resume(
|
|
719
|
+
target["session_id"], text
|
|
720
|
+
):
|
|
721
|
+
return {"status": "none"}
|
|
722
|
+
except Exception as e:
|
|
723
|
+
log_event(
|
|
724
|
+
_logger,
|
|
725
|
+
"bridge.pending_resume.update_failed",
|
|
726
|
+
reason=type(e).__name__,
|
|
727
|
+
error=str(e)[:200],
|
|
728
|
+
)
|
|
729
|
+
return {"status": "none"}
|
|
730
|
+
|
|
731
|
+
log_event(
|
|
732
|
+
_logger,
|
|
733
|
+
"bridge.pending_resume.queued",
|
|
734
|
+
session_id=target["session_id"],
|
|
735
|
+
pipeline=target["pipeline"],
|
|
736
|
+
repo=target["repo"],
|
|
737
|
+
answer_length=len(text),
|
|
738
|
+
answer_hash=hash_text(text),
|
|
739
|
+
)
|
|
740
|
+
return {"status": "queued", "row": target}
|