ctrlrelay 0.1.5__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 +8 -0
- ctrlrelay/bridge/__init__.py +21 -0
- ctrlrelay/bridge/__main__.py +69 -0
- ctrlrelay/bridge/protocol.py +75 -0
- ctrlrelay/bridge/server.py +285 -0
- ctrlrelay/bridge/telegram_handler.py +117 -0
- ctrlrelay/cli.py +1449 -0
- ctrlrelay/core/__init__.py +54 -0
- ctrlrelay/core/audit.py +257 -0
- ctrlrelay/core/checkpoint.py +155 -0
- ctrlrelay/core/config.py +291 -0
- ctrlrelay/core/dispatcher.py +202 -0
- ctrlrelay/core/github.py +272 -0
- ctrlrelay/core/obs.py +118 -0
- ctrlrelay/core/poller.py +319 -0
- ctrlrelay/core/pr_verifier.py +177 -0
- ctrlrelay/core/pr_watcher.py +121 -0
- ctrlrelay/core/scheduler.py +337 -0
- ctrlrelay/core/state.py +167 -0
- ctrlrelay/core/worktree.py +673 -0
- ctrlrelay/dashboard/__init__.py +5 -0
- ctrlrelay/dashboard/client.py +159 -0
- ctrlrelay/pipelines/__init__.py +15 -0
- ctrlrelay/pipelines/base.py +50 -0
- ctrlrelay/pipelines/dev.py +562 -0
- ctrlrelay/pipelines/post_merge.py +279 -0
- ctrlrelay/pipelines/secops.py +379 -0
- ctrlrelay/transports/__init__.py +33 -0
- ctrlrelay/transports/base.py +47 -0
- ctrlrelay/transports/file_mock.py +94 -0
- ctrlrelay/transports/socket_client.py +180 -0
- ctrlrelay-0.1.5.dist-info/METADATA +251 -0
- ctrlrelay-0.1.5.dist-info/RECORD +36 -0
- ctrlrelay-0.1.5.dist-info/WHEEL +4 -0
- ctrlrelay-0.1.5.dist-info/entry_points.txt +2 -0
- ctrlrelay-0.1.5.dist-info/licenses/LICENSE +201 -0
ctrlrelay/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Bridge process for Telegram communication."""
|
|
2
|
+
|
|
3
|
+
from ctrlrelay.bridge.protocol import (
|
|
4
|
+
BridgeMessage,
|
|
5
|
+
BridgeOp,
|
|
6
|
+
ProtocolError,
|
|
7
|
+
parse_message,
|
|
8
|
+
serialize_message,
|
|
9
|
+
)
|
|
10
|
+
from ctrlrelay.bridge.server import BridgeServer
|
|
11
|
+
from ctrlrelay.bridge.telegram_handler import TelegramHandler
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BridgeMessage",
|
|
15
|
+
"BridgeOp",
|
|
16
|
+
"BridgeServer",
|
|
17
|
+
"ProtocolError",
|
|
18
|
+
"TelegramHandler",
|
|
19
|
+
"parse_message",
|
|
20
|
+
"serialize_message",
|
|
21
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Bridge process entry point for daemon mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ctrlrelay.bridge.server import BridgeServer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(description="ctrlrelay Telegram bridge")
|
|
17
|
+
parser.add_argument("--socket-path", required=True, help="Unix socket path")
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--bot-token-env",
|
|
20
|
+
default="CTRLRELAY_TELEGRAM_TOKEN",
|
|
21
|
+
help="Environment variable holding the Telegram bot token",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--chat-id", type=int, required=True, help="Telegram chat ID")
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
|
|
26
|
+
bot_token = os.environ.get(args.bot_token_env)
|
|
27
|
+
if not bot_token:
|
|
28
|
+
print(
|
|
29
|
+
f"error: bot token env var '{args.bot_token_env}' is unset",
|
|
30
|
+
file=sys.stderr,
|
|
31
|
+
)
|
|
32
|
+
sys.exit(2)
|
|
33
|
+
|
|
34
|
+
socket_path = Path(args.socket_path)
|
|
35
|
+
server = BridgeServer(
|
|
36
|
+
socket_path=socket_path,
|
|
37
|
+
bot_token=bot_token,
|
|
38
|
+
chat_id=args.chat_id,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
loop = asyncio.new_event_loop()
|
|
42
|
+
asyncio.set_event_loop(loop)
|
|
43
|
+
|
|
44
|
+
async def _run_server() -> None:
|
|
45
|
+
# Wrap start() in a finally that awaits stop() so the loop can't
|
|
46
|
+
# close before _telegram.close() and the socket unlink complete.
|
|
47
|
+
try:
|
|
48
|
+
await server.start()
|
|
49
|
+
finally:
|
|
50
|
+
await server.stop()
|
|
51
|
+
|
|
52
|
+
main_task = loop.create_task(_run_server())
|
|
53
|
+
|
|
54
|
+
def handle_signal(sig: int) -> None:
|
|
55
|
+
main_task.cancel()
|
|
56
|
+
|
|
57
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
58
|
+
loop.add_signal_handler(sig, handle_signal, sig)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
loop.run_until_complete(main_task)
|
|
62
|
+
except asyncio.CancelledError:
|
|
63
|
+
pass
|
|
64
|
+
finally:
|
|
65
|
+
loop.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Bridge protocol message types and serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProtocolError(Exception):
|
|
13
|
+
"""Raised when protocol parsing fails."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BridgeOp(str, Enum):
|
|
17
|
+
"""Bridge operation types."""
|
|
18
|
+
|
|
19
|
+
SEND = "send"
|
|
20
|
+
ASK = "ask"
|
|
21
|
+
ACK = "ack"
|
|
22
|
+
ANSWER = "answer"
|
|
23
|
+
PING = "ping"
|
|
24
|
+
PONG = "pong"
|
|
25
|
+
ERROR = "error"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BridgeMessage(BaseModel):
|
|
29
|
+
"""Message exchanged between orchestrator and bridge."""
|
|
30
|
+
|
|
31
|
+
op: BridgeOp
|
|
32
|
+
request_id: str | None = None
|
|
33
|
+
|
|
34
|
+
# send
|
|
35
|
+
text: str | None = None
|
|
36
|
+
|
|
37
|
+
# ask
|
|
38
|
+
question: str | None = None
|
|
39
|
+
options: list[str] | None = None
|
|
40
|
+
timeout: int | None = None
|
|
41
|
+
|
|
42
|
+
# ack
|
|
43
|
+
status: str | None = None
|
|
44
|
+
|
|
45
|
+
# answer
|
|
46
|
+
answer: str | None = None
|
|
47
|
+
answered_at: str | None = None
|
|
48
|
+
|
|
49
|
+
# error
|
|
50
|
+
error: str | None = None
|
|
51
|
+
message: str | None = None
|
|
52
|
+
|
|
53
|
+
# Correlation fields (optional; used for structured logging only).
|
|
54
|
+
session_id: str | None = None
|
|
55
|
+
repo: str | None = None
|
|
56
|
+
issue_number: int | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def serialize_message(msg: BridgeMessage) -> str:
|
|
60
|
+
"""Serialize message to newline-delimited JSON."""
|
|
61
|
+
data = msg.model_dump(exclude_none=True)
|
|
62
|
+
return json.dumps(data) + "\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_message(line: str) -> BridgeMessage:
|
|
66
|
+
"""Parse newline-delimited JSON to message."""
|
|
67
|
+
try:
|
|
68
|
+
data: dict[str, Any] = json.loads(line.strip())
|
|
69
|
+
except json.JSONDecodeError as e:
|
|
70
|
+
raise ProtocolError(f"Invalid JSON: {e}") from e
|
|
71
|
+
|
|
72
|
+
if "op" not in data:
|
|
73
|
+
raise ProtocolError("Missing 'op' field in message")
|
|
74
|
+
|
|
75
|
+
return BridgeMessage.model_validate(data)
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
from collections import OrderedDict
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ctrlrelay.bridge.protocol import (
|
|
14
|
+
BridgeMessage,
|
|
15
|
+
BridgeOp,
|
|
16
|
+
ProtocolError,
|
|
17
|
+
parse_message,
|
|
18
|
+
serialize_message,
|
|
19
|
+
)
|
|
20
|
+
from ctrlrelay.bridge.telegram_handler import TelegramHandler
|
|
21
|
+
from ctrlrelay.core.obs import get_logger, hash_text, log_event
|
|
22
|
+
|
|
23
|
+
_logger = get_logger("bridge.server")
|
|
24
|
+
_log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _PendingQuestion:
|
|
28
|
+
"""Question posted to Telegram, awaiting the operator's reply."""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("request_id", "telegram_msg_id", "writer")
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
request_id: str,
|
|
35
|
+
telegram_msg_id: int,
|
|
36
|
+
writer: asyncio.StreamWriter,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.request_id = request_id
|
|
39
|
+
self.telegram_msg_id = telegram_msg_id
|
|
40
|
+
self.writer = writer
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BridgeServer:
|
|
44
|
+
"""Unix socket server that bridges to Telegram — bidirectional.
|
|
45
|
+
|
|
46
|
+
Outbound: clients send SEND/ASK over the socket and we hit Telegram.
|
|
47
|
+
Inbound: we long-poll Telegram for messages; when a reply arrives it's
|
|
48
|
+
matched to the oldest outstanding ASK (or by reply_to_message_id if
|
|
49
|
+
available) and we push an ANSWER frame over that client's socket."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
socket_path: Path,
|
|
54
|
+
bot_token: str,
|
|
55
|
+
chat_id: int,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.socket_path = socket_path
|
|
58
|
+
self.bot_token = bot_token
|
|
59
|
+
self.chat_id = chat_id
|
|
60
|
+
self._server: asyncio.Server | None = None
|
|
61
|
+
self._running = False
|
|
62
|
+
self._telegram: TelegramHandler | None = None
|
|
63
|
+
# Insertion-ordered so FIFO dispatch is deterministic.
|
|
64
|
+
self._pending_questions: OrderedDict[str, _PendingQuestion] = OrderedDict()
|
|
65
|
+
self._pending_lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
async def start(self) -> None:
|
|
68
|
+
"""Start the bridge server."""
|
|
69
|
+
self._telegram = TelegramHandler(
|
|
70
|
+
bot_token=self.bot_token,
|
|
71
|
+
chat_id=self.chat_id,
|
|
72
|
+
)
|
|
73
|
+
await self._telegram.start_polling(self._on_telegram_reply)
|
|
74
|
+
|
|
75
|
+
if self.socket_path.exists():
|
|
76
|
+
self.socket_path.unlink()
|
|
77
|
+
|
|
78
|
+
self.socket_path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
self._server = await asyncio.start_unix_server(
|
|
81
|
+
self._handle_client,
|
|
82
|
+
path=str(self.socket_path),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
os.chmod(self.socket_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
86
|
+
|
|
87
|
+
self._running = True
|
|
88
|
+
async with self._server:
|
|
89
|
+
await self._server.serve_forever()
|
|
90
|
+
|
|
91
|
+
async def stop(self) -> None:
|
|
92
|
+
"""Stop the bridge server."""
|
|
93
|
+
self._running = False
|
|
94
|
+
if self._server:
|
|
95
|
+
self._server.close()
|
|
96
|
+
await self._server.wait_closed()
|
|
97
|
+
|
|
98
|
+
if self._telegram:
|
|
99
|
+
await self._telegram.close()
|
|
100
|
+
|
|
101
|
+
if self.socket_path.exists():
|
|
102
|
+
self.socket_path.unlink()
|
|
103
|
+
|
|
104
|
+
async def _handle_client(
|
|
105
|
+
self,
|
|
106
|
+
reader: asyncio.StreamReader,
|
|
107
|
+
writer: asyncio.StreamWriter,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Handle a client connection."""
|
|
110
|
+
try:
|
|
111
|
+
while self._running:
|
|
112
|
+
line = await reader.readline()
|
|
113
|
+
if not line:
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
msg = parse_message(line.decode())
|
|
118
|
+
except ProtocolError:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
response = await self._handle_message(msg, writer)
|
|
122
|
+
if response is None:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Response write races client disconnect: the transport
|
|
126
|
+
# (SocketTransport) finishes a send/ask round-trip and closes
|
|
127
|
+
# the socket while we're still flushing the ACK. Swallow the
|
|
128
|
+
# expected disconnect errors instead of propagating a
|
|
129
|
+
# traceback into bridge.error.log.
|
|
130
|
+
if writer.is_closing():
|
|
131
|
+
break
|
|
132
|
+
try:
|
|
133
|
+
writer.write(serialize_message(response).encode())
|
|
134
|
+
await writer.drain()
|
|
135
|
+
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
|
136
|
+
_log.debug(
|
|
137
|
+
"bridge: client disconnected mid-response "
|
|
138
|
+
"(op=%s request_id=%s err=%s)",
|
|
139
|
+
msg.op, msg.request_id, e,
|
|
140
|
+
)
|
|
141
|
+
break
|
|
142
|
+
finally:
|
|
143
|
+
# Client disconnected — drop any outstanding questions tied to
|
|
144
|
+
# this writer so we don't try to answer a dead socket later.
|
|
145
|
+
async with self._pending_lock:
|
|
146
|
+
dead = [
|
|
147
|
+
rid for rid, q in self._pending_questions.items()
|
|
148
|
+
if q.writer is writer
|
|
149
|
+
]
|
|
150
|
+
for rid in dead:
|
|
151
|
+
self._pending_questions.pop(rid, None)
|
|
152
|
+
writer.close()
|
|
153
|
+
try:
|
|
154
|
+
await writer.wait_closed()
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
async def _handle_message(
|
|
159
|
+
self,
|
|
160
|
+
msg: BridgeMessage,
|
|
161
|
+
writer: asyncio.StreamWriter,
|
|
162
|
+
) -> BridgeMessage | None:
|
|
163
|
+
"""Handle a single message and return response."""
|
|
164
|
+
if msg.op == BridgeOp.PING:
|
|
165
|
+
return BridgeMessage(op=BridgeOp.PONG)
|
|
166
|
+
|
|
167
|
+
if msg.op == BridgeOp.SEND:
|
|
168
|
+
try:
|
|
169
|
+
assert self._telegram is not None
|
|
170
|
+
await self._telegram.send(msg.text or "")
|
|
171
|
+
_log.info("bridge: SEND delivered, request_id=%s", msg.request_id)
|
|
172
|
+
return BridgeMessage(op=BridgeOp.ACK, request_id=msg.request_id, status="sent")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
_log.warning("bridge: SEND failed, request_id=%s err=%s", msg.request_id, e)
|
|
175
|
+
return BridgeMessage(
|
|
176
|
+
op=BridgeOp.ERROR,
|
|
177
|
+
request_id=msg.request_id,
|
|
178
|
+
error="telegram_api_error",
|
|
179
|
+
message=str(e),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if msg.op == BridgeOp.ASK:
|
|
183
|
+
try:
|
|
184
|
+
assert self._telegram is not None
|
|
185
|
+
question = msg.question or ""
|
|
186
|
+
log_event(
|
|
187
|
+
_logger,
|
|
188
|
+
"dev.question.posted",
|
|
189
|
+
session_id=msg.session_id,
|
|
190
|
+
repo=msg.repo,
|
|
191
|
+
issue_number=msg.issue_number,
|
|
192
|
+
transport="telegram",
|
|
193
|
+
destination=f"telegram:chat={self.chat_id}",
|
|
194
|
+
request_id=msg.request_id,
|
|
195
|
+
question=question,
|
|
196
|
+
question_length=len(question),
|
|
197
|
+
question_hash=hash_text(question),
|
|
198
|
+
options=msg.options,
|
|
199
|
+
)
|
|
200
|
+
telegram_msg_id = await self._telegram.ask(
|
|
201
|
+
question, options=msg.options
|
|
202
|
+
)
|
|
203
|
+
if msg.request_id:
|
|
204
|
+
async with self._pending_lock:
|
|
205
|
+
self._pending_questions[msg.request_id] = _PendingQuestion(
|
|
206
|
+
request_id=msg.request_id,
|
|
207
|
+
telegram_msg_id=telegram_msg_id,
|
|
208
|
+
writer=writer,
|
|
209
|
+
)
|
|
210
|
+
_log.info(
|
|
211
|
+
"bridge: ASK posted request_id=%s telegram_msg_id=%s",
|
|
212
|
+
msg.request_id, telegram_msg_id,
|
|
213
|
+
)
|
|
214
|
+
return BridgeMessage(
|
|
215
|
+
op=BridgeOp.ACK, request_id=msg.request_id, status="pending",
|
|
216
|
+
)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
_log.warning("bridge: ASK failed, request_id=%s err=%s", msg.request_id, e)
|
|
219
|
+
return BridgeMessage(
|
|
220
|
+
op=BridgeOp.ERROR,
|
|
221
|
+
request_id=msg.request_id,
|
|
222
|
+
error="telegram_api_error",
|
|
223
|
+
message=str(e),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
async def _on_telegram_reply(
|
|
229
|
+
self,
|
|
230
|
+
text: str,
|
|
231
|
+
reply_to_message_id: int | None,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Route an incoming Telegram message to the matching pending question.
|
|
234
|
+
|
|
235
|
+
Priority: if reply_to_message_id matches a tracked question, use it.
|
|
236
|
+
Otherwise fall back to FIFO (oldest outstanding question wins) —
|
|
237
|
+
good enough for the single-operator case."""
|
|
238
|
+
async with self._pending_lock:
|
|
239
|
+
match: _PendingQuestion | None = None
|
|
240
|
+
if reply_to_message_id is not None:
|
|
241
|
+
for q in self._pending_questions.values():
|
|
242
|
+
if q.telegram_msg_id == reply_to_message_id:
|
|
243
|
+
match = q
|
|
244
|
+
break
|
|
245
|
+
if match is None and self._pending_questions:
|
|
246
|
+
# FIFO: pop oldest.
|
|
247
|
+
match = next(iter(self._pending_questions.values()))
|
|
248
|
+
if match is None:
|
|
249
|
+
_log.info(
|
|
250
|
+
"bridge: incoming telegram msg with no pending question; "
|
|
251
|
+
"text=%r", text[:80],
|
|
252
|
+
)
|
|
253
|
+
return
|
|
254
|
+
self._pending_questions.pop(match.request_id, None)
|
|
255
|
+
|
|
256
|
+
_log.info(
|
|
257
|
+
"bridge: delivering ANSWER request_id=%s len=%d",
|
|
258
|
+
match.request_id, len(text),
|
|
259
|
+
)
|
|
260
|
+
log_event(
|
|
261
|
+
_logger,
|
|
262
|
+
"dev.answer.received",
|
|
263
|
+
transport="telegram",
|
|
264
|
+
source=f"telegram:chat={self.chat_id}",
|
|
265
|
+
request_id=match.request_id,
|
|
266
|
+
telegram_msg_id=match.telegram_msg_id,
|
|
267
|
+
reply_to_message_id=reply_to_message_id,
|
|
268
|
+
answer=text,
|
|
269
|
+
answer_length=len(text),
|
|
270
|
+
answer_hash=hash_text(text),
|
|
271
|
+
)
|
|
272
|
+
answer = BridgeMessage(
|
|
273
|
+
op=BridgeOp.ANSWER,
|
|
274
|
+
request_id=match.request_id,
|
|
275
|
+
answer=text,
|
|
276
|
+
answered_at=datetime.now(timezone.utc).isoformat(),
|
|
277
|
+
)
|
|
278
|
+
try:
|
|
279
|
+
match.writer.write(serialize_message(answer).encode())
|
|
280
|
+
await match.writer.drain()
|
|
281
|
+
except Exception as e:
|
|
282
|
+
_log.warning(
|
|
283
|
+
"bridge: failed to deliver ANSWER request_id=%s err=%s",
|
|
284
|
+
match.request_id, e,
|
|
285
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Telegram Bot API handler."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from telegram import Bot, ReplyKeyboardMarkup, ReplyKeyboardRemove
|
|
10
|
+
|
|
11
|
+
_log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
IncomingMessageHandler = Callable[[str, int | None], Awaitable[None]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TelegramHandler:
|
|
17
|
+
"""Handles Telegram Bot API communication — outbound (send/ask) and
|
|
18
|
+
inbound (long-poll get_updates) for answers from the operator."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, bot_token: str, chat_id: int) -> None:
|
|
21
|
+
self.bot = Bot(token=bot_token)
|
|
22
|
+
self.chat_id = chat_id
|
|
23
|
+
self._poll_task: asyncio.Task | None = None
|
|
24
|
+
self._offset: int = 0
|
|
25
|
+
|
|
26
|
+
async def send(self, text: str) -> int:
|
|
27
|
+
"""Send a message to the configured chat."""
|
|
28
|
+
message = await self.bot.send_message(
|
|
29
|
+
chat_id=self.chat_id,
|
|
30
|
+
text=text,
|
|
31
|
+
)
|
|
32
|
+
return message.message_id
|
|
33
|
+
|
|
34
|
+
async def ask(
|
|
35
|
+
self,
|
|
36
|
+
question: str,
|
|
37
|
+
options: list[str] | None = None,
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Send a question with optional reply keyboard."""
|
|
40
|
+
reply_markup = None
|
|
41
|
+
if options:
|
|
42
|
+
keyboard = [[opt] for opt in options]
|
|
43
|
+
reply_markup = ReplyKeyboardMarkup(
|
|
44
|
+
keyboard,
|
|
45
|
+
one_time_keyboard=True,
|
|
46
|
+
resize_keyboard=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
message = await self.bot.send_message(
|
|
50
|
+
chat_id=self.chat_id,
|
|
51
|
+
text=question,
|
|
52
|
+
reply_markup=reply_markup or ReplyKeyboardRemove(),
|
|
53
|
+
)
|
|
54
|
+
return message.message_id
|
|
55
|
+
|
|
56
|
+
async def start_polling(self, handler: IncomingMessageHandler) -> None:
|
|
57
|
+
"""Start long-polling Telegram for incoming messages from the
|
|
58
|
+
configured chat. For each message, invokes
|
|
59
|
+
``handler(text, reply_to_message_id)`` where reply_to_message_id is
|
|
60
|
+
the id of the question the user replied to (or None for a fresh
|
|
61
|
+
message). Idempotent — a second call replaces the running loop."""
|
|
62
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
63
|
+
return
|
|
64
|
+
self._poll_task = asyncio.create_task(self._poll_loop(handler))
|
|
65
|
+
|
|
66
|
+
async def stop_polling(self) -> None:
|
|
67
|
+
"""Stop the polling loop if running."""
|
|
68
|
+
if self._poll_task is None:
|
|
69
|
+
return
|
|
70
|
+
self._poll_task.cancel()
|
|
71
|
+
try:
|
|
72
|
+
await self._poll_task
|
|
73
|
+
except asyncio.CancelledError:
|
|
74
|
+
pass
|
|
75
|
+
self._poll_task = None
|
|
76
|
+
|
|
77
|
+
async def _poll_loop(self, handler: IncomingMessageHandler) -> None:
|
|
78
|
+
"""Long-poll get_updates and forward messages from the configured chat."""
|
|
79
|
+
while True:
|
|
80
|
+
try:
|
|
81
|
+
updates = await self.bot.get_updates(
|
|
82
|
+
offset=self._offset,
|
|
83
|
+
timeout=30,
|
|
84
|
+
allowed_updates=["message"],
|
|
85
|
+
)
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
raise
|
|
88
|
+
except Exception as e:
|
|
89
|
+
# Transient network / auth error. Back off and keep trying.
|
|
90
|
+
_log.warning("telegram get_updates failed: %s", e)
|
|
91
|
+
await asyncio.sleep(5)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
for update in updates:
|
|
95
|
+
self._offset = update.update_id + 1
|
|
96
|
+
msg = update.message
|
|
97
|
+
if msg is None or msg.chat is None:
|
|
98
|
+
continue
|
|
99
|
+
if msg.chat.id != self.chat_id:
|
|
100
|
+
continue # ignore messages from other chats
|
|
101
|
+
text = (msg.text or "").strip()
|
|
102
|
+
if not text:
|
|
103
|
+
continue
|
|
104
|
+
reply_id = (
|
|
105
|
+
msg.reply_to_message.message_id
|
|
106
|
+
if msg.reply_to_message is not None
|
|
107
|
+
else None
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
await handler(text, reply_id)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
_log.warning("bridge answer handler raised: %s", e)
|
|
113
|
+
|
|
114
|
+
async def close(self) -> None:
|
|
115
|
+
"""Close the bot session."""
|
|
116
|
+
await self.stop_polling()
|
|
117
|
+
await self.bot.close()
|