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,136 @@
|
|
|
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
|
+
from telegram.error import NetworkError, TimedOut
|
|
11
|
+
|
|
12
|
+
_log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
IncomingMessageHandler = Callable[[str, int | None], Awaitable[None]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_ambiguous_delivery(exc: BaseException) -> bool:
|
|
19
|
+
"""True when a send may have reached Telegram despite raising.
|
|
20
|
+
|
|
21
|
+
Delivery is only confirmed by a response. A timeout or a bare
|
|
22
|
+
transport error can be raised *after* Telegram accepted the message,
|
|
23
|
+
so calling those a failed post would put a lie in the log while the
|
|
24
|
+
question sits on the operator's phone.
|
|
25
|
+
|
|
26
|
+
The taxonomy is not intuitive and is worth stating: `BadRequest`
|
|
27
|
+
subclasses `NetworkError` here, so `isinstance(exc, NetworkError)`
|
|
28
|
+
would sweep definite rejections into the ambiguous bucket. Only
|
|
29
|
+
`TimedOut` and a bare `NetworkError` are genuinely unknown —
|
|
30
|
+
`BadRequest`, `Forbidden`, `InvalidToken` and the rest are Telegram
|
|
31
|
+
answering "no".
|
|
32
|
+
"""
|
|
33
|
+
return isinstance(exc, TimedOut) or type(exc) is NetworkError
|
|
34
|
+
|
|
35
|
+
class TelegramHandler:
|
|
36
|
+
"""Handles Telegram Bot API communication — outbound (send/ask) and
|
|
37
|
+
inbound (long-poll get_updates) for answers from the operator."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, bot_token: str, chat_id: int) -> None:
|
|
40
|
+
self.bot = Bot(token=bot_token)
|
|
41
|
+
self.chat_id = chat_id
|
|
42
|
+
self._poll_task: asyncio.Task | None = None
|
|
43
|
+
self._offset: int = 0
|
|
44
|
+
|
|
45
|
+
async def send(self, text: str) -> int:
|
|
46
|
+
"""Send a message to the configured chat."""
|
|
47
|
+
message = await self.bot.send_message(
|
|
48
|
+
chat_id=self.chat_id,
|
|
49
|
+
text=text,
|
|
50
|
+
)
|
|
51
|
+
return message.message_id
|
|
52
|
+
|
|
53
|
+
async def ask(
|
|
54
|
+
self,
|
|
55
|
+
question: str,
|
|
56
|
+
options: list[str] | None = None,
|
|
57
|
+
) -> int:
|
|
58
|
+
"""Send a question with optional reply keyboard."""
|
|
59
|
+
reply_markup = None
|
|
60
|
+
if options:
|
|
61
|
+
keyboard = [[opt] for opt in options]
|
|
62
|
+
reply_markup = ReplyKeyboardMarkup(
|
|
63
|
+
keyboard,
|
|
64
|
+
one_time_keyboard=True,
|
|
65
|
+
resize_keyboard=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
message = await self.bot.send_message(
|
|
69
|
+
chat_id=self.chat_id,
|
|
70
|
+
text=question,
|
|
71
|
+
reply_markup=reply_markup or ReplyKeyboardRemove(),
|
|
72
|
+
)
|
|
73
|
+
return message.message_id
|
|
74
|
+
|
|
75
|
+
async def start_polling(self, handler: IncomingMessageHandler) -> None:
|
|
76
|
+
"""Start long-polling Telegram for incoming messages from the
|
|
77
|
+
configured chat. For each message, invokes
|
|
78
|
+
``handler(text, reply_to_message_id)`` where reply_to_message_id is
|
|
79
|
+
the id of the question the user replied to (or None for a fresh
|
|
80
|
+
message). Idempotent — a second call replaces the running loop."""
|
|
81
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
82
|
+
return
|
|
83
|
+
self._poll_task = asyncio.create_task(self._poll_loop(handler))
|
|
84
|
+
|
|
85
|
+
async def stop_polling(self) -> None:
|
|
86
|
+
"""Stop the polling loop if running."""
|
|
87
|
+
if self._poll_task is None:
|
|
88
|
+
return
|
|
89
|
+
self._poll_task.cancel()
|
|
90
|
+
try:
|
|
91
|
+
await self._poll_task
|
|
92
|
+
except asyncio.CancelledError:
|
|
93
|
+
pass
|
|
94
|
+
self._poll_task = None
|
|
95
|
+
|
|
96
|
+
async def _poll_loop(self, handler: IncomingMessageHandler) -> None:
|
|
97
|
+
"""Long-poll get_updates and forward messages from the configured chat."""
|
|
98
|
+
while True:
|
|
99
|
+
try:
|
|
100
|
+
updates = await self.bot.get_updates(
|
|
101
|
+
offset=self._offset,
|
|
102
|
+
timeout=30,
|
|
103
|
+
allowed_updates=["message"],
|
|
104
|
+
)
|
|
105
|
+
except asyncio.CancelledError:
|
|
106
|
+
raise
|
|
107
|
+
except Exception as e:
|
|
108
|
+
# Transient network / auth error. Back off and keep trying.
|
|
109
|
+
_log.warning("telegram get_updates failed: %s", e)
|
|
110
|
+
await asyncio.sleep(5)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
for update in updates:
|
|
114
|
+
self._offset = update.update_id + 1
|
|
115
|
+
msg = update.message
|
|
116
|
+
if msg is None or msg.chat is None:
|
|
117
|
+
continue
|
|
118
|
+
if msg.chat.id != self.chat_id:
|
|
119
|
+
continue # ignore messages from other chats
|
|
120
|
+
text = (msg.text or "").strip()
|
|
121
|
+
if not text:
|
|
122
|
+
continue
|
|
123
|
+
reply_id = (
|
|
124
|
+
msg.reply_to_message.message_id
|
|
125
|
+
if msg.reply_to_message is not None
|
|
126
|
+
else None
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
await handler(text, reply_id)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
_log.warning("bridge answer handler raised: %s", e)
|
|
132
|
+
|
|
133
|
+
async def close(self) -> None:
|
|
134
|
+
"""Close the bot session."""
|
|
135
|
+
await self.stop_polling()
|
|
136
|
+
await self.bot.close()
|