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.
Files changed (53) hide show
  1. ctrlrelay/__init__.py +33 -0
  2. ctrlrelay/bridge/__init__.py +21 -0
  3. ctrlrelay/bridge/__main__.py +89 -0
  4. ctrlrelay/bridge/protocol.py +75 -0
  5. ctrlrelay/bridge/server.py +740 -0
  6. ctrlrelay/bridge/telegram_handler.py +136 -0
  7. ctrlrelay/cli.py +3138 -0
  8. ctrlrelay/core/__init__.py +54 -0
  9. ctrlrelay/core/audit.py +257 -0
  10. ctrlrelay/core/checkpoint.py +155 -0
  11. ctrlrelay/core/config.py +778 -0
  12. ctrlrelay/core/dispatcher.py +372 -0
  13. ctrlrelay/core/github.py +432 -0
  14. ctrlrelay/core/network.py +277 -0
  15. ctrlrelay/core/obs.py +118 -0
  16. ctrlrelay/core/poller.py +920 -0
  17. ctrlrelay/core/pr_verifier.py +230 -0
  18. ctrlrelay/core/pr_watcher.py +121 -0
  19. ctrlrelay/core/question_expiry.py +193 -0
  20. ctrlrelay/core/scheduler.py +337 -0
  21. ctrlrelay/core/state.py +642 -0
  22. ctrlrelay/core/worktree.py +893 -0
  23. ctrlrelay/dashboard/__init__.py +5 -0
  24. ctrlrelay/dashboard/client.py +159 -0
  25. ctrlrelay/gh_protocol.py +47 -0
  26. ctrlrelay/install.py +203 -0
  27. ctrlrelay/personalization/__init__.py +26 -0
  28. ctrlrelay/personalization/manager.py +1183 -0
  29. ctrlrelay/personalization/paths.py +143 -0
  30. ctrlrelay/pipelines/__init__.py +15 -0
  31. ctrlrelay/pipelines/base.py +50 -0
  32. ctrlrelay/pipelines/dev.py +1367 -0
  33. ctrlrelay/pipelines/post_merge.py +411 -0
  34. ctrlrelay/pipelines/secops.py +1018 -0
  35. ctrlrelay/pipelines/task.py +713 -0
  36. ctrlrelay/setup.py +617 -0
  37. ctrlrelay/templates/__init__.py +0 -0
  38. ctrlrelay/templates/global-CLAUDE.md.snippet +61 -0
  39. ctrlrelay/templates/launchd/__init__.py +0 -0
  40. ctrlrelay/templates/launchd/bridge.plist.template +37 -0
  41. ctrlrelay/templates/launchd/poller.plist.template +44 -0
  42. ctrlrelay/templates/systemd/__init__.py +0 -0
  43. ctrlrelay/templates/systemd/bridge.service.template +20 -0
  44. ctrlrelay/templates/systemd/poller.service.template +25 -0
  45. ctrlrelay/transports/__init__.py +34 -0
  46. ctrlrelay/transports/base.py +74 -0
  47. ctrlrelay/transports/file_mock.py +103 -0
  48. ctrlrelay/transports/socket_client.py +298 -0
  49. ctrlrelay-0.10.0.dist-info/METADATA +254 -0
  50. ctrlrelay-0.10.0.dist-info/RECORD +53 -0
  51. ctrlrelay-0.10.0.dist-info/WHEEL +4 -0
  52. ctrlrelay-0.10.0.dist-info/entry_points.txt +2 -0
  53. 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()