remote-coder 0.4.1__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 (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. remote_coder-0.4.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from hashlib import sha256
7
+ from pathlib import Path
8
+ from threading import Lock
9
+ import yaml
10
+ from pydantic import BaseModel, Field, SecretStr, field_validator
11
+
12
+ from app.config import Settings
13
+ from app.models import ModelName
14
+
15
+ _NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
16
+
17
+ WEBHOOK_TOKEN_HASH_PREFIX_LENGTH = 16
18
+ _WEBHOOK_TOKEN_HASH_PREFIX_RE = re.compile(
19
+ rf"^[0-9a-f]{{{WEBHOOK_TOKEN_HASH_PREFIX_LENGTH}}}$"
20
+ )
21
+
22
+
23
+ def compute_token_hash(token: str) -> str:
24
+ return sha256(token.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def compute_token_hash_prefix(token: str, length: int = WEBHOOK_TOKEN_HASH_PREFIX_LENGTH) -> str:
28
+ return compute_token_hash(token)[:length]
29
+
30
+
31
+ def build_public_webhook_url(public_base_url: str, bot_token: str) -> str:
32
+ base = public_base_url.strip().rstrip("/")
33
+ prefix = compute_token_hash_prefix(bot_token.strip())
34
+ return f"{base}/telegram/webhook/{prefix}"
35
+
36
+
37
+ def normalize_webhook_token_hash_path_segment(segment: str) -> str | None:
38
+ s = segment.strip().lower()
39
+ return s if _WEBHOOK_TOKEN_HASH_PREFIX_RE.fullmatch(s) else None
40
+
41
+
42
+ def _parse_env_int_id_list(var_name: str) -> list[int]:
43
+ raw = os.getenv(var_name)
44
+ if raw is None or not str(raw).strip():
45
+ return []
46
+ parts = [item.strip() for item in str(raw).split(",") if item.strip()]
47
+ return [int(v) for v in parts]
48
+
49
+
50
+ def _legacy_projects_need_env_fill(projects: object) -> bool:
51
+ if not isinstance(projects, list):
52
+ return False
53
+ for p in projects:
54
+ if not isinstance(p, dict):
55
+ continue
56
+ token = p.get("bot_token")
57
+ if token is None or (isinstance(token, str) and not token.strip()):
58
+ return True
59
+ chats = p.get("allowed_chat_ids")
60
+ if chats is None or (isinstance(chats, list) and len(chats) == 0):
61
+ return True
62
+ return False
63
+
64
+
65
+ def _fill_legacy_projects_payload_from_env(data: dict) -> dict:
66
+ projects = data.get("projects")
67
+ if not isinstance(projects, list) or not _legacy_projects_need_env_fill(projects):
68
+ return data
69
+
70
+ from dotenv import load_dotenv
71
+
72
+ load_dotenv()
73
+
74
+ env_token = (os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
75
+ env_chats = _parse_env_int_id_list("TELEGRAM_ALLOWED_CHAT_IDS")
76
+ env_users = _parse_env_int_id_list("TELEGRAM_ALLOWED_USER_IDS")
77
+ wh_raw = os.getenv("TELEGRAM_WEBHOOK_SECRET")
78
+ env_wh = wh_raw.strip() if wh_raw and str(wh_raw).strip() else None
79
+
80
+ new_projects: list[object] = []
81
+ for p in projects:
82
+ if not isinstance(p, dict):
83
+ new_projects.append(p)
84
+ continue
85
+ row = dict(p)
86
+ bt = row.get("bot_token")
87
+ if bt is None or (isinstance(bt, str) and not bt.strip()):
88
+ if env_token:
89
+ row["bot_token"] = env_token
90
+ ac = row.get("allowed_chat_ids")
91
+ if ac is None or (isinstance(ac, list) and len(ac) == 0):
92
+ if env_chats:
93
+ row["allowed_chat_ids"] = env_chats
94
+ if row.get("allowed_user_ids") is None:
95
+ row["allowed_user_ids"] = env_users
96
+ if (
97
+ row.get("webhook_secret") in (None, "")
98
+ and env_wh is not None
99
+ ):
100
+ row["webhook_secret"] = env_wh
101
+ new_projects.append(row)
102
+
103
+ out = dict(data)
104
+ out["projects"] = new_projects
105
+ return out
106
+
107
+
108
+ def mask_bot_token(token: str) -> str:
109
+ if not token:
110
+ return "(not set)"
111
+ if len(token) <= 8:
112
+ return "***"
113
+ return f"***…{token[-4:]}"
114
+
115
+
116
+ def projects_config_path_for_settings(project_root: Path, explicit: Path | None) -> Path:
117
+ if explicit is not None:
118
+ return explicit.expanduser().resolve()
119
+ return (project_root / ".remote-coder" / "projects.json").resolve()
120
+
121
+
122
+ class ProjectRecord(BaseModel):
123
+ model_config = {"extra": "forbid"}
124
+
125
+ name: str
126
+ root_path: Path
127
+ worktree_base_dir: Path
128
+ default_model: ModelName = ModelName.CLAUDE
129
+ enabled: bool = True
130
+ bot_token: SecretStr
131
+ webhook_secret: SecretStr | None = None
132
+ allowed_chat_ids: list[int]
133
+ allowed_user_ids: list[int] = Field(default_factory=list)
134
+
135
+ @field_validator("name")
136
+ @classmethod
137
+ def validate_name(cls, value: str) -> str:
138
+ if not _NAME_PATTERN.match(value):
139
+ raise ValueError(
140
+ "project name must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
141
+ )
142
+ return value
143
+
144
+ @field_validator("root_path", "worktree_base_dir", mode="before")
145
+ @classmethod
146
+ def expand_path(cls, value: object) -> Path:
147
+ if isinstance(value, Path):
148
+ return value.expanduser().resolve()
149
+ if isinstance(value, str):
150
+ return Path(value).expanduser().resolve()
151
+ raise TypeError("path must be str or Path")
152
+
153
+
154
+ class ProjectsFilePayload(BaseModel):
155
+ model_config = {"extra": "forbid"}
156
+
157
+ default_project: str
158
+ projects: list[ProjectRecord] = Field(default_factory=list)
159
+
160
+
161
+ class ProjectRegistry:
162
+ def __init__(self, config_path: Path) -> None:
163
+ self._path = config_path
164
+ self._lock = Lock()
165
+ self._payload = ProjectsFilePayload(default_project="", projects=[])
166
+
167
+ @property
168
+ def config_path(self) -> Path:
169
+ return self._path
170
+
171
+ def load(self) -> None:
172
+ with self._lock:
173
+ self._payload = self._read_file_unlocked()
174
+
175
+ def ensure_seeded_from_settings(self, settings: Settings) -> None:
176
+ # TELEGRAM_* 는 레지스트리에 프로젝트가 없을 때만 시드에 사용합니다. 런타임 인증은 레지스트리의
177
+ # allowed_chat_ids / allowed_user_ids 와 각 봇 AllowlistAuthService 가 담당합니다.
178
+ self._path.parent.mkdir(parents=True, exist_ok=True)
179
+ file_existed = self._path.exists()
180
+ if file_existed:
181
+ self.load()
182
+
183
+ token = settings.telegram_bot_token
184
+ if token is None:
185
+ if not file_existed:
186
+ empty = ProjectsFilePayload(default_project="", projects=[])
187
+ with self._lock:
188
+ self._payload = empty
189
+ self._write_file_unlocked(empty)
190
+ return
191
+
192
+ if self._payload.projects:
193
+ return
194
+
195
+ seed = ProjectsFilePayload(
196
+ default_project=settings.default_project,
197
+ projects=[
198
+ ProjectRecord(
199
+ name=settings.default_project,
200
+ root_path=settings.project_root,
201
+ worktree_base_dir=settings.worktree_base_dir,
202
+ default_model=settings.default_model,
203
+ enabled=True,
204
+ bot_token=token,
205
+ webhook_secret=settings.telegram_webhook_secret,
206
+ allowed_chat_ids=list(settings.telegram_allowed_chat_ids),
207
+ allowed_user_ids=list(settings.telegram_allowed_user_ids),
208
+ )
209
+ ],
210
+ )
211
+ with self._lock:
212
+ self._payload = seed
213
+ self._write_file_unlocked(seed)
214
+
215
+ def save(self) -> None:
216
+ with self._lock:
217
+ self._path.parent.mkdir(parents=True, exist_ok=True)
218
+ self._write_file_unlocked(self._payload)
219
+
220
+ def list_projects(self) -> list[ProjectRecord]:
221
+ with self._lock:
222
+ return list(self._payload.projects)
223
+
224
+ def get(self, name: str) -> ProjectRecord | None:
225
+ with self._lock:
226
+ for p in self._payload.projects:
227
+ if p.name == name:
228
+ return p.model_copy(deep=True)
229
+ return None
230
+
231
+ def get_by_token_hash(self, token_hash: str) -> ProjectRecord | None:
232
+ normalized = normalize_webhook_token_hash_path_segment(token_hash)
233
+ if normalized is None:
234
+ return None
235
+ with self._lock:
236
+ for project in self._payload.projects:
237
+ prefix = compute_token_hash_prefix(project.bot_token.get_secret_value())
238
+ if prefix == normalized:
239
+ return project.model_copy(deep=True)
240
+ return None
241
+
242
+ def get_default_project_name(self) -> str:
243
+ with self._lock:
244
+ return self._payload.default_project
245
+
246
+ def set_default_project(self, name: str) -> None:
247
+ with self._lock:
248
+ if not any(p.name == name for p in self._payload.projects):
249
+ raise ValueError(f"unknown project: {name}")
250
+ self._payload.default_project = name
251
+ self._write_file_unlocked(self._payload)
252
+
253
+ def add_project(self, record: ProjectRecord) -> None:
254
+ record = record.model_copy(deep=True)
255
+ self._validate_paths(record)
256
+ with self._lock:
257
+ if any(p.name == record.name for p in self._payload.projects):
258
+ raise ValueError(f"project already exists: {record.name}")
259
+ ProjectRegistry._raise_if_token_hash_prefix_collides(record, list(self._payload.projects))
260
+ projects = list(self._payload.projects)
261
+ projects.append(record)
262
+ self._payload = ProjectsFilePayload(
263
+ default_project=self._payload.default_project or record.name,
264
+ projects=projects,
265
+ )
266
+ self._write_file_unlocked(self._payload)
267
+
268
+ def update_project(self, name: str, record: ProjectRecord) -> None:
269
+ record = record.model_copy(deep=True)
270
+ if record.name != name:
271
+ raise ValueError("cannot change project name via update; remove and add")
272
+ self._validate_paths(record)
273
+ with self._lock:
274
+ projects = [p for p in self._payload.projects if p.name != name]
275
+ if len(projects) == len(self._payload.projects):
276
+ raise ValueError(f"unknown project: {name}")
277
+ ProjectRegistry._raise_if_token_hash_prefix_collides(record, projects)
278
+ projects.append(record)
279
+ self._payload = ProjectsFilePayload(
280
+ default_project=self._payload.default_project,
281
+ projects=projects,
282
+ )
283
+ self._write_file_unlocked(self._payload)
284
+
285
+ def remove_project(self, name: str) -> None:
286
+ with self._lock:
287
+ projects = [p for p in self._payload.projects if p.name != name]
288
+ if len(projects) == len(self._payload.projects):
289
+ raise ValueError(f"unknown project: {name}")
290
+ new_default = self._payload.default_project
291
+ if new_default == name and projects:
292
+ new_default = projects[0].name
293
+ elif not projects:
294
+ new_default = ""
295
+ self._payload = ProjectsFilePayload(default_project=new_default, projects=projects)
296
+ self._write_file_unlocked(self._payload)
297
+
298
+ def to_public_dict(self) -> dict:
299
+ # 호출 측에서 이미 락을 잡고 있으면 데드락이 나므로, API 응답 용도로만 사용하세요.
300
+ with self._lock:
301
+ return {
302
+ "default_project": self._payload.default_project,
303
+ "projects": [
304
+ ProjectRegistry._project_record_to_public_dict(p) for p in self._payload.projects
305
+ ],
306
+ }
307
+
308
+ @staticmethod
309
+ def _project_record_to_public_dict(record: ProjectRecord) -> dict:
310
+ token_plain = record.bot_token.get_secret_value()
311
+ prefix = compute_token_hash_prefix(token_plain)
312
+ secret_plain = (
313
+ record.webhook_secret.get_secret_value().strip() if record.webhook_secret else ""
314
+ )
315
+ return {
316
+ "name": record.name,
317
+ "root_path": str(record.root_path),
318
+ "worktree_base_dir": str(record.worktree_base_dir),
319
+ "default_model": record.default_model.value,
320
+ "enabled": record.enabled,
321
+ "bot_token_masked": mask_bot_token(token_plain),
322
+ "webhook_secret_set": bool(secret_plain),
323
+ "allowed_chat_ids": list(record.allowed_chat_ids),
324
+ "allowed_user_ids": list(record.allowed_user_ids),
325
+ "webhook_path": f"/telegram/webhook/{prefix}",
326
+ "token_hash_prefix": prefix,
327
+ }
328
+
329
+ @staticmethod
330
+ def _raise_if_token_hash_prefix_collides(record: ProjectRecord, existing: list[ProjectRecord]) -> None:
331
+ prefix = compute_token_hash_prefix(record.bot_token.get_secret_value())
332
+ for p in existing:
333
+ if compute_token_hash_prefix(p.bot_token.get_secret_value()) == prefix:
334
+ raise ValueError(
335
+ f"webhook token hash prefix collision with project {p.name!r}",
336
+ )
337
+
338
+ def _read_file_unlocked(self) -> ProjectsFilePayload:
339
+ if not self._path.exists():
340
+ return ProjectsFilePayload(default_project="", projects=[])
341
+ raw = self._path.read_text(encoding="utf-8")
342
+ if self._path.suffix.lower() in (".yaml", ".yml"):
343
+ data = yaml.safe_load(raw) or {}
344
+ else:
345
+ data = json.loads(raw) if raw.strip() else {}
346
+ if isinstance(data, dict):
347
+ data = _fill_legacy_projects_payload_from_env(data)
348
+ return ProjectsFilePayload.model_validate(data)
349
+
350
+ def _write_file_unlocked(self, payload: ProjectsFilePayload) -> None:
351
+ storable = self._payload_to_storable_dict(payload)
352
+ if self._path.suffix.lower() in (".yaml", ".yml"):
353
+ text = yaml.safe_dump(storable, allow_unicode=True, default_flow_style=False)
354
+ else:
355
+ text = json.dumps(storable, indent=2, ensure_ascii=False)
356
+ self._path.write_text(text + "\n", encoding="utf-8")
357
+
358
+ @staticmethod
359
+ def _project_record_to_storable_dict(record: ProjectRecord) -> dict:
360
+ data = record.model_dump(mode="json", exclude={"bot_token", "webhook_secret"})
361
+ data["bot_token"] = record.bot_token.get_secret_value()
362
+ data["webhook_secret"] = (
363
+ record.webhook_secret.get_secret_value() if record.webhook_secret else None
364
+ )
365
+ return data
366
+
367
+ @staticmethod
368
+ def _payload_to_storable_dict(payload: ProjectsFilePayload) -> dict:
369
+ return {
370
+ "default_project": payload.default_project,
371
+ "projects": [
372
+ ProjectRegistry._project_record_to_storable_dict(p) for p in payload.projects
373
+ ],
374
+ }
375
+
376
+ @staticmethod
377
+ def _validate_paths(record: ProjectRecord) -> None:
378
+ root = record.root_path
379
+ if not root.exists():
380
+ raise ValueError(f"root_path does not exist: {root}")
381
+ if not root.is_dir():
382
+ raise ValueError(f"root_path is not a directory: {root}")
383
+ wt = record.worktree_base_dir
384
+ wt.mkdir(parents=True, exist_ok=True)
File without changes
app/security/auth.py ADDED
@@ -0,0 +1,19 @@
1
+ class AllowlistAuthService:
2
+ def __init__(self, allowed_chat_ids: set[int], allowed_user_ids: set[int] | None = None) -> None:
3
+ self._allowed_chat_ids = allowed_chat_ids
4
+ self._allowed_user_ids = allowed_user_ids or set()
5
+
6
+ @property
7
+ def allowed_chat_ids(self) -> frozenset[int]:
8
+ return frozenset(self._allowed_chat_ids)
9
+
10
+ @property
11
+ def allowed_user_ids(self) -> frozenset[int]:
12
+ return frozenset(self._allowed_user_ids)
13
+
14
+ def is_allowed(self, chat_id: int, user_id: int | None = None) -> bool:
15
+ if chat_id in self._allowed_chat_ids:
16
+ return True
17
+ if user_id is not None and user_id in self._allowed_user_ids:
18
+ return True
19
+ return False
app/system_startup.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from app.git.service import GitWorktreeService
6
+ from app.monitoring.events import EventLogger
7
+ from app.projects.registry import ProjectRegistry
8
+
9
+
10
+ def run_startup_project_pulls(
11
+ *,
12
+ pull_projects_on_server_startup_enabled: bool,
13
+ project_registry: ProjectRegistry,
14
+ git_service: GitWorktreeService,
15
+ remote: str,
16
+ system_log: EventLogger,
17
+ ) -> None:
18
+ if not pull_projects_on_server_startup_enabled:
19
+ return
20
+
21
+ roots: dict[Path, str] = {}
22
+ for record in project_registry.list_projects():
23
+ if not record.enabled:
24
+ continue
25
+ path = record.root_path.resolve()
26
+ if path not in roots:
27
+ roots[path] = record.name
28
+
29
+ for root_path, project_name in roots.items():
30
+ try:
31
+ summary = git_service.pull_repository(root_path, remote)
32
+ system_log.info("startup pull completed: %s", summary, project=project_name)
33
+ except Exception:
34
+ system_log.exception("startup pull failed", project=project_name)
File without changes
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from threading import Lock
6
+
7
+ from app.projects.registry import ProjectRecord
8
+ from app.security.auth import AllowlistAuthService
9
+ from app.telegram.commands import CommandContext
10
+ from app.telegram.notifier import Notifier
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BotInstance:
15
+ project_name: str
16
+ token_hash: str
17
+ notifier: Notifier
18
+ auth_service: AllowlistAuthService
19
+ command_context: CommandContext
20
+ webhook_secret: str | None = None
21
+
22
+
23
+ BotInstanceFactory = Callable[[ProjectRecord], BotInstance]
24
+
25
+
26
+ class BotInstanceManager:
27
+ # NOTE: Shared singletons are captured by the factory closure and injected
28
+ # into each BotInstance command_context. This manager only stores and routes
29
+ # per-bot notifier/auth/command-context bindings.
30
+
31
+ def __init__(self, factory: BotInstanceFactory) -> None:
32
+ self._factory = factory
33
+ self._lock = Lock()
34
+ self._by_name: dict[str, BotInstance] = {}
35
+ self._by_hash: dict[str, BotInstance] = {}
36
+
37
+ def register(self, record: ProjectRecord) -> BotInstance:
38
+ instance = self._factory(record)
39
+ with self._lock:
40
+ previous = self._by_name.get(instance.project_name)
41
+ if previous is not None:
42
+ self._by_hash.pop(previous.token_hash, None)
43
+ self._by_name[instance.project_name] = instance
44
+ self._by_hash[instance.token_hash] = instance
45
+ return instance
46
+
47
+ def unregister(self, name: str) -> bool:
48
+ with self._lock:
49
+ removed = self._by_name.pop(name, None)
50
+ if removed is None:
51
+ return False
52
+ self._by_hash.pop(removed.token_hash, None)
53
+ return True
54
+
55
+ def get(self, token_hash: str) -> BotInstance | None:
56
+ if not token_hash:
57
+ return None
58
+ with self._lock:
59
+ return self._by_hash.get(token_hash)
60
+
61
+ def get_by_name(self, name: str) -> BotInstance | None:
62
+ with self._lock:
63
+ return self._by_name.get(name)
64
+
65
+ def list_all(self) -> list[BotInstance]:
66
+ with self._lock:
67
+ return list(self._by_name.values())
@@ -0,0 +1,64 @@
1
+ from app.telegram.commands.base import (
2
+ MODEL_USAGE,
3
+ CommandContext,
4
+ CommandResponse,
5
+ ConfirmableCommand,
6
+ InlineButton,
7
+ TelegramCommand,
8
+ TelegramMessage,
9
+ effective_model_for_chat,
10
+ effective_model_selection_for_chat,
11
+ effective_project_name_for_chat,
12
+ format_usage,
13
+ )
14
+ from app.telegram.commands.branch import BranchCommand, PrCommand, PullCommand, RebaseCommand
15
+ from app.telegram.commands.clear_stop import ClearCommand, StopCommand
16
+ from app.telegram.commands.fix import (
17
+ FIX_COMMIT_PENDING_ACTION,
18
+ FIX_SOURCE_AWAIT_ACTION,
19
+ FIX_SOURCE_PENDING_ACTION,
20
+ FixCommand,
21
+ )
22
+ from app.telegram.commands.model import ModelCommand
23
+ from app.telegram.commands.monitor import MonitorCommand
24
+ from app.telegram.commands.registry import (
25
+ CommandRegistry,
26
+ build_default_commands,
27
+ default_telegram_bot_commands,
28
+ )
29
+ from app.telegram.commands.status import ReportsCommand, StatusCommand
30
+ from app.telegram.commands.system import HelpCommand, InitCommand, StartCommand
31
+
32
+ __all__ = [
33
+ "MODEL_USAGE",
34
+ "BranchCommand",
35
+ "ClearCommand",
36
+ "CommandContext",
37
+ "CommandRegistry",
38
+ "CommandResponse",
39
+ "ConfirmableCommand",
40
+ "FIX_COMMIT_PENDING_ACTION",
41
+ "FIX_SOURCE_AWAIT_ACTION",
42
+ "FIX_SOURCE_PENDING_ACTION",
43
+ "FixCommand",
44
+ "HelpCommand",
45
+ "InitCommand",
46
+ "InlineButton",
47
+ "ModelCommand",
48
+ "MonitorCommand",
49
+ "PrCommand",
50
+ "PullCommand",
51
+ "RebaseCommand",
52
+ "ReportsCommand",
53
+ "StartCommand",
54
+ "StatusCommand",
55
+ "StopCommand",
56
+ "TelegramCommand",
57
+ "TelegramMessage",
58
+ "build_default_commands",
59
+ "default_telegram_bot_commands",
60
+ "effective_model_for_chat",
61
+ "effective_model_selection_for_chat",
62
+ "effective_project_name_for_chat",
63
+ "format_usage",
64
+ ]