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/telegram/webhook.py
ADDED
|
@@ -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
|