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 ADDED
@@ -0,0 +1,8 @@
1
+ """ctrlrelay: Local-first orchestrator for Claude Code."""
2
+
3
+ from ctrlrelay.core import checkpoint
4
+
5
+ __version__ = "0.1.3"
6
+
7
+ # Public API
8
+ __all__ = ["__version__", "checkpoint"]
@@ -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()