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/admin/router.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Callable
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
8
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
9
|
+
from pydantic import BaseModel, Field, SecretStr
|
|
10
|
+
|
|
11
|
+
from app.admin.advanced_settings import AdvancedSettings, FileAdvancedSettingsStore
|
|
12
|
+
from app.admin.database_browser import ConversationDatabaseBrowser
|
|
13
|
+
from app.config import Settings
|
|
14
|
+
from app.models import ModelName, UiLanguage
|
|
15
|
+
from app.monitoring.events import EventLogger
|
|
16
|
+
from app.monitoring.log_buffer import InMemoryLogBuffer
|
|
17
|
+
from app.projects.registry import (
|
|
18
|
+
WEBHOOK_TOKEN_HASH_PREFIX_LENGTH,
|
|
19
|
+
ProjectRecord,
|
|
20
|
+
ProjectRegistry,
|
|
21
|
+
mask_bot_token,
|
|
22
|
+
)
|
|
23
|
+
from app.telegram.bot_instances import BotInstanceManager
|
|
24
|
+
from app.telegram.conversation import SQLiteConversationStore
|
|
25
|
+
from app.telegram.webhook_registration import TelegramWebhookRegistrar
|
|
26
|
+
|
|
27
|
+
_adminlog = EventLogger("app.admin", "admin.ui")
|
|
28
|
+
_monitorlog = EventLogger("app.admin.monitoring", "monitoring.ui")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _client_host(request: Request) -> str:
|
|
32
|
+
if request.client is None:
|
|
33
|
+
return ""
|
|
34
|
+
return request.client.host or ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_localhost(request: Request) -> None:
|
|
38
|
+
host = _client_host(request)
|
|
39
|
+
if host in ("127.0.0.1", "::1", "localhost", "testclient"):
|
|
40
|
+
return
|
|
41
|
+
raise HTTPException(status_code=403, detail="The admin UI is only available from localhost.")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
LocalhostOnly = Annotated[None, Depends(require_localhost)]
|
|
45
|
+
|
|
46
|
+
_DEFAULT_NEW_PROJECT_WEBHOOK_SECRET = "optional-secret"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProjectUpsertBody(BaseModel):
|
|
50
|
+
name: str
|
|
51
|
+
root_path: str
|
|
52
|
+
worktree_base_dir: str
|
|
53
|
+
default_model: ModelName = ModelName.CLAUDE
|
|
54
|
+
enabled: bool = True
|
|
55
|
+
bot_token: str | None = None
|
|
56
|
+
webhook_secret: str | None = None
|
|
57
|
+
allowed_chat_ids: list[int] | None = None
|
|
58
|
+
allowed_user_ids: list[int] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DefaultProjectBody(BaseModel):
|
|
62
|
+
name: str = Field(min_length=1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_ADMIN_ICON_NAMES = frozenset(
|
|
66
|
+
{"home.svg", "projects.svg", "advanced.svg", "logs.svg", "database.svg", "download.svg"}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache(maxsize=8)
|
|
71
|
+
def _load_template_html(template_name: str) -> str:
|
|
72
|
+
template_path = Path(__file__).parent / "templates" / template_name
|
|
73
|
+
return template_path.read_text(encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_admin_page(template_name: str, lang: UiLanguage) -> str:
|
|
77
|
+
html = _load_template_html(template_name)
|
|
78
|
+
inject = f'<script>window.__UI_LANG__="{lang.value}";</script>\n</head>'
|
|
79
|
+
return html.replace("</head>", inject, 1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _sync_bot_instance(manager: BotInstanceManager | None, record: ProjectRecord) -> None:
|
|
83
|
+
if manager is None:
|
|
84
|
+
return
|
|
85
|
+
if record.enabled:
|
|
86
|
+
manager.register(record)
|
|
87
|
+
else:
|
|
88
|
+
manager.unregister(record.name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _sync_project_webhook(
|
|
92
|
+
registrar: TelegramWebhookRegistrar | None,
|
|
93
|
+
record: ProjectRecord,
|
|
94
|
+
) -> None:
|
|
95
|
+
if registrar is None or not record.enabled:
|
|
96
|
+
return
|
|
97
|
+
if not registrar.sync_project(record):
|
|
98
|
+
_adminlog.warning("project webhook sync failed name=%s", record.name, project=record.name)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def create_admin_router(
|
|
102
|
+
settings: Settings,
|
|
103
|
+
registry: ProjectRegistry,
|
|
104
|
+
advanced_settings_store: FileAdvancedSettingsStore,
|
|
105
|
+
log_buffer: InMemoryLogBuffer,
|
|
106
|
+
conversation_store: SQLiteConversationStore,
|
|
107
|
+
bot_instance_manager: BotInstanceManager | None = None,
|
|
108
|
+
webhook_registrar: TelegramWebhookRegistrar | None = None,
|
|
109
|
+
bot_commands_builder: Callable[[UiLanguage], list[dict[str, str]]] | None = None,
|
|
110
|
+
) -> APIRouter:
|
|
111
|
+
router = APIRouter(tags=["admin"])
|
|
112
|
+
|
|
113
|
+
def _ui_language() -> UiLanguage:
|
|
114
|
+
return advanced_settings_store.get().ui_language
|
|
115
|
+
|
|
116
|
+
@router.get("/", response_class=HTMLResponse)
|
|
117
|
+
def admin_hub(_: LocalhostOnly) -> str:
|
|
118
|
+
_adminlog.info("page served path=/")
|
|
119
|
+
return _render_admin_page("admin.html", _ui_language())
|
|
120
|
+
|
|
121
|
+
@router.get("/projects", response_class=HTMLResponse)
|
|
122
|
+
def admin_projects(_: LocalhostOnly) -> str:
|
|
123
|
+
_adminlog.info("page served path=/projects")
|
|
124
|
+
return _render_admin_page("projects.html", _ui_language())
|
|
125
|
+
|
|
126
|
+
@router.get("/advanced", response_class=HTMLResponse)
|
|
127
|
+
def admin_advanced(_: LocalhostOnly) -> str:
|
|
128
|
+
_adminlog.info("page served path=/advanced")
|
|
129
|
+
return _render_admin_page("advanced.html", _ui_language())
|
|
130
|
+
|
|
131
|
+
@router.get("/logs", response_class=HTMLResponse)
|
|
132
|
+
def admin_logs(_: LocalhostOnly) -> str:
|
|
133
|
+
_adminlog.info("page served path=/logs")
|
|
134
|
+
return _render_admin_page("logs.html", _ui_language())
|
|
135
|
+
|
|
136
|
+
@router.get("/database", response_class=HTMLResponse)
|
|
137
|
+
def admin_database(_: LocalhostOnly) -> str:
|
|
138
|
+
_adminlog.info("page served path=/database")
|
|
139
|
+
return _render_admin_page("database.html", _ui_language())
|
|
140
|
+
|
|
141
|
+
@router.get("/api/database/tables")
|
|
142
|
+
def api_database_tables(_: LocalhostOnly) -> dict[str, object]:
|
|
143
|
+
browser = ConversationDatabaseBrowser(conversation_store.db_path)
|
|
144
|
+
payload = browser.tables_payload()
|
|
145
|
+
_monitorlog.info("database tables queried count=%d", len(payload.get("tables", [])))
|
|
146
|
+
return payload
|
|
147
|
+
|
|
148
|
+
@router.get("/api/database/filter-options")
|
|
149
|
+
def api_database_filter_options(
|
|
150
|
+
_: LocalhostOnly,
|
|
151
|
+
table: str = Query(..., min_length=1, max_length=64),
|
|
152
|
+
) -> dict[str, object]:
|
|
153
|
+
browser = ConversationDatabaseBrowser(conversation_store.db_path)
|
|
154
|
+
try:
|
|
155
|
+
payload = browser.distinct_filter_options(table)
|
|
156
|
+
_monitorlog.info("database filter options queried table=%s", table)
|
|
157
|
+
return payload
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
_monitorlog.warning("database filter options failed table=%s err=%s", table, exc)
|
|
160
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
161
|
+
|
|
162
|
+
@router.get("/api/database/rows")
|
|
163
|
+
def api_database_rows(
|
|
164
|
+
_: LocalhostOnly,
|
|
165
|
+
table: str = Query(..., min_length=1, max_length=64),
|
|
166
|
+
project: str | None = Query(None, max_length=200),
|
|
167
|
+
chat_id: int | None = Query(None),
|
|
168
|
+
role: str | None = Query(None, max_length=64),
|
|
169
|
+
job_id: str | None = Query(None, max_length=200),
|
|
170
|
+
q: str | None = Query(None, max_length=500),
|
|
171
|
+
sort: str | None = Query(None, max_length=64),
|
|
172
|
+
order: str = Query("desc"),
|
|
173
|
+
limit: int = Query(50, ge=1, le=200),
|
|
174
|
+
offset: int = Query(0, ge=0),
|
|
175
|
+
) -> dict[str, object]:
|
|
176
|
+
if order not in ("asc", "desc"):
|
|
177
|
+
raise HTTPException(status_code=422, detail="order must be asc or desc.")
|
|
178
|
+
browser = ConversationDatabaseBrowser(conversation_store.db_path)
|
|
179
|
+
try:
|
|
180
|
+
payload = browser.query_rows(
|
|
181
|
+
table,
|
|
182
|
+
project=project,
|
|
183
|
+
chat_id=chat_id,
|
|
184
|
+
role=role,
|
|
185
|
+
job_id=job_id,
|
|
186
|
+
q=q,
|
|
187
|
+
sort=sort,
|
|
188
|
+
order=order,
|
|
189
|
+
limit=limit,
|
|
190
|
+
offset=offset,
|
|
191
|
+
)
|
|
192
|
+
_monitorlog.info(
|
|
193
|
+
"database rows queried table=%s rows=%d limit=%d offset=%d filters=%d",
|
|
194
|
+
table,
|
|
195
|
+
len(payload.get("rows", [])),
|
|
196
|
+
limit,
|
|
197
|
+
offset,
|
|
198
|
+
sum(v is not None for v in (project, chat_id, role, job_id, q)),
|
|
199
|
+
chat_id=chat_id,
|
|
200
|
+
job_id=job_id,
|
|
201
|
+
project=project,
|
|
202
|
+
)
|
|
203
|
+
return payload
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
_monitorlog.warning("database rows query failed table=%s err=%s", table, exc)
|
|
206
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
207
|
+
|
|
208
|
+
@router.get("/api/database/export.csv")
|
|
209
|
+
def api_database_export_csv(
|
|
210
|
+
_: LocalhostOnly,
|
|
211
|
+
table: str = Query(..., min_length=1, max_length=64),
|
|
212
|
+
project: str | None = Query(None, max_length=200),
|
|
213
|
+
chat_id: int | None = Query(None),
|
|
214
|
+
role: str | None = Query(None, max_length=64),
|
|
215
|
+
job_id: str | None = Query(None, max_length=200),
|
|
216
|
+
q: str | None = Query(None, max_length=500),
|
|
217
|
+
sort: str | None = Query(None, max_length=64),
|
|
218
|
+
order: str = Query("desc"),
|
|
219
|
+
max_rows: int = Query(50_000, ge=1, le=100_000),
|
|
220
|
+
) -> StreamingResponse:
|
|
221
|
+
if order not in ("asc", "desc"):
|
|
222
|
+
raise HTTPException(status_code=422, detail="order must be asc or desc.")
|
|
223
|
+
browser = ConversationDatabaseBrowser(conversation_store.db_path)
|
|
224
|
+
try:
|
|
225
|
+
stream = browser.iter_csv_rows(
|
|
226
|
+
table,
|
|
227
|
+
project=project,
|
|
228
|
+
chat_id=chat_id,
|
|
229
|
+
role=role,
|
|
230
|
+
job_id=job_id,
|
|
231
|
+
q=q,
|
|
232
|
+
sort=sort,
|
|
233
|
+
order=order,
|
|
234
|
+
max_rows=max_rows,
|
|
235
|
+
)
|
|
236
|
+
except ValueError as exc:
|
|
237
|
+
_monitorlog.warning("database export failed table=%s err=%s", table, exc)
|
|
238
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
239
|
+
safe_name = table.replace("/", "_").replace("\\", "_")[:80] or "export"
|
|
240
|
+
_monitorlog.info(
|
|
241
|
+
"database export started table=%s max_rows=%d filters=%d",
|
|
242
|
+
table,
|
|
243
|
+
max_rows,
|
|
244
|
+
sum(v is not None for v in (project, chat_id, role, job_id, q)),
|
|
245
|
+
chat_id=chat_id,
|
|
246
|
+
job_id=job_id,
|
|
247
|
+
project=project,
|
|
248
|
+
)
|
|
249
|
+
return StreamingResponse(
|
|
250
|
+
stream,
|
|
251
|
+
media_type="text/csv; charset=utf-8",
|
|
252
|
+
headers={
|
|
253
|
+
"Content-Disposition": f'attachment; filename="{safe_name}_export.csv"',
|
|
254
|
+
},
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@router.get("/api/logs")
|
|
258
|
+
def api_logs(
|
|
259
|
+
_: LocalhostOnly,
|
|
260
|
+
limit: int = Query(200, ge=1, le=1000),
|
|
261
|
+
after_id: int | None = Query(None, ge=1),
|
|
262
|
+
level: str | None = Query(None, description="Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"),
|
|
263
|
+
q: str | None = Query(None, max_length=500),
|
|
264
|
+
logger: str | None = Query(None, max_length=200),
|
|
265
|
+
chat_id: int | None = Query(None),
|
|
266
|
+
user_id: int | None = Query(None),
|
|
267
|
+
job_id: str | None = Query(None, max_length=200),
|
|
268
|
+
project: str | None = Query(None, max_length=200),
|
|
269
|
+
category: str | None = Query(None, max_length=64),
|
|
270
|
+
) -> dict[str, object]:
|
|
271
|
+
q_clean = q.strip() if q else None
|
|
272
|
+
if q_clean == "":
|
|
273
|
+
q_clean = None
|
|
274
|
+
logger_clean = logger.strip() if logger else None
|
|
275
|
+
if logger_clean == "":
|
|
276
|
+
logger_clean = None
|
|
277
|
+
level_clean = level.strip() if level else None
|
|
278
|
+
if level_clean == "":
|
|
279
|
+
level_clean = None
|
|
280
|
+
job_id_clean = job_id.strip() if job_id else None
|
|
281
|
+
if job_id_clean == "":
|
|
282
|
+
job_id_clean = None
|
|
283
|
+
project_clean = project.strip() if project else None
|
|
284
|
+
if project_clean == "":
|
|
285
|
+
project_clean = None
|
|
286
|
+
category_clean = category.strip() if category else None
|
|
287
|
+
if category_clean == "":
|
|
288
|
+
category_clean = None
|
|
289
|
+
try:
|
|
290
|
+
entries, max_seen = log_buffer.query(
|
|
291
|
+
limit=limit,
|
|
292
|
+
after_id=after_id,
|
|
293
|
+
min_level=level_clean,
|
|
294
|
+
q=q_clean,
|
|
295
|
+
logger_sub=logger_clean,
|
|
296
|
+
chat_id=chat_id,
|
|
297
|
+
user_id=user_id,
|
|
298
|
+
job_id=job_id_clean,
|
|
299
|
+
project=project_clean,
|
|
300
|
+
category=category_clean,
|
|
301
|
+
)
|
|
302
|
+
except ValueError as exc:
|
|
303
|
+
_monitorlog.warning("logs query failed err=%s", exc)
|
|
304
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
305
|
+
_monitorlog.info(
|
|
306
|
+
"logs queried entries=%d max_id=%d after_id=%s limit=%d level=%s logger=%s category=%s",
|
|
307
|
+
len(entries),
|
|
308
|
+
max_seen,
|
|
309
|
+
after_id or "-",
|
|
310
|
+
limit,
|
|
311
|
+
level_clean or "-",
|
|
312
|
+
logger_clean or "-",
|
|
313
|
+
category_clean or "-",
|
|
314
|
+
chat_id=chat_id,
|
|
315
|
+
user_id=user_id,
|
|
316
|
+
job_id=job_id_clean,
|
|
317
|
+
project=project_clean,
|
|
318
|
+
)
|
|
319
|
+
return {
|
|
320
|
+
"entries": entries,
|
|
321
|
+
"max_id": max_seen,
|
|
322
|
+
"max_entries": log_buffer.max_entries,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@router.get("/api/settings")
|
|
326
|
+
def api_settings(_: LocalhostOnly) -> dict:
|
|
327
|
+
token = (
|
|
328
|
+
settings.telegram_bot_token.get_secret_value()
|
|
329
|
+
if settings.telegram_bot_token is not None
|
|
330
|
+
else ""
|
|
331
|
+
)
|
|
332
|
+
_adminlog.info(
|
|
333
|
+
"settings queried env_allowlist_chats=%d env_allowlist_users=%d webhook_secret_set=%s default_model=%s",
|
|
334
|
+
len(settings.telegram_allowed_chat_ids),
|
|
335
|
+
len(settings.telegram_allowed_user_ids),
|
|
336
|
+
bool(settings.telegram_webhook_secret),
|
|
337
|
+
settings.default_model.value,
|
|
338
|
+
)
|
|
339
|
+
return {
|
|
340
|
+
"telegram_bot_token_masked": mask_bot_token(token),
|
|
341
|
+
"telegram_allowed_chat_ids": settings.telegram_allowed_chat_ids,
|
|
342
|
+
"telegram_allowed_user_ids": settings.telegram_allowed_user_ids,
|
|
343
|
+
"telegram_webhook_secret_set": bool(settings.telegram_webhook_secret),
|
|
344
|
+
"default_model_env": settings.default_model.value,
|
|
345
|
+
"job_timeout_seconds_env": settings.job_timeout_seconds,
|
|
346
|
+
"projects_config_path": str(registry.config_path),
|
|
347
|
+
"webhook_token_hash_prefix_length": WEBHOOK_TOKEN_HASH_PREFIX_LENGTH,
|
|
348
|
+
"webhook_route_template": "/telegram/webhook/{token_hash_prefix}",
|
|
349
|
+
"webhook_public_url_rule": "<public HTTPS Base URL> + each project's webhook_path",
|
|
350
|
+
"webhook_hint": "Each project (bot) has its own webhook_path and token_hash_prefix. "
|
|
351
|
+
"The full URL is the public Base joined with webhook_path. While remote-coder up is running, "
|
|
352
|
+
"registration/edits refresh it automatically. "
|
|
353
|
+
"Manual registration: python scripts/set_webhook.py <Base URL>",
|
|
354
|
+
"webhook_deleted_disabled_note": (
|
|
355
|
+
"When a project is disabled or deleted, the server no longer handles updates arriving "
|
|
356
|
+
"with that token_hash_prefix (match failure / 404). Even if Telegram calls the old URL, "
|
|
357
|
+
"this app ignores it. To clear the bot's webhook or point it at a new URL, run Bot API "
|
|
358
|
+
"deleteWebhook or rerun scripts/set_webhook.py with the updated registry."
|
|
359
|
+
),
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
@router.get("/api/projects")
|
|
363
|
+
def api_projects_get(_: LocalhostOnly) -> JSONResponse:
|
|
364
|
+
_adminlog.info("projects queried count=%d", len(registry.list_projects()))
|
|
365
|
+
return JSONResponse(registry.to_public_dict())
|
|
366
|
+
|
|
367
|
+
@router.post("/api/projects")
|
|
368
|
+
def api_projects_create(body: ProjectUpsertBody, _: LocalhostOnly) -> JSONResponse:
|
|
369
|
+
if not body.bot_token or not body.bot_token.strip():
|
|
370
|
+
raise HTTPException(status_code=400, detail="bot_token is required")
|
|
371
|
+
if not body.allowed_chat_ids:
|
|
372
|
+
raise HTTPException(status_code=400, detail="allowed_chat_ids must have at least one entry")
|
|
373
|
+
wh_stripped = (body.webhook_secret or "").strip()
|
|
374
|
+
webhook_secret = SecretStr(
|
|
375
|
+
wh_stripped if wh_stripped else _DEFAULT_NEW_PROJECT_WEBHOOK_SECRET
|
|
376
|
+
)
|
|
377
|
+
record = ProjectRecord(
|
|
378
|
+
name=body.name,
|
|
379
|
+
root_path=body.root_path,
|
|
380
|
+
worktree_base_dir=body.worktree_base_dir,
|
|
381
|
+
default_model=body.default_model,
|
|
382
|
+
enabled=body.enabled,
|
|
383
|
+
bot_token=SecretStr(body.bot_token.strip()),
|
|
384
|
+
webhook_secret=webhook_secret,
|
|
385
|
+
allowed_chat_ids=list(body.allowed_chat_ids),
|
|
386
|
+
allowed_user_ids=list(body.allowed_user_ids or []),
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
registry.add_project(record)
|
|
390
|
+
except ValueError as exc:
|
|
391
|
+
_adminlog.warning("project create failed name=%s err=%s", body.name, exc, project=body.name)
|
|
392
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
393
|
+
_adminlog.info("project created name=%s enabled=%s", body.name, body.enabled, project=body.name)
|
|
394
|
+
_sync_bot_instance(bot_instance_manager, record)
|
|
395
|
+
_sync_project_webhook(webhook_registrar, record)
|
|
396
|
+
return JSONResponse(registry.to_public_dict())
|
|
397
|
+
|
|
398
|
+
@router.put("/api/projects/{name}")
|
|
399
|
+
def api_projects_update(name: str, body: ProjectUpsertBody, _: LocalhostOnly) -> JSONResponse:
|
|
400
|
+
existing = registry.get(name)
|
|
401
|
+
if existing is None:
|
|
402
|
+
raise HTTPException(status_code=404, detail=f"unknown project: {name}")
|
|
403
|
+
bot_token = (
|
|
404
|
+
SecretStr(body.bot_token.strip())
|
|
405
|
+
if body.bot_token and body.bot_token.strip()
|
|
406
|
+
else existing.bot_token
|
|
407
|
+
)
|
|
408
|
+
if body.webhook_secret is not None:
|
|
409
|
+
webhook_secret = SecretStr(body.webhook_secret) if body.webhook_secret.strip() else None
|
|
410
|
+
else:
|
|
411
|
+
webhook_secret = existing.webhook_secret
|
|
412
|
+
allowed_chat_ids = (
|
|
413
|
+
list(body.allowed_chat_ids)
|
|
414
|
+
if body.allowed_chat_ids is not None
|
|
415
|
+
else existing.allowed_chat_ids
|
|
416
|
+
)
|
|
417
|
+
if not allowed_chat_ids:
|
|
418
|
+
raise HTTPException(status_code=400, detail="allowed_chat_ids must have at least one entry")
|
|
419
|
+
allowed_user_ids = (
|
|
420
|
+
list(body.allowed_user_ids)
|
|
421
|
+
if body.allowed_user_ids is not None
|
|
422
|
+
else existing.allowed_user_ids
|
|
423
|
+
)
|
|
424
|
+
record = ProjectRecord(
|
|
425
|
+
name=body.name,
|
|
426
|
+
root_path=body.root_path,
|
|
427
|
+
worktree_base_dir=body.worktree_base_dir,
|
|
428
|
+
default_model=body.default_model,
|
|
429
|
+
enabled=body.enabled,
|
|
430
|
+
bot_token=bot_token,
|
|
431
|
+
webhook_secret=webhook_secret,
|
|
432
|
+
allowed_chat_ids=allowed_chat_ids,
|
|
433
|
+
allowed_user_ids=allowed_user_ids,
|
|
434
|
+
)
|
|
435
|
+
try:
|
|
436
|
+
registry.update_project(name, record)
|
|
437
|
+
except ValueError as exc:
|
|
438
|
+
_adminlog.warning(
|
|
439
|
+
"project update failed old_name=%s new_name=%s err=%s",
|
|
440
|
+
name,
|
|
441
|
+
body.name,
|
|
442
|
+
exc,
|
|
443
|
+
project=name,
|
|
444
|
+
)
|
|
445
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
446
|
+
_adminlog.info(
|
|
447
|
+
"project updated old_name=%s new_name=%s enabled=%s",
|
|
448
|
+
name,
|
|
449
|
+
body.name,
|
|
450
|
+
body.enabled,
|
|
451
|
+
project=body.name,
|
|
452
|
+
)
|
|
453
|
+
_sync_bot_instance(bot_instance_manager, record)
|
|
454
|
+
_sync_project_webhook(webhook_registrar, record)
|
|
455
|
+
return JSONResponse(registry.to_public_dict())
|
|
456
|
+
|
|
457
|
+
@router.delete("/api/projects/{name}")
|
|
458
|
+
def api_projects_delete(name: str, _: LocalhostOnly) -> JSONResponse:
|
|
459
|
+
try:
|
|
460
|
+
registry.remove_project(name)
|
|
461
|
+
except ValueError as exc:
|
|
462
|
+
_adminlog.warning("project delete failed name=%s err=%s", name, exc, project=name)
|
|
463
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
464
|
+
_adminlog.info("project deleted name=%s", name, project=name)
|
|
465
|
+
if bot_instance_manager is not None:
|
|
466
|
+
bot_instance_manager.unregister(name)
|
|
467
|
+
return JSONResponse(registry.to_public_dict())
|
|
468
|
+
|
|
469
|
+
@router.post("/api/projects/default")
|
|
470
|
+
def api_projects_set_default(body: DefaultProjectBody, _: LocalhostOnly) -> JSONResponse:
|
|
471
|
+
try:
|
|
472
|
+
registry.set_default_project(body.name)
|
|
473
|
+
except ValueError as exc:
|
|
474
|
+
_adminlog.warning("default project update failed name=%s err=%s", body.name, exc, project=body.name)
|
|
475
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
476
|
+
_adminlog.info("default project updated name=%s", body.name, project=body.name)
|
|
477
|
+
return JSONResponse(registry.to_public_dict())
|
|
478
|
+
|
|
479
|
+
@router.get("/api/advanced-settings")
|
|
480
|
+
def api_advanced_settings_get(_: LocalhostOnly) -> dict:
|
|
481
|
+
_adminlog.info("advanced settings queried")
|
|
482
|
+
return advanced_settings_store.get().model_dump(mode="json")
|
|
483
|
+
|
|
484
|
+
@router.put("/api/advanced-settings")
|
|
485
|
+
def api_advanced_settings_put(body: AdvancedSettings, _: LocalhostOnly) -> dict:
|
|
486
|
+
saved = advanced_settings_store.save(body)
|
|
487
|
+
if webhook_registrar is not None and bot_commands_builder is not None:
|
|
488
|
+
webhook_registrar.set_bot_commands(bot_commands_builder(saved.ui_language))
|
|
489
|
+
for project in registry.list_projects():
|
|
490
|
+
webhook_registrar.sync_project_commands(project)
|
|
491
|
+
_adminlog.info(
|
|
492
|
+
"advanced settings updated ui_language=%s lifecycle_notify=%s pull_startup=%s auto_merge=%s delete_rebased_branch=%s natural_confirm_buttons=%s status_limit=%d job_timeout=%s memory_limit=%s",
|
|
493
|
+
saved.ui_language.value,
|
|
494
|
+
saved.server_lifecycle_notify_enabled,
|
|
495
|
+
saved.pull_projects_on_server_startup_enabled,
|
|
496
|
+
saved.auto_merge_to_main_enabled,
|
|
497
|
+
saved.delete_rebased_branch_enabled,
|
|
498
|
+
saved.natural_job_confirmation_buttons_enabled,
|
|
499
|
+
saved.status_recent_job_limit,
|
|
500
|
+
saved.job_timeout_seconds or "-",
|
|
501
|
+
saved.conversation_memory_limit_enabled,
|
|
502
|
+
)
|
|
503
|
+
return saved.model_dump(mode="json")
|
|
504
|
+
|
|
505
|
+
@router.get("/admin-static/i18n.js")
|
|
506
|
+
def admin_i18n_js(_: LocalhostOnly) -> FileResponse:
|
|
507
|
+
path = Path(__file__).parent / "static" / "i18n.js"
|
|
508
|
+
if not path.is_file():
|
|
509
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
510
|
+
return FileResponse(path, media_type="application/javascript; charset=utf-8")
|
|
511
|
+
|
|
512
|
+
@router.get("/admin-static/summary.js")
|
|
513
|
+
def admin_summary_js(_: LocalhostOnly) -> FileResponse:
|
|
514
|
+
path = Path(__file__).parent / "static" / "summary.js"
|
|
515
|
+
if not path.is_file():
|
|
516
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
517
|
+
return FileResponse(path, media_type="application/javascript; charset=utf-8")
|
|
518
|
+
|
|
519
|
+
@router.get("/admin-static/icons/{filename}")
|
|
520
|
+
def admin_icon(filename: str, _: LocalhostOnly) -> FileResponse:
|
|
521
|
+
if filename not in _ADMIN_ICON_NAMES:
|
|
522
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
523
|
+
path = Path(__file__).parent / "static" / "icons" / filename
|
|
524
|
+
if not path.is_file():
|
|
525
|
+
raise HTTPException(status_code=404, detail="not found")
|
|
526
|
+
return FileResponse(path, media_type="image/svg+xml")
|
|
527
|
+
|
|
528
|
+
return router
|