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,988 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import deque
5
+ from dataclasses import dataclass, replace
6
+ from functools import partial
7
+ from threading import Lock
8
+
9
+ from fastapi import APIRouter, BackgroundTasks, Header, HTTPException
10
+ from pydantic import BaseModel, Field
11
+
12
+ from app.ai.model_catalog import format_model_selection
13
+ from app.ai.usage import format_token_usage
14
+ from app.jobs.manager import JobManager
15
+ from app.jobs.schemas import FixKind, Job, JobMode, JobRequest
16
+ from app.jobs.store import JobStore
17
+ from app.monitoring.events import EventLogger
18
+ from app.security.auth import AllowlistAuthService
19
+ from app.telegram.commands import (
20
+ CommandContext,
21
+ CommandRegistry,
22
+ CommandResponse,
23
+ FIX_SOURCE_AWAIT_ACTION,
24
+ FIX_SOURCE_PENDING_ACTION,
25
+ InlineButton,
26
+ TelegramMessage,
27
+ effective_project_name_for_chat,
28
+ )
29
+ from app.projects.registry import normalize_webhook_token_hash_path_segment
30
+ from app.telegram.bot_instances import BotInstanceManager
31
+ from app.telegram.confirmations import PendingConfirmation
32
+ from app.telegram.conversation import SQLiteConversationStore
33
+ from app.telegram.notifier import Notifier
34
+ from app.telegram.parser import CommandParseError, CommandParser
35
+
36
+ _inbound = EventLogger("app.telegram.inbound", "telegram.inbound")
37
+ _cmdlog = EventLogger("app.telegram.command", "telegram.command")
38
+ _authlog = EventLogger("app.security.auth", "auth.reject")
39
+
40
+
41
+ def _telegram_text_preview(text: str, max_len: int = 80) -> str:
42
+ stripped = text.strip()
43
+ if not stripped:
44
+ return ""
45
+ first = stripped.splitlines()[0]
46
+ return first[:max_len]
47
+
48
+
49
+ _JOB_RESULT_MEMORY_READ_ONLY_STDOUT_PREVIEW = 800
50
+
51
+
52
+ def format_job_result_memory_summary(final_job: Job) -> str:
53
+ summary = f"status={final_job.status.value}"
54
+ if final_job.error_stage:
55
+ summary += f" stage={final_job.error_stage}"
56
+ if final_job.error:
57
+ summary += f" err={str(final_job.error)[:300]}"
58
+ requested_model = format_model_selection(final_job.request.model, final_job.request.model_id)
59
+ summary += f" model={final_job.runner_actual_model or requested_model}"
60
+ token_usage = format_token_usage(final_job.runner_token_usage)
61
+ if token_usage:
62
+ summary += f" tokens={token_usage}"
63
+ if final_job.request.mode in (JobMode.PLAN, JobMode.ASK) and final_job.runner_stdout_summary:
64
+ preview = final_job.runner_stdout_summary[:_JOB_RESULT_MEMORY_READ_ONLY_STDOUT_PREVIEW]
65
+ summary += f" stdout_preview={preview}"
66
+ return summary
67
+
68
+
69
+ _NATURAL_JOB_CONFIRMATION = "__natural_job__"
70
+ _NATURAL_JOB_CONFIRM_YES = "__natural_job__:yes"
71
+ _NATURAL_JOB_CONFIRM_NO = "__natural_job__:no"
72
+ _NATURAL_JOB_MODE_INPUT = "__natural_job_mode_input__"
73
+
74
+
75
+ class _RecentUpdateTracker:
76
+ def __init__(self, max_size: int = 1024) -> None:
77
+ self._max_size = max_size
78
+ self._seen: set[tuple[str, int]] = set()
79
+ self._order: deque[tuple[str, int]] = deque()
80
+ self._lock = Lock()
81
+
82
+ def mark_seen(self, route_key: str, update_id: int) -> bool:
83
+ key = (route_key, update_id)
84
+ with self._lock:
85
+ if key in self._seen:
86
+ return True
87
+ self._seen.add(key)
88
+ self._order.append(key)
89
+ while len(self._order) > self._max_size:
90
+ old = self._order.popleft()
91
+ self._seen.discard(old)
92
+ return False
93
+
94
+
95
+ def _format_natural_job_confirmation(
96
+ request: JobRequest,
97
+ current_branch: str,
98
+ *,
99
+ use_buttons: bool = False,
100
+ ) -> str:
101
+ lines = [
102
+ "Confirm the work to run.",
103
+ "",
104
+ f"- Project: {request.project}",
105
+ f"- Work branch: {current_branch}",
106
+ f"- Model: {format_model_selection(request.model, request.model_id)}",
107
+ ]
108
+ if request.mode is JobMode.PLAN:
109
+ lines.append("- Mode: plan (read-only, no commit/push)")
110
+ elif request.mode is JobMode.ASK:
111
+ lines.append("- Mode: ask (read-only, no commit/push)")
112
+ else:
113
+ lines.append("- Mode: agent (may edit code, commit, and push)")
114
+ if request.branch:
115
+ lines.append(f"- Requested branch: {request.branch}")
116
+ if use_buttons:
117
+ footer = "Choose whether to run it."
118
+ else:
119
+ footer = (
120
+ "Send `y` or `Y` to run it. "
121
+ "A new natural-language request can replace this confirmation. "
122
+ "Unparsed input cancels the pending work."
123
+ )
124
+ lines.extend(["", footer])
125
+ return "\n".join(lines)
126
+
127
+
128
+ _FIX_REPLY_PREFIX_RE = re.compile(r"^(?:fix|수정)\s*[::]\s*", re.IGNORECASE)
129
+
130
+
131
+ def _match_fix_reply_prefix(text: str) -> str | None:
132
+ stripped = text.lstrip()
133
+ match = _FIX_REPLY_PREFIX_RE.match(stripped)
134
+ if match is None:
135
+ return None
136
+ return stripped[match.end() :]
137
+
138
+
139
+ def _format_fix_source_confirmation(
140
+ request: JobRequest,
141
+ target_job: Job,
142
+ *,
143
+ use_buttons: bool,
144
+ ) -> str:
145
+ lines = [
146
+ "Confirm the fix job.",
147
+ "",
148
+ f"- Project: {request.project}",
149
+ f"- Target Job: {target_job.id}",
150
+ f"- Branch: {target_job.branch}",
151
+ f"- Original commit: {target_job.commit_hash}",
152
+ f"- Model: {format_model_selection(request.model, request.model_id)}",
153
+ "- Mode: agent_fix (source) - amends the existing commit and pushes with --force-with-lease",
154
+ ]
155
+ if use_buttons:
156
+ lines.extend(["", "Choose whether to run it."])
157
+ else:
158
+ lines.extend(
159
+ [
160
+ "",
161
+ "Send `y` or `Y` to run it. Any other response cancels it.",
162
+ ]
163
+ )
164
+ return "\n".join(lines)
165
+
166
+
167
+ def _natural_job_confirmation_buttons() -> list[list[InlineButton]]:
168
+ return [[InlineButton("Yes", _NATURAL_JOB_CONFIRM_YES), InlineButton("No", _NATURAL_JOB_CONFIRM_NO)]]
169
+
170
+
171
+ def _natural_job_confirmation_buttons_enabled(command_context: CommandContext) -> bool:
172
+ if command_context.advanced_settings_store is None:
173
+ return False
174
+ return command_context.advanced_settings_store.get().natural_job_confirmation_buttons_enabled
175
+
176
+
177
+ def _format_natural_job_cancelled(request: JobRequest | None) -> str:
178
+ if request is None:
179
+ return "Cancelled the work request."
180
+ return (
181
+ "Cancelled the work request. "
182
+ f"(project: {request.project}, model: {format_model_selection(request.model, request.model_id)})"
183
+ )
184
+
185
+
186
+ def _format_mode_input_prompt(mode: JobMode) -> str:
187
+ if mode is JobMode.PLAN:
188
+ return (
189
+ "Send the instruction to run in plan mode.\n\n"
190
+ "Example: Plan a login fix\n"
191
+ "Example: model: codex List only API boundary risks"
192
+ )
193
+ if mode is JobMode.ASK:
194
+ return (
195
+ "Send the question to run in ask mode.\n\n"
196
+ "Example: Explain the JobManager flow\n"
197
+ "Example: model: codex How do I run pytest?"
198
+ )
199
+ raise AssertionError(mode)
200
+
201
+
202
+ class TelegramChat(BaseModel):
203
+ id: int
204
+
205
+
206
+ class TelegramUser(BaseModel):
207
+ id: int
208
+
209
+
210
+ class TelegramReplyMessage(BaseModel):
211
+ message_id: int
212
+ text: str | None = None
213
+
214
+
215
+ class TelegramIncomingMessage(BaseModel):
216
+ message_id: int | None = None
217
+ text: str | None = None
218
+ chat: TelegramChat
219
+ from_user: TelegramUser | None = Field(default=None, alias="from")
220
+ reply_to_message: TelegramReplyMessage | None = None
221
+
222
+ model_config = {"populate_by_name": True}
223
+
224
+
225
+ class TelegramCallbackQueryFrom(BaseModel):
226
+ id: int
227
+
228
+
229
+ class TelegramCallbackQueryMessage(BaseModel):
230
+ chat: TelegramChat
231
+
232
+
233
+ class TelegramCallbackQuery(BaseModel):
234
+ id: str
235
+ from_user: TelegramCallbackQueryFrom = Field(alias="from")
236
+ message: TelegramCallbackQueryMessage | None = None
237
+ data: str | None = None
238
+
239
+ model_config = {"populate_by_name": True}
240
+
241
+
242
+ class TelegramUpdate(BaseModel):
243
+ update_id: int
244
+ message: TelegramIncomingMessage | None = None
245
+ callback_query: TelegramCallbackQuery | None = None
246
+
247
+
248
+ @dataclass
249
+ class _Req:
250
+ update: TelegramUpdate
251
+ background_tasks: BackgroundTasks
252
+ notifier: Notifier
253
+ command_context: CommandContext
254
+ scope_project: str | None
255
+ chat_id: int
256
+ user_id: int | None
257
+ message: TelegramMessage
258
+ message_head_lower: str
259
+ reply_mid: int | None
260
+ reply_txt: str | None
261
+
262
+
263
+ def create_webhook_router(
264
+ bot_instance_manager: BotInstanceManager,
265
+ parser: CommandParser,
266
+ command_registry: CommandRegistry,
267
+ job_manager: JobManager,
268
+ job_store: JobStore,
269
+ conversation_store: SQLiteConversationStore | None = None,
270
+ ) -> APIRouter:
271
+ router = APIRouter(prefix="/telegram", tags=["telegram"])
272
+ recent_updates = _RecentUpdateTracker()
273
+
274
+ def _handle_callback_query(
275
+ update: TelegramUpdate,
276
+ cq: TelegramCallbackQuery,
277
+ notifier: Notifier,
278
+ auth_service: AllowlistAuthService,
279
+ command_context: CommandContext,
280
+ scope_project: str | None,
281
+ background_tasks: BackgroundTasks,
282
+ ) -> dict[str, str]:
283
+ if cq.message is None or not cq.data:
284
+ _inbound.info(
285
+ "callback_query skipped missing message/data update_id=%s has_message=%s has_data=%s",
286
+ update.update_id,
287
+ cq.message is not None,
288
+ bool(cq.data),
289
+ )
290
+ background_tasks.add_task(notifier.answer_callback_query, cq.id)
291
+ return {"status": "ignored"}
292
+ cq_chat_id = cq.message.chat.id
293
+ cq_user_id = cq.from_user.id
294
+ cq_preview = _telegram_text_preview(cq.data)
295
+ _inbound.info(
296
+ "callback_query received update_id=%s data=%s",
297
+ update.update_id,
298
+ cq_preview or "(empty)",
299
+ chat_id=cq_chat_id,
300
+ user_id=cq_user_id,
301
+ )
302
+ if not auth_service.is_allowed(chat_id=cq_chat_id, user_id=cq_user_id):
303
+ _authlog.warning(
304
+ "unauthorized callback_query update_id=%s",
305
+ update.update_id,
306
+ chat_id=cq_chat_id,
307
+ user_id=cq_user_id,
308
+ )
309
+ background_tasks.add_task(notifier.answer_callback_query, cq.id)
310
+ return {"status": "ignored"}
311
+ notifier.answer_callback_query(cq.id)
312
+ if cq.data in {_NATURAL_JOB_CONFIRM_YES, _NATURAL_JOB_CONFIRM_NO}:
313
+ pending = command_context.confirmation_store.get(scope_project, cq_chat_id)
314
+ if pending is None or pending.command_name != _NATURAL_JOB_CONFIRMATION:
315
+ background_tasks.add_task(notifier.send_text, cq_chat_id, "There is no pending confirmation.")
316
+ return {"status": "ignored"}
317
+ confirmed = command_context.confirmation_store.pop(scope_project, cq_chat_id)
318
+ if cq.data == _NATURAL_JOB_CONFIRM_NO:
319
+ background_tasks.add_task(
320
+ notifier.send_text,
321
+ cq_chat_id,
322
+ _format_natural_job_cancelled(confirmed.job_request if confirmed else None),
323
+ )
324
+ return {"status": "ok"}
325
+ if confirmed is None or confirmed.job_request is None or confirmed.original_text is None:
326
+ background_tasks.add_task(notifier.send_text, cq_chat_id, "Could not process the pending confirmation.")
327
+ return {"status": "ignored"}
328
+ job = _submit_confirmed_natural_request(
329
+ request=confirmed.job_request,
330
+ original_text=confirmed.original_text,
331
+ background_tasks=background_tasks,
332
+ )
333
+ return {"status": "accepted", "job_id": job.id}
334
+ cq_message = TelegramMessage(chat_id=cq_chat_id, user_id=cq_user_id, text=cq.data)
335
+ cq_response = command_registry.dispatch_rich(cq_message, command_context)
336
+ if cq_response:
337
+ button_rows = len(cq_response.inline_buttons or [])
338
+ _cmdlog.info(
339
+ "callback_query handled cmd=%s response_len=%d button_rows=%d",
340
+ cq_preview or "(empty)",
341
+ len(cq_response.text),
342
+ button_rows,
343
+ chat_id=cq_chat_id,
344
+ user_id=cq_user_id,
345
+ )
346
+ if cq_response.inline_buttons:
347
+ background_tasks.add_task(
348
+ partial(
349
+ notifier.send_with_buttons,
350
+ cq_chat_id,
351
+ cq_response.text,
352
+ cq_response.inline_buttons,
353
+ skip_body_i18n=cq_response.skip_notifier_body_i18n,
354
+ )
355
+ )
356
+ else:
357
+ background_tasks.add_task(
358
+ partial(
359
+ notifier.send_text,
360
+ cq_chat_id,
361
+ cq_response.text,
362
+ skip_body_i18n=cq_response.skip_notifier_body_i18n,
363
+ )
364
+ )
365
+ else:
366
+ _cmdlog.info(
367
+ "callback_query no command response cmd=%s",
368
+ cq_preview or "(empty)",
369
+ chat_id=cq_chat_id,
370
+ user_id=cq_user_id,
371
+ )
372
+ return {"status": "ok"}
373
+
374
+ def _queue_natural_confirmation(req: _Req, request: JobRequest, original_text_stripped: str) -> bool:
375
+ cc = req.command_context
376
+ ent = cc.project_registry.get(req.scope_project)
377
+ if ent is None:
378
+ req.background_tasks.add_task(
379
+ req.notifier.send_text,
380
+ req.chat_id,
381
+ f"Unknown project: {req.scope_project}",
382
+ )
383
+ return False
384
+ try:
385
+ current_branch = str(cc.git_service.get_current_branch(ent.root_path))
386
+ except RuntimeError as exc:
387
+ req.background_tasks.add_task(
388
+ req.notifier.send_text, req.chat_id, f"Could not resolve work branch: {exc}"
389
+ )
390
+ return False
391
+ cc.confirmation_store.set(
392
+ req.scope_project,
393
+ req.chat_id,
394
+ PendingConfirmation(
395
+ command_name=_NATURAL_JOB_CONFIRMATION,
396
+ action="submit",
397
+ job_request=request,
398
+ original_text=original_text_stripped,
399
+ ),
400
+ )
401
+ use_confirmation_buttons = _natural_job_confirmation_buttons_enabled(cc)
402
+ confirmation_text = _format_natural_job_confirmation(
403
+ request,
404
+ current_branch,
405
+ use_buttons=use_confirmation_buttons,
406
+ )
407
+ if use_confirmation_buttons:
408
+ req.background_tasks.add_task(
409
+ req.notifier.send_with_buttons,
410
+ req.chat_id,
411
+ confirmation_text,
412
+ _natural_job_confirmation_buttons(),
413
+ )
414
+ else:
415
+ req.background_tasks.add_task(req.notifier.send_text, req.chat_id, confirmation_text)
416
+ return True
417
+
418
+ def _handle_pending(req: _Req, pending: PendingConfirmation | None) -> dict[str, str] | None:
419
+ if pending is None:
420
+ return None
421
+ cc = req.command_context
422
+ scope_project = req.scope_project
423
+ chat_id = req.chat_id
424
+ notifier = req.notifier
425
+ bt = req.background_tasks
426
+
427
+ if pending.command_name == _NATURAL_JOB_CONFIRMATION and req.message_head_lower != "/init":
428
+ if req.message.text.strip() in {"y", "Y"}:
429
+ confirmed = cc.confirmation_store.pop(scope_project, chat_id)
430
+ if confirmed is None or confirmed.job_request is None or confirmed.original_text is None:
431
+ bt.add_task(notifier.send_text, chat_id, "Could not process the pending confirmation.")
432
+ return {"status": "ignored"}
433
+ job = _submit_confirmed_natural_request(
434
+ request=confirmed.job_request,
435
+ original_text=confirmed.original_text,
436
+ background_tasks=bt,
437
+ )
438
+ return {"status": "accepted", "job_id": job.id}
439
+ try:
440
+ parsed_request = parser.parse_natural(
441
+ req.message.text,
442
+ scope_project,
443
+ chat_id=chat_id,
444
+ user_id=req.user_id,
445
+ message_id=req.update.message.message_id,
446
+ reply_to_message_id=req.reply_mid,
447
+ reply_to_text=req.reply_txt,
448
+ )
449
+ except CommandParseError as exc:
450
+ cc.confirmation_store.pop(scope_project, chat_id)
451
+ _cmdlog.warning(
452
+ "parse error replacing pending message_id=%s err=%s",
453
+ req.update.message.message_id,
454
+ str(exc)[:120],
455
+ chat_id=chat_id,
456
+ user_id=req.user_id,
457
+ )
458
+ bt.add_task(notifier.send_text, chat_id, _format_natural_job_cancelled(pending.job_request))
459
+ bt.add_task(notifier.send_text, chat_id, str(exc))
460
+ return {"status": "ignored"}
461
+ cc.confirmation_store.pop(scope_project, chat_id)
462
+ _cmdlog.info(
463
+ "natural pending replaced mode=%s model=%s branch=%s commit=%s instruction_len=%d reply_to=%s",
464
+ parsed_request.mode.value,
465
+ parsed_request.model.value,
466
+ parsed_request.branch or "-",
467
+ parsed_request.commit,
468
+ len(parsed_request.instruction),
469
+ parsed_request.reply_to_message_id or "-",
470
+ chat_id=chat_id,
471
+ user_id=req.user_id,
472
+ project=parsed_request.project,
473
+ )
474
+ if _queue_natural_confirmation(req, parsed_request, req.message.text.strip()):
475
+ return {"status": "ok"}
476
+ return {"status": "ignored"}
477
+
478
+ if pending.command_name == _NATURAL_JOB_MODE_INPUT and req.message_head_lower != "/init":
479
+ cc.confirmation_store.pop(scope_project, chat_id)
480
+ mode_prefix = "/plan" if pending.action == JobMode.PLAN.value else "/ask"
481
+ try:
482
+ parsed_request = parser.parse_natural(
483
+ f"{mode_prefix} {req.message.text}",
484
+ scope_project,
485
+ chat_id=chat_id,
486
+ user_id=req.user_id,
487
+ message_id=req.update.message.message_id,
488
+ reply_to_message_id=req.reply_mid,
489
+ reply_to_text=req.reply_txt,
490
+ )
491
+ except CommandParseError as exc:
492
+ _cmdlog.warning(
493
+ "parse error for pending mode input message_id=%s mode=%s err=%s",
494
+ req.update.message.message_id,
495
+ pending.action,
496
+ str(exc)[:120],
497
+ chat_id=chat_id,
498
+ user_id=req.user_id,
499
+ )
500
+ bt.add_task(notifier.send_text, chat_id, str(exc))
501
+ return {"status": "ignored"}
502
+ _cmdlog.info(
503
+ "pending mode input parsed mode=%s model=%s instruction_len=%d reply_to=%s",
504
+ parsed_request.mode.value,
505
+ parsed_request.model.value,
506
+ len(parsed_request.instruction),
507
+ parsed_request.reply_to_message_id or "-",
508
+ chat_id=chat_id,
509
+ user_id=req.user_id,
510
+ project=parsed_request.project,
511
+ )
512
+ if _queue_natural_confirmation(req, parsed_request, req.message.text.strip()):
513
+ return {"status": "ok"}
514
+ return {"status": "ignored"}
515
+
516
+ if (
517
+ pending.command_name == "/fix"
518
+ and pending.action == FIX_SOURCE_AWAIT_ACTION
519
+ and req.message_head_lower != "/init"
520
+ and not req.message.text.strip().startswith("/")
521
+ ):
522
+ cc.confirmation_store.pop(scope_project, chat_id)
523
+ target_job = (
524
+ job_store.get(pending.target_job_id) if pending.target_job_id is not None else None
525
+ )
526
+ project_name = effective_project_name_for_chat(cc, chat_id)
527
+ if (
528
+ target_job is None
529
+ or project_name is None
530
+ or not job_manager.is_fix_candidate(target_job, project_name, chat_id)
531
+ ):
532
+ bt.add_task(notifier.send_text, chat_id, "Fix target job is no longer available.")
533
+ return {"status": "ignored"}
534
+ fix_request = JobRequest(
535
+ project=project_name,
536
+ model=target_job.request.model,
537
+ model_id=target_job.request.model_id,
538
+ instruction=req.message.text.strip(),
539
+ mode=JobMode.AGENT_FIX,
540
+ fix_kind=FixKind.SOURCE,
541
+ parent_job_id=target_job.id,
542
+ branch=target_job.branch,
543
+ chat_id=chat_id,
544
+ requested_by=req.user_id,
545
+ message_id=req.update.message.message_id,
546
+ reply_to_message_id=req.reply_mid,
547
+ )
548
+ cc.confirmation_store.set(
549
+ scope_project,
550
+ chat_id,
551
+ PendingConfirmation(
552
+ command_name="/fix",
553
+ action=FIX_SOURCE_PENDING_ACTION,
554
+ job_request=fix_request,
555
+ original_text=req.message.text.strip(),
556
+ target_job_id=target_job.id,
557
+ ),
558
+ )
559
+ use_buttons = _natural_job_confirmation_buttons_enabled(cc)
560
+ confirmation_text = _format_fix_source_confirmation(
561
+ fix_request, target_job, use_buttons=use_buttons
562
+ )
563
+ if use_buttons:
564
+ bt.add_task(
565
+ notifier.send_with_buttons,
566
+ chat_id,
567
+ confirmation_text,
568
+ _natural_job_confirmation_buttons(),
569
+ )
570
+ else:
571
+ bt.add_task(notifier.send_text, chat_id, confirmation_text)
572
+ return {"status": "ok"}
573
+
574
+ if (
575
+ pending.command_name == "/fix"
576
+ and pending.action == FIX_SOURCE_PENDING_ACTION
577
+ and req.message_head_lower != "/init"
578
+ ):
579
+ if req.message.text.strip() in {"y", "Y"}:
580
+ confirmed = cc.confirmation_store.pop(scope_project, chat_id)
581
+ if (
582
+ confirmed is None
583
+ or confirmed.job_request is None
584
+ or confirmed.job_request.parent_job_id is None
585
+ ):
586
+ bt.add_task(notifier.send_text, chat_id, "Could not process the pending confirmation.")
587
+ return {"status": "ignored"}
588
+ bt.add_task(job_manager.execute_fix_job, confirmed.job_request, None)
589
+ return {"status": "accepted"}
590
+ cc.confirmation_store.pop(scope_project, chat_id)
591
+ bt.add_task(notifier.send_text, chat_id, "Cancelled the fix job.")
592
+ return {"status": "ignored"}
593
+
594
+ return None
595
+
596
+ def _handle_command(req: _Req) -> dict[str, str] | None:
597
+ command_response: CommandResponse | None = command_registry.dispatch_rich(
598
+ req.message, req.command_context
599
+ )
600
+ if not command_response:
601
+ return None
602
+ raw_cmd = req.message.text.strip()
603
+ cmd_token = raw_cmd.split(maxsplit=1)[0] if raw_cmd else ""
604
+ _cmdlog.info(
605
+ "command handled cmd=%s response_len=%d button_rows=%d",
606
+ cmd_token,
607
+ len(command_response.text),
608
+ len(command_response.inline_buttons or []),
609
+ chat_id=req.chat_id,
610
+ user_id=req.user_id,
611
+ )
612
+ if command_response.inline_buttons:
613
+ req.background_tasks.add_task(
614
+ partial(
615
+ req.notifier.send_with_buttons,
616
+ req.chat_id,
617
+ command_response.text,
618
+ command_response.inline_buttons,
619
+ skip_body_i18n=command_response.skip_notifier_body_i18n,
620
+ )
621
+ )
622
+ else:
623
+ req.background_tasks.add_task(
624
+ partial(
625
+ req.notifier.send_text,
626
+ req.chat_id,
627
+ command_response.text,
628
+ skip_body_i18n=command_response.skip_notifier_body_i18n,
629
+ )
630
+ )
631
+ return {"status": "ok"}
632
+
633
+ def _handle_fix_reply(req: _Req) -> dict[str, str] | None:
634
+ fix_reply_match = _match_fix_reply_prefix(req.message.text)
635
+ if fix_reply_match is None or req.reply_mid is None or conversation_store is None:
636
+ return None
637
+ fix_instruction = fix_reply_match.strip()
638
+ project_name_for_fix = effective_project_name_for_chat(req.command_context, req.chat_id)
639
+ linked_job_id = conversation_store.get_job_id_for_message_id(
640
+ req.scope_project, req.chat_id, req.reply_mid
641
+ )
642
+ target_job = job_store.get(linked_job_id) if linked_job_id else None
643
+ if not (
644
+ fix_instruction
645
+ and project_name_for_fix is not None
646
+ and target_job is not None
647
+ and job_manager.is_fix_candidate(target_job, project_name_for_fix, req.chat_id)
648
+ ):
649
+ return None
650
+ fix_request = JobRequest(
651
+ project=project_name_for_fix,
652
+ model=target_job.request.model,
653
+ model_id=target_job.request.model_id,
654
+ instruction=fix_instruction,
655
+ mode=JobMode.AGENT_FIX,
656
+ fix_kind=FixKind.SOURCE,
657
+ parent_job_id=target_job.id,
658
+ branch=target_job.branch,
659
+ chat_id=req.chat_id,
660
+ requested_by=req.user_id,
661
+ message_id=req.update.message.message_id,
662
+ reply_to_message_id=req.reply_mid,
663
+ )
664
+ req.command_context.confirmation_store.set(
665
+ req.scope_project,
666
+ req.chat_id,
667
+ PendingConfirmation(
668
+ command_name="/fix",
669
+ action=FIX_SOURCE_PENDING_ACTION,
670
+ job_request=fix_request,
671
+ original_text=req.message.text.strip(),
672
+ target_job_id=target_job.id,
673
+ ),
674
+ )
675
+ use_buttons = _natural_job_confirmation_buttons_enabled(req.command_context)
676
+ confirmation_text = _format_fix_source_confirmation(
677
+ fix_request, target_job, use_buttons=use_buttons
678
+ )
679
+ if use_buttons:
680
+ req.background_tasks.add_task(
681
+ req.notifier.send_with_buttons,
682
+ req.chat_id,
683
+ confirmation_text,
684
+ _natural_job_confirmation_buttons(),
685
+ )
686
+ else:
687
+ req.background_tasks.add_task(req.notifier.send_text, req.chat_id, confirmation_text)
688
+ return {"status": "ok"}
689
+
690
+ def _handle_natural(req: _Req) -> dict[str, str]:
691
+ try:
692
+ request = parser.parse_natural(
693
+ req.message.text,
694
+ req.scope_project,
695
+ chat_id=req.chat_id,
696
+ user_id=req.user_id,
697
+ message_id=req.update.message.message_id,
698
+ reply_to_message_id=req.reply_mid,
699
+ reply_to_text=req.reply_txt,
700
+ )
701
+ except CommandParseError as exc:
702
+ _cmdlog.warning(
703
+ "parse error message_id=%s err=%s",
704
+ req.update.message.message_id,
705
+ str(exc)[:120],
706
+ chat_id=req.chat_id,
707
+ user_id=req.user_id,
708
+ )
709
+ req.background_tasks.add_task(req.notifier.send_text, req.chat_id, str(exc))
710
+ return {"status": "ignored"}
711
+
712
+ _cmdlog.info(
713
+ "natural request parsed mode=%s model=%s branch=%s commit=%s instruction_len=%d reply_to=%s",
714
+ request.mode.value,
715
+ request.model.value,
716
+ request.branch or "-",
717
+ request.commit,
718
+ len(request.instruction),
719
+ request.reply_to_message_id or "-",
720
+ chat_id=req.chat_id,
721
+ user_id=req.user_id,
722
+ project=request.project,
723
+ )
724
+
725
+ if _queue_natural_confirmation(req, request, req.message.text.strip()):
726
+ return {"status": "ok"}
727
+ return {"status": "ignored"}
728
+
729
+ @router.post("/webhook/{token_hash}")
730
+ def telegram_webhook(
731
+ token_hash: str,
732
+ update: TelegramUpdate,
733
+ background_tasks: BackgroundTasks,
734
+ x_telegram_bot_api_secret_token: str | None = Header(default=None),
735
+ ) -> dict[str, str]:
736
+ route_key = normalize_webhook_token_hash_path_segment(token_hash)
737
+ if route_key is None:
738
+ raise HTTPException(status_code=404, detail="bot instance not found")
739
+ bot_instance = bot_instance_manager.get(route_key)
740
+ if bot_instance is None:
741
+ raise HTTPException(status_code=404, detail="bot instance not found")
742
+ auth_service = bot_instance.auth_service
743
+ notifier = bot_instance.notifier
744
+ command_context = replace(bot_instance.command_context, project_name=bot_instance.project_name)
745
+ scope_project = bot_instance.project_name
746
+ webhook_secret = bot_instance.webhook_secret
747
+
748
+ _inbound.info("update received id=%s", update.update_id)
749
+ if webhook_secret and x_telegram_bot_api_secret_token != webhook_secret:
750
+ _authlog.warning("webhook secret mismatch update_id=%s", update.update_id)
751
+ return {"status": "ignored"}
752
+
753
+ if recent_updates.mark_seen(route_key, update.update_id):
754
+ _inbound.info("duplicate update ignored id=%s", update.update_id)
755
+ if update.callback_query:
756
+ background_tasks.add_task(notifier.answer_callback_query, update.callback_query.id)
757
+ return {"status": "ignored"}
758
+
759
+ if update.callback_query:
760
+ return _handle_callback_query(
761
+ update,
762
+ update.callback_query,
763
+ notifier,
764
+ auth_service,
765
+ command_context,
766
+ scope_project,
767
+ background_tasks,
768
+ )
769
+
770
+ if not update.message:
771
+ _inbound.info("update without message skipped update_id=%s", update.update_id)
772
+ return {"status": "ignored"}
773
+ if not update.message.text:
774
+ chat_only = update.message.chat.id
775
+ user_only = update.message.from_user.id if update.message.from_user else None
776
+ _inbound.info(
777
+ "empty text skipped update_id=%s message_id=%s",
778
+ update.update_id,
779
+ update.message.message_id,
780
+ chat_id=chat_only,
781
+ user_id=user_only,
782
+ )
783
+ return {"status": "ignored"}
784
+
785
+ chat_id = update.message.chat.id
786
+ user_id = update.message.from_user.id if update.message.from_user else None
787
+ preview = _telegram_text_preview(update.message.text)
788
+ _inbound.info(
789
+ "message received update_id=%s message_id=%s len=%d reply_to=%s preview=%s",
790
+ update.update_id,
791
+ update.message.message_id,
792
+ len(update.message.text),
793
+ (
794
+ update.message.reply_to_message.message_id
795
+ if update.message.reply_to_message is not None
796
+ else "-"
797
+ ),
798
+ preview or "(empty)",
799
+ chat_id=chat_id,
800
+ user_id=user_id,
801
+ )
802
+ if not auth_service.is_allowed(chat_id=chat_id, user_id=user_id):
803
+ _authlog.warning(
804
+ "unauthorized chat/user update_id=%s message_id=%s",
805
+ update.update_id,
806
+ update.message.message_id,
807
+ chat_id=chat_id,
808
+ user_id=user_id,
809
+ )
810
+ return {"status": "ignored"}
811
+
812
+ message = TelegramMessage(chat_id=chat_id, user_id=user_id, text=update.message.text)
813
+ message_tokens = message.text.strip().split(maxsplit=1)
814
+ message_head_lower = (message_tokens[0] if message_tokens else "").lower()
815
+ reply_mid = (
816
+ update.message.reply_to_message.message_id
817
+ if update.message.reply_to_message is not None
818
+ else None
819
+ )
820
+ reply_txt = (
821
+ update.message.reply_to_message.text
822
+ if update.message.reply_to_message is not None
823
+ else None
824
+ )
825
+ req = _Req(
826
+ update=update,
827
+ background_tasks=background_tasks,
828
+ notifier=notifier,
829
+ command_context=command_context,
830
+ scope_project=scope_project,
831
+ chat_id=chat_id,
832
+ user_id=user_id,
833
+ message=message,
834
+ message_head_lower=message_head_lower,
835
+ reply_mid=reply_mid,
836
+ reply_txt=reply_txt,
837
+ )
838
+
839
+ pending = command_context.confirmation_store.get(scope_project, chat_id)
840
+ pending_result = _handle_pending(req, pending)
841
+ if pending_result is not None:
842
+ return pending_result
843
+
844
+ if message_head_lower in {"/plan", "/ask"} and len(message_tokens) == 1:
845
+ mode = JobMode.PLAN if message_head_lower == "/plan" else JobMode.ASK
846
+ command_context.confirmation_store.set(
847
+ scope_project,
848
+ chat_id,
849
+ PendingConfirmation(
850
+ command_name=_NATURAL_JOB_MODE_INPUT,
851
+ action=mode.value,
852
+ ),
853
+ )
854
+ background_tasks.add_task(notifier.send_text, chat_id, _format_mode_input_prompt(mode))
855
+ return {"status": "ok"}
856
+
857
+ command_result = _handle_command(req)
858
+ if command_result is not None:
859
+ return command_result
860
+
861
+ fix_reply_result = _handle_fix_reply(req)
862
+ if fix_reply_result is not None:
863
+ return fix_reply_result
864
+
865
+ return _handle_natural(req)
866
+
867
+ def _submit_confirmed_natural_request(
868
+ request: JobRequest,
869
+ original_text: str,
870
+ background_tasks: BackgroundTasks,
871
+ ) -> Job:
872
+ if conversation_store is not None:
873
+ conversation_store.append(
874
+ project=request.project,
875
+ chat_id=request.chat_id,
876
+ role="user",
877
+ text=original_text,
878
+ message_id=request.message_id,
879
+ reply_to_message_id=request.reply_to_message_id,
880
+ )
881
+ _cmdlog.info(
882
+ "conversation user message recorded message_id=%s",
883
+ request.message_id,
884
+ chat_id=request.chat_id,
885
+ user_id=request.requested_by,
886
+ project=request.project,
887
+ )
888
+
889
+ job = job_manager.submit(request)
890
+ _cmdlog.info(
891
+ "job accepted background scheduled",
892
+ chat_id=request.chat_id,
893
+ user_id=request.requested_by,
894
+ project=request.project,
895
+ job_id=job.id,
896
+ )
897
+
898
+ if conversation_store is not None and request.message_id is not None:
899
+ conversation_store.bind_user_message_job(
900
+ project=request.project,
901
+ chat_id=request.chat_id,
902
+ message_id=request.message_id,
903
+ job_id=job.id,
904
+ )
905
+
906
+ if (
907
+ conversation_store is not None
908
+ and request.message_id is not None
909
+ and request.branch is not None
910
+ ):
911
+ conversation_store.bind_message_branch(
912
+ project=request.project,
913
+ chat_id=request.chat_id,
914
+ message_id=request.message_id,
915
+ branch=request.branch,
916
+ job_id=job.id,
917
+ )
918
+
919
+ if conversation_store is not None:
920
+ conversation_store.append(
921
+ project=request.project,
922
+ chat_id=request.chat_id,
923
+ role="job_accepted",
924
+ text=f"Job accepted: {job.id}",
925
+ job_id=job.id,
926
+ message_id=getattr(job, "accepted_message_id", None),
927
+ )
928
+ _cmdlog.info(
929
+ "conversation job_accepted recorded",
930
+ chat_id=request.chat_id,
931
+ user_id=request.requested_by,
932
+ project=request.project,
933
+ job_id=job.id,
934
+ )
935
+
936
+ if conversation_store is not None:
937
+
938
+ def run_and_record(jid: str) -> None:
939
+ _cmdlog.info("background job run start", job_id=jid)
940
+ final_job = job_manager.run(jid)
941
+ if final_job is None:
942
+ _cmdlog.warning("background job run returned none", job_id=jid)
943
+ return
944
+ summary = format_job_result_memory_summary(final_job)
945
+ conversation_store.append(
946
+ project=final_job.request.project,
947
+ chat_id=final_job.request.chat_id,
948
+ role="job_result",
949
+ text=summary,
950
+ job_id=final_job.id,
951
+ message_id=(
952
+ final_job.result_message_ids[0]
953
+ if final_job.result_message_ids
954
+ else None
955
+ ),
956
+ )
957
+ _cmdlog.info(
958
+ "conversation job_result recorded status=%s",
959
+ final_job.status.value,
960
+ chat_id=final_job.request.chat_id,
961
+ user_id=final_job.request.requested_by,
962
+ project=final_job.request.project,
963
+ job_id=final_job.id,
964
+ )
965
+ if final_job.request.message_id is not None and final_job.branch is not None:
966
+ conversation_store.bind_message_branch(
967
+ project=final_job.request.project,
968
+ chat_id=final_job.request.chat_id,
969
+ message_id=final_job.request.message_id,
970
+ branch=final_job.branch,
971
+ job_id=final_job.id,
972
+ )
973
+ _cmdlog.info(
974
+ "conversation branch binding recorded branch=%s",
975
+ final_job.branch,
976
+ chat_id=final_job.request.chat_id,
977
+ user_id=final_job.request.requested_by,
978
+ project=final_job.request.project,
979
+ job_id=final_job.id,
980
+ )
981
+
982
+ background_tasks.add_task(run_and_record, job.id)
983
+ else:
984
+ background_tasks.add_task(job_manager.run, job.id)
985
+ _ = job_store
986
+ return job
987
+
988
+ return router