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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/projects/registry.py
ADDED
|
@@ -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)
|
app/security/__init__.py
ADDED
|
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)
|
app/telegram/__init__.py
ADDED
|
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
|
+
]
|