coding-agent-telegram 2026.3.26__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 (31) hide show
  1. coding_agent_telegram/__init__.py +4 -0
  2. coding_agent_telegram/__main__.py +5 -0
  3. coding_agent_telegram/agent_runner.py +438 -0
  4. coding_agent_telegram/bot.py +74 -0
  5. coding_agent_telegram/cli.py +130 -0
  6. coding_agent_telegram/command_router.py +18 -0
  7. coding_agent_telegram/config.py +123 -0
  8. coding_agent_telegram/diff_utils.py +369 -0
  9. coding_agent_telegram/filters.py +42 -0
  10. coding_agent_telegram/git_utils.py +250 -0
  11. coding_agent_telegram/logging_utils.py +17 -0
  12. coding_agent_telegram/resources/.env.example +83 -0
  13. coding_agent_telegram/resources/sensitive_path_globs.txt +15 -0
  14. coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt +30 -0
  15. coding_agent_telegram/resources/snapshot_excluded_dir_names.txt +16 -0
  16. coding_agent_telegram/resources/snapshot_excluded_file_globs.txt +4 -0
  17. coding_agent_telegram/router/__init__.py +2 -0
  18. coding_agent_telegram/router/base.py +536 -0
  19. coding_agent_telegram/router/git_commands.py +106 -0
  20. coding_agent_telegram/router/message_commands.py +45 -0
  21. coding_agent_telegram/router/project_commands.py +234 -0
  22. coding_agent_telegram/router/session_commands.py +197 -0
  23. coding_agent_telegram/session_runtime.py +505 -0
  24. coding_agent_telegram/session_store.py +309 -0
  25. coding_agent_telegram/telegram_sender.py +236 -0
  26. coding_agent_telegram-2026.3.26.dist-info/METADATA +475 -0
  27. coding_agent_telegram-2026.3.26.dist-info/RECORD +31 -0
  28. coding_agent_telegram-2026.3.26.dist-info/WHEEL +5 -0
  29. coding_agent_telegram-2026.3.26.dist-info/entry_points.txt +2 -0
  30. coding_agent_telegram-2026.3.26.dist-info/licenses/LICENSE +21 -0
  31. coding_agent_telegram-2026.3.26.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4 @@
