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.
- coding_agent_telegram/__init__.py +4 -0
- coding_agent_telegram/__main__.py +5 -0
- coding_agent_telegram/agent_runner.py +438 -0
- coding_agent_telegram/bot.py +74 -0
- coding_agent_telegram/cli.py +130 -0
- coding_agent_telegram/command_router.py +18 -0
- coding_agent_telegram/config.py +123 -0
- coding_agent_telegram/diff_utils.py +369 -0
- coding_agent_telegram/filters.py +42 -0
- coding_agent_telegram/git_utils.py +250 -0
- coding_agent_telegram/logging_utils.py +17 -0
- coding_agent_telegram/resources/.env.example +83 -0
- coding_agent_telegram/resources/sensitive_path_globs.txt +15 -0
- coding_agent_telegram/resources/snapshot_excluded_dir_globs.txt +30 -0
- coding_agent_telegram/resources/snapshot_excluded_dir_names.txt +16 -0
- coding_agent_telegram/resources/snapshot_excluded_file_globs.txt +4 -0
- coding_agent_telegram/router/__init__.py +2 -0
- coding_agent_telegram/router/base.py +536 -0
- coding_agent_telegram/router/git_commands.py +106 -0
- coding_agent_telegram/router/message_commands.py +45 -0
- coding_agent_telegram/router/project_commands.py +234 -0
- coding_agent_telegram/router/session_commands.py +197 -0
- coding_agent_telegram/session_runtime.py +505 -0
- coding_agent_telegram/session_store.py +309 -0
- coding_agent_telegram/telegram_sender.py +236 -0
- coding_agent_telegram-2026.3.26.dist-info/METADATA +475 -0
- coding_agent_telegram-2026.3.26.dist-info/RECORD +31 -0
- coding_agent_telegram-2026.3.26.dist-info/WHEEL +5 -0
- coding_agent_telegram-2026.3.26.dist-info/entry_points.txt +2 -0
- coding_agent_telegram-2026.3.26.dist-info/licenses/LICENSE +21 -0
- coding_agent_telegram-2026.3.26.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|