1
+ """coding_agent_telegram package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "2026.03.26"
@@ -0,0 +1,5 @@
1
+ from coding_agent_telegram.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import tempfile
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Optional, Sequence, Tuple, Union
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ AssistantEvent = Union[dict[str, Any], list[Any], str]
18
+
19
+
20
+ @dataclass
21
+ class AgentRunResult:
22
+ session_id: Optional[str]
23
+ success: bool
24
+ assistant_text: str
25
+ error_message: Optional[str]
26
+ raw_events: list[dict]
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class AgentStallInfo:
31
+ command: tuple[str, ...]
32
+ elapsed_seconds: float
33
+ idle_seconds: float
34
+ seen_output: bool
35
+ last_stderr: str
36
+
37
+
38
+ class MultiAgentRunner:
39
+ """Runs supported local agent CLIs while preserving session behavior."""
40
+
41
+ STALL_WARNING_AFTER_SECONDS = 60.0
42
+ STALL_POLL_INTERVAL_SECONDS = 0.5
43
+ PROMPT_PREFIX = (
44
+ "Treat the current on-disk workspace as the source of truth. "
45
+ "Re-read relevant files from disk before making claims or edits. "
46
+ )
47
+
48
+ def __init__(
49
+ self,
50
+ codex_bin: str,
51
+ copilot_bin: str,
52
+ approval_policy: str,
53
+ sandbox_mode: str,
54
+ codex_model: str = "",
55
+ copilot_model: str = "",
56
+ copilot_autopilot: bool = True,
57
+ copilot_no_ask_user: bool = True,
58
+ copilot_allow_all: bool = True,
59
+ copilot_allow_all_tools: bool = False,
60
+ copilot_allow_tools: tuple[str, ...] = (),
61
+ copilot_deny_tools: tuple[str, ...] = (),
62
+ copilot_available_tools: tuple[str, ...] = (),
63
+ ) -> None:
64
+ self.codex_bin = codex_bin
65
+ self.copilot_bin = copilot_bin
66
+ self.approval_policy = approval_policy
67
+ self.sandbox_mode = sandbox_mode
68
+ self.codex_model = codex_model.strip()
69
+ self.copilot_model = copilot_model.strip()
70
+ self.copilot_autopilot = copilot_autopilot
71
+ self.copilot_no_ask_user = copilot_no_ask_user
72
+ self.copilot_allow_all = copilot_allow_all
73
+ self.copilot_allow_all_tools = copilot_allow_all_tools
74
+ self.copilot_allow_tools = tuple(tool.strip() for tool in copilot_allow_tools if tool.strip())
75
+ self.copilot_deny_tools = tuple(tool.strip() for tool in copilot_deny_tools if tool.strip())
76
+ self.copilot_available_tools = tuple(tool.strip() for tool in copilot_available_tools if tool.strip())
77
+
78
+ def _extract_assistant_text(self, event: AssistantEvent) -> str:
79
+ if isinstance(event, str):
80
+ return event
81
+ if isinstance(event, list):
82
+ return "\n".join(filter(None, [self._extract_assistant_text(item) for item in event]))
83
+ if not isinstance(event, dict):
84
+ return ""
85
+
86
+ chunks: list[str] = []
87
+ if isinstance(event.get("assistant_text"), str):
88
+ chunks.append(event["assistant_text"])
89
+
90
+ role = event.get("role")
91
+ event_type = event.get("type")
92
+ if role == "assistant" or event_type in {"message", "assistant_message", "output_text", "text"}:
93
+ for key in ("text", "message", "content"):
94
+ value = event.get(key)
95
+ extracted = self._extract_assistant_text(value)
96
+ if extracted:
97
+ chunks.append(extracted)
98
+
99
+ for item in event.values():
100
+ if isinstance(item, (dict, list)):
101
+ extracted = self._extract_assistant_text(item)
102
+ if extracted:
103
+ chunks.append(extracted)
104
+
105
+ unique_chunks: list[str] = []
106
+ for chunk in chunks:
107
+ cleaned = chunk.strip()
108
+ if cleaned and cleaned not in unique_chunks:
109
+ unique_chunks.append(cleaned)
110
+ return "\n".join(unique_chunks)
111
+
112
+ def _parse_jsonl(self, stdout: str) -> Tuple[Optional[str], bool, str, Optional[str], list[dict]]:
113
+ events: list[dict] = []
114
+ for line in stdout.splitlines():
115
+ line = line.strip()
116
+ if not line:
117
+ continue
118
+ try:
119
+ events.append(json.loads(line))
120
+ except json.JSONDecodeError:
121
+ continue
122
+
123
+ session_id = None
124
+ success = True
125
+ assistant_text = ""
126
+ error_message = None
127
+
128
+ for ev in events:
129
+ for key in ("session_id", "thread_id", "sessionId", "threadId"):
130
+ if isinstance(ev.get(key), str):
131
+ session_id = ev[key]
132
+ extracted_text = self._extract_assistant_text(ev)
133
+ if extracted_text:
134
+ assistant_text = extracted_text
135
+ if isinstance(ev.get("error"), str):
136
+ error_message = ev["error"]
137
+ if isinstance(ev.get("message"), str) and ev.get("type") == "error":
138
+ error_message = ev["message"]
139
+ if isinstance(ev.get("success"), bool):
140
+ success = ev["success"]
141
+
142
+ return session_id, success, assistant_text, error_message, events
143
+
144
+ def _run(
145
+ self,
146
+ args: list[str],
147
+ *,
148
+ cwd: Optional[Path] = None,
149
+ env: Optional[dict[str, str]] = None,
150
+ on_stall: Optional[Callable[[AgentStallInfo], None]] = None,
151
+ ) -> AgentRunResult:
152
+ proc = subprocess.Popen(
153
+ args,
154
+ cwd=cwd,
155
+ env=env,
156
+ stdout=subprocess.PIPE,
157
+ stderr=subprocess.PIPE,
158
+ text=True,
159
+ )
160
+
161
+ stdout_chunks: list[str] = []
162
+ stderr_chunks: list[str] = []
163
+ state_lock = threading.Lock()
164
+ start_time = time.monotonic()
165
+ last_activity = start_time
166
+ seen_output = False
167
+ last_stderr = ""
168
+
169
+ def record_activity(chunk: str, *, is_stderr: bool) -> None:
170
+ nonlocal last_activity, seen_output, last_stderr
171
+ if not chunk:
172
+ return
173
+ with state_lock:
174
+ last_activity = time.monotonic()
175
+ seen_output = True
176
+ if is_stderr and chunk.strip():
177
+ last_stderr = chunk.strip()
178
+
179
+ def read_stream(stream, chunks: list[str], *, is_stderr: bool) -> None:
180
+ try:
181
+ for line in iter(stream.readline, ""):
182
+ chunks.append(line)
183
+ record_activity(line, is_stderr=is_stderr)
184
+ finally:
185
+ stream.close()
186
+
187
+ stdout_thread = threading.Thread(
188
+ target=read_stream,
189
+ args=(proc.stdout, stdout_chunks),
190
+ kwargs={"is_stderr": False},
191
+ daemon=True,
192
+ )
193
+ stderr_thread = threading.Thread(
194
+ target=read_stream,
195
+ args=(proc.stderr, stderr_chunks),
196
+ kwargs={"is_stderr": True},
197
+ daemon=True,
198
+ )
199
+ stdout_thread.start()
200
+ stderr_thread.start()
201
+
202
+ stall_reported = False
203
+ while proc.poll() is None:
204
+ if on_stall and not stall_reported:
205
+ now = time.monotonic()
206
+ with state_lock:
207
+ idle_seconds = now - last_activity
208
+ seen_output_snapshot = seen_output
209
+ last_stderr_snapshot = last_stderr
210
+ if idle_seconds >= self.STALL_WARNING_AFTER_SECONDS:
211
+ stall_reported = True
212
+ info = AgentStallInfo(
213
+ command=tuple(args),
214
+ elapsed_seconds=now - start_time,
215
+ idle_seconds=idle_seconds,
216
+ seen_output=seen_output_snapshot,
217
+ last_stderr=last_stderr_snapshot,
218
+ )
219
+ logger.warning(
220
+ "Agent command appears stalled after %.1fs without output: %s",
221
+ info.idle_seconds,
222
+ " ".join(args[:3]),
223
+ )
224
+ try:
225
+ on_stall(info)
226
+ except Exception:
227
+ logger.exception("Agent stall callback failed.")
228
+ time.sleep(self.STALL_POLL_INTERVAL_SECONDS)
229
+
230
+ stdout_thread.join()
231
+ stderr_thread.join()
232
+ stdout = "".join(stdout_chunks)
233
+ stderr = "".join(stderr_chunks)
234
+ session_id, parsed_success, assistant_text, error_message, events = self._parse_jsonl(stdout)
235
+
236
+ success = proc.returncode == 0 and parsed_success
237
+ if not success and not error_message:
238
+ error_message = stderr.strip() or "Agent command failed."
239
+
240
+ return AgentRunResult(
241
+ session_id=session_id,
242
+ success=success,
243
+ assistant_text=assistant_text,
244
+ error_message=error_message,
245
+ raw_events=events,
246
+ )
247
+
248
+ def _run_with_output_file(
249
+ self,
250
+ args: list[str],
251
+ *,
252
+ cwd: Path,
253
+ tail_args: int,
254
+ env: Optional[dict[str, str]] = None,
255
+ on_stall: Optional[Callable[[AgentStallInfo], None]] = None,
256
+ ) -> AgentRunResult:
257
+ with tempfile.NamedTemporaryFile(prefix="coding-agent-telegram-", suffix=".txt", delete=False) as handle:
258
+ output_path = Path(handle.name)
259
+
260
+ try:
261
+ split_at = len(args) - tail_args
262
+ result = self._run(
263
+ [*args[:split_at], "--output-last-message", str(output_path), *args[split_at:]],
264
+ cwd=cwd,
265
+ env=env,
266
+ on_stall=on_stall,
267
+ )
268
+ if output_path.exists():
269
+ output_text = output_path.read_text(encoding="utf-8").strip()
270
+ if output_text:
271
+ result.assistant_text = output_text
272
+ return result
273
+ finally:
274
+ try:
275
+ os.unlink(output_path)
276
+ except FileNotFoundError:
277
+ pass
278
+
279
+ def _codex_base(
280
+ self,
281
+ project_path: Path,
282
+ user_message: str,
283
+ skip_git_repo_check: bool,
284
+ image_paths: Sequence[Path] = (),
285
+ ) -> list[str]:
286
+ args = []
287
+ if self.codex_model:
288
+ args.extend(["-m", self.codex_model])
289
+ for image_path in image_paths:
290
+ args.extend(["--image", str(image_path)])
291
+ args.extend(
292
+ [
293
+ "-c",
294
+ f"approval_policy={self.approval_policy}",
295
+ "-c",
296
+ f"sandbox_mode={self.sandbox_mode}",
297
+ "--json",
298
+ "--cd",
299
+ str(project_path),
300
+ f"{self.PROMPT_PREFIX}{user_message}",
301
+ ]
302
+ )
303
+ if skip_git_repo_check:
304
+ return ["--skip-git-repo-check", *args]
305
+ return args
306
+
307
+ def _codex_resume_base(
308
+ self,
309
+ user_message: str,
310
+ skip_git_repo_check: bool,
311
+ image_paths: Sequence[Path] = (),
312
+ ) -> list[str]:
313
+ args = []
314
+ if self.codex_model:
315
+ args.extend(["-m", self.codex_model])
316
+ for image_path in image_paths:
317
+ args.extend(["--image", str(image_path)])
318
+ args.extend(
319
+ [
320
+ "-c",
321
+ f"approval_policy={self.approval_policy}",
322
+ "-c",
323
+ f"sandbox_mode={self.sandbox_mode}",
324
+ "--json",
325
+ f"{self.PROMPT_PREFIX}{user_message}",
326
+ ]
327
+ )
328
+ if skip_git_repo_check:
329
+ return ["--skip-git-repo-check", *args]
330
+ return args
331
+
332
+ def _codex_resume_args(
333
+ self,
334
+ session_id: str,
335
+ user_message: str,
336
+ skip_git_repo_check: bool,
337
+ image_paths: Sequence[Path] = (),
338
+ ) -> list[str]:
339
+ return [
340
+ *self._codex_resume_base(user_message, skip_git_repo_check, image_paths)[:-1],
341
+ session_id,
342
+ f"{self.PROMPT_PREFIX}{user_message}",
343
+ ]
344
+
345
+ def _copilot_env(self, project_path: Path, skip_git_repo_check: bool) -> dict[str, str]:
346
+ env = os.environ.copy()
347
+ if skip_git_repo_check:
348
+ env["COPILOT_HOME"] = str(project_path / ".copilot")
349
+ return env
350
+
351
+ def _copilot_base(self, user_message: str, skip_git_repo_check: bool) -> list[str]:
352
+ args = []
353
+ if self.copilot_model:
354
+ args.extend(["--model", self.copilot_model])
355
+ if self.copilot_autopilot:
356
+ args.append("--autopilot")
357
+ if self.copilot_no_ask_user:
358
+ args.append("--no-ask-user")
359
+ if self.copilot_allow_all:
360
+ args.append("--allow-all")
361
+ elif self.copilot_allow_all_tools or skip_git_repo_check:
362
+ args.append("--allow-all-tools")
363
+ for tool in self.copilot_allow_tools:
364
+ args.extend(["--allow-tool", tool])
365
+ for tool in self.copilot_deny_tools:
366
+ args.extend(["--deny-tool", tool])
367
+ if self.copilot_available_tools:
368
+ args.extend(["--available-tools", ",".join(self.copilot_available_tools)])
369
+ args.extend(
370
+ [
371
+ "--output-format=json",
372
+ "--prompt",
373
+ f"{self.PROMPT_PREFIX}{user_message}",
374
+ ]
375
+ )
376
+ return args
377
+
378
+ def create_session(
379
+ self,
380
+ provider: str,
381
+ project_path: Path,
382
+ user_message: str,
383
+ *,
384
+ skip_git_repo_check: bool = False,
385
+ image_paths: Sequence[Path] = (),
386
+ on_stall: Optional[Callable[[AgentStallInfo], None]] = None,
387
+ ) -> AgentRunResult:
388
+ if provider == "codex":
389
+ args = [
390
+ self.codex_bin,
391
+ "exec",
392
+ *self._codex_base(project_path, user_message, skip_git_repo_check, image_paths),
393
+ ]
394
+ return self._run_with_output_file(args, cwd=project_path, tail_args=1, on_stall=on_stall)
395
+ elif provider == "copilot":
396
+ if image_paths:
397
+ return AgentRunResult(None, False, "", "Image attachments are not supported for Copilot sessions.", [])
398
+ args = [self.copilot_bin, *self._copilot_base(user_message, skip_git_repo_check)]
399
+ return self._run(
400
+ args,
401
+ cwd=project_path,
402
+ env=self._copilot_env(project_path, skip_git_repo_check),
403
+ on_stall=on_stall,
404
+ )
405
+ else:
406
+ return AgentRunResult(None, False, "", f"Unsupported provider: {provider}", [])
407
+
408
+ def resume_session(
409
+ self,
410
+ provider: str,
411
+ session_id: str,
412
+ project_path: Path,
413
+ user_message: str,
414
+ *,
415
+ skip_git_repo_check: bool = False,
416
+ image_paths: Sequence[Path] = (),
417
+ on_stall: Optional[Callable[[AgentStallInfo], None]] = None,
418
+ ) -> AgentRunResult:
419
+ if provider == "codex":
420
+ args = [
421
+ self.codex_bin,
422
+ "exec",
423
+ "resume",
424
+ *self._codex_resume_args(session_id, user_message, skip_git_repo_check, image_paths),
425
+ ]
426
+ return self._run_with_output_file(args, cwd=project_path, tail_args=2, on_stall=on_stall)
427
+ elif provider == "copilot":
428
+ if image_paths:
429
+ return AgentRunResult(None, False, "", "Image attachments are not supported for Copilot sessions.", [])
430
+ args = [self.copilot_bin, f"--resume={session_id}", *self._copilot_base(user_message, skip_git_repo_check)]
431
+ return self._run(
432
+ args,
433
+ cwd=project_path,
434
+ env=self._copilot_env(project_path, skip_git_repo_check),
435
+ on_stall=on_stall,
436
+ )
437
+ else:
438
+ return AgentRunResult(None, False, "", f"Unsupported provider: {provider}", [])
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from telegram import BotCommand, BotCommandScopeChat, BotCommandScopeDefault
6
+ from telegram.ext import Application, CallbackQueryHandler, CommandHandler, MessageHandler, filters as tg_filters
7
+
8
+ from coding_agent_telegram.command_router import CommandRouter
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def default_bot_commands(*, enable_commit_command: bool) -> list[BotCommand]:
15
+ commands = [
16
+ BotCommand("project", "Set the current project folder"),
17
+ BotCommand("branch", "Create and switch to a git work branch"),
18
+ BotCommand("new", "Create a new session"),
19
+ BotCommand("switch", "List sessions or switch to one"),
20
+ BotCommand("current", "Show the active session"),
21
+ BotCommand("push", "Push the current session branch"),
22
+ ]
23
+ if enable_commit_command:
24
+ commands.insert(5, BotCommand("commit", "Run validated git commit commands"))
25
+ return commands
26
+
27
+
28
+ def allowed_private_chat_filter(allowed_chat_ids: set[int]):
29
+ return tg_filters.Chat(chat_id=sorted(allowed_chat_ids)) & tg_filters.ChatType.PRIVATE
30
+
31
+
32
+ async def initialize_bot_commands(app: Application, *, enable_commit_command: bool, allowed_chat_ids: set[int]) -> None:
33
+ commands = default_bot_commands(enable_commit_command=enable_commit_command)
34
+ await app.bot.delete_my_commands(scope=BotCommandScopeDefault())
35
+ for chat_id in sorted(allowed_chat_ids):
36
+ await app.bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id))
37
+
38
+
39
+ async def handle_error(update, context) -> None:
40
+ logger.exception("Telegram handler failed.", exc_info=context.error)
41
+ if update is not None and getattr(update, "effective_chat", None) is not None:
42
+ await context.bot.send_message(
43
+ chat_id=update.effective_chat.id,
44
+ text="⚠️ Command failed. Check the server log for details.",
45
+ )
46
+
47
+
48
+ def build_application(token: str, router: CommandRouter, *, allowed_chat_ids: set[int]) -> Application:
49
+ app = Application.builder().token(token).build()
50
+ allowed_private = allowed_private_chat_filter(allowed_chat_ids)
51
+ unsupported_media = (
52
+ tg_filters.ANIMATION
53
+ | tg_filters.AUDIO
54
+ | tg_filters.Document.ALL
55
+ | tg_filters.Sticker.ALL
56
+ | tg_filters.VIDEO
57
+ | tg_filters.VIDEO_NOTE
58
+ | tg_filters.VOICE
59
+ )
60
+
61
+ app.add_handler(CommandHandler("project", router.handle_project, filters=allowed_private))
62
+ app.add_handler(CommandHandler("branch", router.handle_branch, filters=allowed_private))
63
+ app.add_handler(CommandHandler("new", router.handle_new, filters=allowed_private))
64
+ app.add_handler(CommandHandler("switch", router.handle_switch, filters=allowed_private))
65
+ app.add_handler(CommandHandler("current", router.handle_current, filters=allowed_private))
66
+ app.add_handler(CommandHandler("commit", router.handle_commit, filters=allowed_private))
67
+ app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private))
68
+ app.add_handler(CallbackQueryHandler(router.handle_trust_project_callback, pattern=r"^trustproject:(yes|no):"))
69
+ app.add_handler(MessageHandler(allowed_private & tg_filters.PHOTO, router.handle_photo))
70
+ app.add_handler(MessageHandler(allowed_private & tg_filters.TEXT & ~tg_filters.COMMAND, router.handle_message))
71
+ app.add_handler(MessageHandler(allowed_private & unsupported_media, router.handle_unsupported_message))
72
+ app.add_error_handler(handle_error)
73
+
74
+ return app
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import importlib.resources
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from coding_agent_telegram.agent_runner import MultiAgentRunner
12
+ from coding_agent_telegram.bot import build_application, default_bot_commands, initialize_bot_commands
13
+ from coding_agent_telegram.command_router import CommandRouter, RouterDeps
14
+ from coding_agent_telegram.config import load_config
15
+ from coding_agent_telegram.logging_utils import setup_logging
16
+ from coding_agent_telegram.session_store import SessionStore
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+ BOT_ID_HASH_PREFIX_LENGTH = 12
21
+
22
+
23
+ def _ensure_env_file() -> Path:
24
+ env_path = Path.cwd() / ".env"
25
+ if not env_path.exists():
26
+ template = importlib.resources.files("coding_agent_telegram").joinpath("resources/.env.example").read_text(
27
+ encoding="utf-8"
28
+ )
29
+ env_path.write_text(template, encoding="utf-8")
30
+ return env_path
31
+
32
+
33
+ def _bot_id_from_token(token: str) -> str:
34
+ return f"bot-{hashlib.sha256(token.encode('utf-8')).hexdigest()[:BOT_ID_HASH_PREFIX_LENGTH]}"
35
+
36
+
37
+ async def _run_polling_apps(apps: Sequence) -> None:
38
+ started_apps = []
39
+ try:
40
+ for app in apps:
41
+ await app.initialize()
42
+ me = await app.bot.get_me()
43
+ logger.info(
44
+ "Connected Telegram bot: @%s (id=%s, name=%s)",
45
+ me.username or "unknown",
46
+ me.id,
47
+ me.first_name,
48
+ )
49
+ enable_commit_command = bool(app.bot_data.get("enable_commit_command", False))
50
+ allowed_chat_ids = set(app.bot_data.get("allowed_chat_ids", set()))
51
+ await initialize_bot_commands(
52
+ app,
53
+ enable_commit_command=enable_commit_command,
54
+ allowed_chat_ids=allowed_chat_ids,
55
+ )
56
+ logger.info(
57
+ "Registered %d Telegram commands for %d allowed chat(s) on @%s",
58
+ len(default_bot_commands(enable_commit_command=enable_commit_command)),
59
+ len(allowed_chat_ids),
60
+ me.username or "unknown",
61
+ )
62
+ await app.start()
63
+ if app.updater is None:
64
+ raise RuntimeError("Telegram updater is not available.")
65
+ await app.updater.start_polling()
66
+ logger.info("Started polling for @%s", me.username or "unknown")
67
+ started_apps.append(app)
68
+
69
+ logger.info("Started %d Telegram bot(s).", len(started_apps))
70
+ await asyncio.Event().wait()
71
+ finally:
72
+ for app in reversed(started_apps):
73
+ if app.updater is not None:
74
+ await app.updater.stop()
75
+ await app.stop()
76
+ await app.shutdown()
77
+
78
+
79
+ async def _run(cfg, store: SessionStore, runner: MultiAgentRunner) -> None:
80
+ apps = []
81
+ for token in cfg.telegram_bot_tokens:
82
+ router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id=_bot_id_from_token(token)))
83
+ app = build_application(token, router, allowed_chat_ids=cfg.allowed_chat_ids)
84
+ app.bot_data["enable_commit_command"] = cfg.enable_commit_command
85
+ app.bot_data["allowed_chat_ids"] = set(cfg.allowed_chat_ids)
86
+ apps.append(app)
87
+
88
+ await _run_polling_apps(apps)
89
+
90
+
91
+ def main() -> None:
92
+ try:
93
+ cfg = load_config()
94
+ except ValueError as exc:
95
+ env_path = _ensure_env_file()
96
+ print(str(exc), file=sys.stderr)
97
+ print("", file=sys.stderr)
98
+ print(f"Created {env_path} if it did not already exist.", file=sys.stderr)
99
+ print("Update these fields in .env:", file=sys.stderr)
100
+ print("- WORKSPACE_ROOT", file=sys.stderr)
101
+ print("- TELEGRAM_BOT_TOKENS", file=sys.stderr)
102
+ print("- ALLOWED_CHAT_IDS", file=sys.stderr)
103
+ print("- LOG_DIR", file=sys.stderr)
104
+ print("", file=sys.stderr)
105
+ print("Then run: coding-agent-telegram", file=sys.stderr)
106
+ raise SystemExit(1)
107
+
108
+ log_file = setup_logging(cfg.log_level, cfg.log_dir)
109
+ logger.info("Logging to %s", log_file)
110
+
111
+ store = SessionStore(cfg.state_file, cfg.state_backup_file)
112
+ runner = MultiAgentRunner(
113
+ codex_bin=cfg.codex_bin,
114
+ copilot_bin=cfg.copilot_bin,
115
+ approval_policy=cfg.codex_approval_policy,
116
+ sandbox_mode=cfg.codex_sandbox_mode,
117
+ codex_model=cfg.codex_model,
118
+ copilot_model=cfg.copilot_model,
119
+ copilot_autopilot=cfg.copilot_autopilot,
120
+ copilot_no_ask_user=cfg.copilot_no_ask_user,
121
+ copilot_allow_all=cfg.copilot_allow_all,
122
+ copilot_allow_all_tools=cfg.copilot_allow_all_tools,
123
+ copilot_allow_tools=cfg.copilot_allow_tools,
124
+ copilot_deny_tools=cfg.copilot_deny_tools,
125
+ copilot_available_tools=cfg.copilot_available_tools,
126
+ )
127
+ try:
128
+ asyncio.run(_run(cfg, store, runner))
129
+ except KeyboardInterrupt:
130
+ logger.info("Stopping Telegram bot polling.")
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from coding_agent_telegram.router.base import CommandRouterBase, RouterDeps
4
+ from coding_agent_telegram.router.git_commands import GitCommandMixin
5
+ from coding_agent_telegram.router.message_commands import MessageCommandMixin
6
+ from coding_agent_telegram.router.project_commands import ProjectCommandMixin
7
+ from coding_agent_telegram.router.session_commands import SessionCommandMixin
8
+
9
+
10
+ class CommandRouter(
11
+ ProjectCommandMixin,
12
+ GitCommandMixin,
13
+ SessionCommandMixin,
14
+ MessageCommandMixin,
15
+ CommandRouterBase,
16
+ ):
17
+ """Compose categorized command handlers behind the historical router API."""
18
+