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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from threading import Lock
|
|
5
|
+
|
|
6
|
+
from app.models import ModelName
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ModelPreference:
|
|
11
|
+
provider: ModelName
|
|
12
|
+
model_id: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InMemoryModelPreferenceStore:
|
|
16
|
+
def __init__(self, default_model: ModelName) -> None:
|
|
17
|
+
self._default_model = default_model
|
|
18
|
+
self._values: dict[tuple[str | None, int], ModelPreference] = {}
|
|
19
|
+
self._lock = Lock()
|
|
20
|
+
|
|
21
|
+
def get(self, project_name: str | None, chat_id: int) -> ModelName:
|
|
22
|
+
with self._lock:
|
|
23
|
+
selection = self._values.get((project_name, chat_id))
|
|
24
|
+
return selection.provider if selection is not None else self._default_model
|
|
25
|
+
|
|
26
|
+
def get_explicit(self, project_name: str | None, chat_id: int) -> ModelName | None:
|
|
27
|
+
with self._lock:
|
|
28
|
+
selection = self._values.get((project_name, chat_id))
|
|
29
|
+
return selection.provider if selection is not None else None
|
|
30
|
+
|
|
31
|
+
def set(self, project_name: str | None, chat_id: int, model: ModelName) -> None:
|
|
32
|
+
self.set_selection(project_name, chat_id, ModelPreference(model))
|
|
33
|
+
|
|
34
|
+
def get_explicit_selection(
|
|
35
|
+
self,
|
|
36
|
+
project_name: str | None,
|
|
37
|
+
chat_id: int,
|
|
38
|
+
) -> ModelPreference | None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
return self._values.get((project_name, chat_id))
|
|
41
|
+
|
|
42
|
+
def set_selection(
|
|
43
|
+
self,
|
|
44
|
+
project_name: str | None,
|
|
45
|
+
chat_id: int,
|
|
46
|
+
selection: ModelPreference,
|
|
47
|
+
) -> None:
|
|
48
|
+
with self._lock:
|
|
49
|
+
self._values[(project_name, chat_id)] = selection
|
|
50
|
+
|
|
51
|
+
def clear(self, project_name: str | None, chat_id: int) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._values.pop((project_name, chat_id), None)
|
app/telegram/notifier.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from app.ai.model_catalog import format_model_selection
|
|
10
|
+
from app.ai.usage import format_token_usage
|
|
11
|
+
from app.jobs.schemas import Job, JobMode
|
|
12
|
+
from app.monitoring.events import EventLogger
|
|
13
|
+
from app.telegram.i18n import language_from_settings_store, translate_button_label, translate_text, ui_message
|
|
14
|
+
|
|
15
|
+
_outbound = EventLogger("app.telegram.outbound", "telegram.outbound")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Notifier(Protocol):
|
|
19
|
+
def send_text(self, chat_id: int, text: str, *, skip_body_i18n: bool = False) -> int | None: ...
|
|
20
|
+
|
|
21
|
+
def send_with_buttons(
|
|
22
|
+
self,
|
|
23
|
+
chat_id: int,
|
|
24
|
+
text: str,
|
|
25
|
+
inline_buttons: list,
|
|
26
|
+
*,
|
|
27
|
+
skip_body_i18n: bool = False,
|
|
28
|
+
) -> int | None: ...
|
|
29
|
+
|
|
30
|
+
def answer_callback_query(self, callback_query_id: str) -> None: ...
|
|
31
|
+
|
|
32
|
+
def send_job_accepted(self, job: Job) -> int | None: ...
|
|
33
|
+
|
|
34
|
+
def send_job_result(self, job: Job) -> list[int]: ...
|
|
35
|
+
|
|
36
|
+
def send_long_text(self, chat_id: int, text: str) -> list[int]: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class _OutboundButton:
|
|
41
|
+
label: str
|
|
42
|
+
callback_data: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_job_accepted_message(job: Job) -> tuple[str, list[list[_OutboundButton]]]:
|
|
46
|
+
mode_line = ""
|
|
47
|
+
if job.request.mode is JobMode.PLAN:
|
|
48
|
+
mode_line = ui_message("job.mode_line", "\n- Mode: {mode}", mode="plan")
|
|
49
|
+
elif job.request.mode is JobMode.ASK:
|
|
50
|
+
mode_line = ui_message("job.mode_line", "\n- Mode: {mode}", mode="ask")
|
|
51
|
+
text = ui_message(
|
|
52
|
+
"job.accepted",
|
|
53
|
+
"✅ Job accepted\n\n"
|
|
54
|
+
"- Job ID: {job_id}\n"
|
|
55
|
+
"- Project: {project}\n"
|
|
56
|
+
"- Model: {model}{mode_line}",
|
|
57
|
+
job_id=job.id,
|
|
58
|
+
project=job.request.project,
|
|
59
|
+
model=format_model_selection(job.request.model, job.request.model_id),
|
|
60
|
+
mode_line=mode_line,
|
|
61
|
+
)
|
|
62
|
+
buttons = [[_OutboundButton(ui_message("job.stop_button", "Stop job"), f"/stop {job.id}")]]
|
|
63
|
+
return text, buttons
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ui_response_block(summary: str | None) -> str:
|
|
67
|
+
if not summary:
|
|
68
|
+
return ""
|
|
69
|
+
return ui_message("job.response_block", "\n\nAI response:\n{summary}", summary=summary)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ui_failure_details(job: Job) -> str:
|
|
73
|
+
details: list[str] = []
|
|
74
|
+
if job.error_stage:
|
|
75
|
+
details.append(
|
|
76
|
+
ui_message("job.failure_detail_stage", "\n- Failure stage: {stage}", stage=job.error_stage)
|
|
77
|
+
)
|
|
78
|
+
if job.log_path:
|
|
79
|
+
details.append(
|
|
80
|
+
ui_message("job.failure_detail_log_path", "\n- Log path: {log_path}", log_path=job.log_path)
|
|
81
|
+
)
|
|
82
|
+
return "".join(details)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ui_failure_block(summary: str | None) -> str:
|
|
86
|
+
if not summary:
|
|
87
|
+
return ""
|
|
88
|
+
return ui_message("job.failure_block", "\n\nFailure output summary:\n{summary}", summary=summary)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ui_token_usage(job: Job) -> str:
|
|
92
|
+
return format_token_usage(job.runner_token_usage) or ui_message(
|
|
93
|
+
"common.unavailable",
|
|
94
|
+
"unavailable",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_job_result_message(job: Job) -> str:
|
|
99
|
+
mode_prefix = ""
|
|
100
|
+
if job.request.mode is JobMode.PLAN:
|
|
101
|
+
mode_prefix = "[plan] "
|
|
102
|
+
elif job.request.mode is JobMode.ASK:
|
|
103
|
+
mode_prefix = "[ask] "
|
|
104
|
+
|
|
105
|
+
if job.status.value == "cancelled":
|
|
106
|
+
return ui_message(
|
|
107
|
+
"job.cancelled",
|
|
108
|
+
"{mode_prefix}⛔ Job cancelled\n\n- Job ID: {job_id}\n- Project: {project}",
|
|
109
|
+
mode_prefix=mode_prefix,
|
|
110
|
+
job_id=job.id,
|
|
111
|
+
project=job.request.project,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if job.status.value == "succeeded":
|
|
115
|
+
if job.request.mode in (JobMode.PLAN, JobMode.ASK):
|
|
116
|
+
label = "plan" if job.request.mode is JobMode.PLAN else "ask"
|
|
117
|
+
model_label = job.runner_actual_model or format_model_selection(
|
|
118
|
+
job.request.model,
|
|
119
|
+
job.request.model_id,
|
|
120
|
+
)
|
|
121
|
+
return ui_message(
|
|
122
|
+
"job.readonly_completed",
|
|
123
|
+
"[{mode}] Completed\n\n"
|
|
124
|
+
"- Job ID: {job_id}\n"
|
|
125
|
+
"- Project: {project}\n"
|
|
126
|
+
"- Model used: {model}\n"
|
|
127
|
+
"- Token usage: {token_usage}{response_block}",
|
|
128
|
+
mode=label,
|
|
129
|
+
job_id=job.id,
|
|
130
|
+
project=job.request.project,
|
|
131
|
+
model=model_label,
|
|
132
|
+
token_usage=_ui_token_usage(job),
|
|
133
|
+
response_block=_ui_response_block(job.runner_stdout_summary),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
changed = ", ".join(job.changed_files) if job.changed_files else ui_message(
|
|
137
|
+
"job.no_changes",
|
|
138
|
+
"No changes",
|
|
139
|
+
)
|
|
140
|
+
branch_line = job.branch if job.branch else ui_message(
|
|
141
|
+
"job.branch_none_no_changes",
|
|
142
|
+
"(none - no branch; no changes)",
|
|
143
|
+
)
|
|
144
|
+
commit_line = job.commit_hash or "-"
|
|
145
|
+
if job.changed_files and not job.request.commit:
|
|
146
|
+
commit_line = ui_message("job.no_commit_skipped", "(no commit - commit/push skipped)")
|
|
147
|
+
elif job.changed_files and job.request.commit and not job.commit_hash:
|
|
148
|
+
commit_line = ui_message("job.nothing_staged_skipped", "(nothing staged - push skipped)")
|
|
149
|
+
model_label = job.runner_actual_model or format_model_selection(
|
|
150
|
+
job.request.model,
|
|
151
|
+
job.request.model_id,
|
|
152
|
+
)
|
|
153
|
+
return ui_message(
|
|
154
|
+
"job.completed",
|
|
155
|
+
"✅ Job completed\n\n"
|
|
156
|
+
"- Job ID: {job_id}\n"
|
|
157
|
+
"- Project: {project}\n"
|
|
158
|
+
"- Branch: {branch}\n"
|
|
159
|
+
"- Commit: {commit}\n"
|
|
160
|
+
"- Changed files: {changed}\n"
|
|
161
|
+
"- Model used: {model}\n"
|
|
162
|
+
"- Token usage: {token_usage}{response_block}",
|
|
163
|
+
job_id=job.id,
|
|
164
|
+
project=job.request.project,
|
|
165
|
+
branch=branch_line,
|
|
166
|
+
commit=commit_line,
|
|
167
|
+
changed=changed,
|
|
168
|
+
model=model_label,
|
|
169
|
+
token_usage=_ui_token_usage(job),
|
|
170
|
+
response_block=_ui_response_block(job.runner_stdout_summary),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
failure_summary = job.runner_stderr_summary or job.runner_stdout_summary
|
|
174
|
+
return ui_message(
|
|
175
|
+
"job.failed",
|
|
176
|
+
"{mode_prefix}❌ Job failed\n\n"
|
|
177
|
+
"- Job ID: {job_id}\n"
|
|
178
|
+
"- Project: {project}\n"
|
|
179
|
+
"- Error: {error}{details}{failure_block}",
|
|
180
|
+
mode_prefix=mode_prefix,
|
|
181
|
+
job_id=job.id,
|
|
182
|
+
project=job.request.project,
|
|
183
|
+
error=job.error or "unknown error",
|
|
184
|
+
details=_ui_failure_details(job),
|
|
185
|
+
failure_block=_ui_failure_block(failure_summary),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class TelegramNotifier:
|
|
190
|
+
_TELEGRAM_TEXT_LIMIT = 4096
|
|
191
|
+
_MAX_ATTEMPTS = 3
|
|
192
|
+
|
|
193
|
+
def __init__(self, bot_token: str, advanced_settings_store=None) -> None:
|
|
194
|
+
self._api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
195
|
+
self._callback_answer_url = f"https://api.telegram.org/bot{bot_token}/answerCallbackQuery"
|
|
196
|
+
self._advanced_settings_store = advanced_settings_store
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def _language(self):
|
|
200
|
+
return language_from_settings_store(self._advanced_settings_store)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _extract_message_id(response: httpx.Response) -> int | None:
|
|
204
|
+
try:
|
|
205
|
+
data = response.json()
|
|
206
|
+
except ValueError:
|
|
207
|
+
return None
|
|
208
|
+
result = data.get("result") if isinstance(data, dict) else None
|
|
209
|
+
message_id = result.get("message_id") if isinstance(result, dict) else None
|
|
210
|
+
return int(message_id) if message_id is not None else None
|
|
211
|
+
|
|
212
|
+
def _post_with_retry(
|
|
213
|
+
self,
|
|
214
|
+
url: str,
|
|
215
|
+
payload: dict,
|
|
216
|
+
*,
|
|
217
|
+
log_label: str,
|
|
218
|
+
chat_id: int | None = None,
|
|
219
|
+
) -> httpx.Response | None:
|
|
220
|
+
for attempt in range(1, self._MAX_ATTEMPTS + 1):
|
|
221
|
+
try:
|
|
222
|
+
response = httpx.post(url, json=payload, timeout=httpx.Timeout(10.0, connect=5.0))
|
|
223
|
+
response.raise_for_status()
|
|
224
|
+
return response
|
|
225
|
+
except httpx.HTTPError as exc:
|
|
226
|
+
_outbound.warning(
|
|
227
|
+
"%s attempt failed attempt=%d/%d err=%s",
|
|
228
|
+
log_label,
|
|
229
|
+
attempt,
|
|
230
|
+
self._MAX_ATTEMPTS,
|
|
231
|
+
type(exc).__name__,
|
|
232
|
+
chat_id=chat_id,
|
|
233
|
+
)
|
|
234
|
+
if attempt == self._MAX_ATTEMPTS:
|
|
235
|
+
_outbound.warning(
|
|
236
|
+
"%s failed after %d attempts: %s",
|
|
237
|
+
log_label,
|
|
238
|
+
self._MAX_ATTEMPTS,
|
|
239
|
+
type(exc).__name__,
|
|
240
|
+
chat_id=chat_id,
|
|
241
|
+
)
|
|
242
|
+
return None
|
|
243
|
+
time.sleep(attempt)
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def _post_message(self, chat_id: int, text: str) -> int | None:
|
|
247
|
+
_outbound.info("sendMessage start len=%d", len(text), chat_id=chat_id)
|
|
248
|
+
response = self._post_with_retry(
|
|
249
|
+
self._api_url,
|
|
250
|
+
{"chat_id": chat_id, "text": text},
|
|
251
|
+
log_label="sendMessage",
|
|
252
|
+
chat_id=chat_id,
|
|
253
|
+
)
|
|
254
|
+
if response is None:
|
|
255
|
+
return None
|
|
256
|
+
_outbound.info("sent text len=%d status=%d", len(text), response.status_code, chat_id=chat_id)
|
|
257
|
+
return self._extract_message_id(response)
|
|
258
|
+
|
|
259
|
+
def send_text(self, chat_id: int, text: str, *, skip_body_i18n: bool = False) -> int | None:
|
|
260
|
+
out = text if skip_body_i18n else translate_text(text, self._language)
|
|
261
|
+
return self._post_message(chat_id, out)
|
|
262
|
+
|
|
263
|
+
def send_with_buttons(
|
|
264
|
+
self,
|
|
265
|
+
chat_id: int,
|
|
266
|
+
text: str,
|
|
267
|
+
inline_buttons: list,
|
|
268
|
+
*,
|
|
269
|
+
skip_body_i18n: bool = False,
|
|
270
|
+
) -> int | None:
|
|
271
|
+
language = self._language
|
|
272
|
+
out_text = text if skip_body_i18n else translate_text(text, language)
|
|
273
|
+
keyboard = [
|
|
274
|
+
[
|
|
275
|
+
{"text": translate_button_label(btn.label, language), "callback_data": btn.callback_data}
|
|
276
|
+
for btn in row
|
|
277
|
+
]
|
|
278
|
+
for row in inline_buttons
|
|
279
|
+
]
|
|
280
|
+
payload = {
|
|
281
|
+
"chat_id": chat_id,
|
|
282
|
+
"text": out_text,
|
|
283
|
+
"reply_markup": {"inline_keyboard": keyboard},
|
|
284
|
+
}
|
|
285
|
+
button_count = sum(len(row) for row in inline_buttons)
|
|
286
|
+
_outbound.info(
|
|
287
|
+
"sendMessage buttons start len=%d rows=%d buttons=%d",
|
|
288
|
+
len(out_text),
|
|
289
|
+
len(inline_buttons),
|
|
290
|
+
button_count,
|
|
291
|
+
chat_id=chat_id,
|
|
292
|
+
)
|
|
293
|
+
response = self._post_with_retry(
|
|
294
|
+
self._api_url,
|
|
295
|
+
payload,
|
|
296
|
+
log_label="sendMessage (buttons)",
|
|
297
|
+
chat_id=chat_id,
|
|
298
|
+
)
|
|
299
|
+
if response is None:
|
|
300
|
+
return None
|
|
301
|
+
_outbound.info(
|
|
302
|
+
"sent message with buttons len=%d status=%d",
|
|
303
|
+
len(out_text),
|
|
304
|
+
response.status_code,
|
|
305
|
+
chat_id=chat_id,
|
|
306
|
+
)
|
|
307
|
+
return self._extract_message_id(response)
|
|
308
|
+
|
|
309
|
+
def answer_callback_query(self, callback_query_id: str) -> None:
|
|
310
|
+
_outbound.info("answerCallbackQuery start")
|
|
311
|
+
response = self._post_with_retry(
|
|
312
|
+
self._callback_answer_url,
|
|
313
|
+
{"callback_query_id": callback_query_id},
|
|
314
|
+
log_label="answerCallbackQuery",
|
|
315
|
+
)
|
|
316
|
+
if response is not None:
|
|
317
|
+
_outbound.info("answerCallbackQuery sent status=%d", response.status_code)
|
|
318
|
+
|
|
319
|
+
def send_job_accepted(self, job: Job) -> int | None:
|
|
320
|
+
_outbound.info(
|
|
321
|
+
"notify job accepted",
|
|
322
|
+
chat_id=job.request.chat_id,
|
|
323
|
+
job_id=job.id,
|
|
324
|
+
project=job.request.project,
|
|
325
|
+
)
|
|
326
|
+
text, buttons = build_job_accepted_message(job)
|
|
327
|
+
return self.send_with_buttons(job.request.chat_id, text, buttons)
|
|
328
|
+
|
|
329
|
+
def send_job_result(self, job: Job) -> list[int]:
|
|
330
|
+
_outbound.info(
|
|
331
|
+
"notify job result status=%s changed_files=%d",
|
|
332
|
+
job.status.value,
|
|
333
|
+
len(job.changed_files),
|
|
334
|
+
chat_id=job.request.chat_id,
|
|
335
|
+
job_id=job.id,
|
|
336
|
+
project=job.request.project,
|
|
337
|
+
)
|
|
338
|
+
return self.send_long_text(job.request.chat_id, build_job_result_message(job))
|
|
339
|
+
|
|
340
|
+
def send_long_text(self, chat_id: int, text: str) -> list[int]:
|
|
341
|
+
"""Split text across Telegram messages when it exceeds the 4096-character limit."""
|
|
342
|
+
outgoing = translate_text(text, self._language)
|
|
343
|
+
chunks = self._chunk_text(outgoing, self._TELEGRAM_TEXT_LIMIT)
|
|
344
|
+
_outbound.info(
|
|
345
|
+
"send_long_text chunks=%d total_len=%d",
|
|
346
|
+
len(chunks),
|
|
347
|
+
len(outgoing),
|
|
348
|
+
chat_id=chat_id,
|
|
349
|
+
)
|
|
350
|
+
message_ids: list[int] = []
|
|
351
|
+
for idx, chunk in enumerate(chunks, 1):
|
|
352
|
+
_outbound.info(
|
|
353
|
+
"send_long_text chunk=%d/%d len=%d",
|
|
354
|
+
idx,
|
|
355
|
+
len(chunks),
|
|
356
|
+
len(chunk),
|
|
357
|
+
chat_id=chat_id,
|
|
358
|
+
)
|
|
359
|
+
message_id = self._post_message(chat_id, chunk)
|
|
360
|
+
if message_id is not None:
|
|
361
|
+
message_ids.append(message_id)
|
|
362
|
+
return message_ids
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _chunk_text(text: str, max_len: int) -> list[str]:
|
|
366
|
+
if max_len <= 0:
|
|
367
|
+
raise ValueError("max_len must be positive")
|
|
368
|
+
if len(text) <= max_len:
|
|
369
|
+
return [text]
|
|
370
|
+
chunks: list[str] = []
|
|
371
|
+
i = 0
|
|
372
|
+
n = len(text)
|
|
373
|
+
min_break = max_len // 2
|
|
374
|
+
while i < n:
|
|
375
|
+
j = min(i + max_len, n)
|
|
376
|
+
if j < n:
|
|
377
|
+
segment = text[i:j]
|
|
378
|
+
cut = segment.rfind("\n")
|
|
379
|
+
if cut >= min_break:
|
|
380
|
+
j = i + cut + 1
|
|
381
|
+
else:
|
|
382
|
+
cut = segment.rfind(" ")
|
|
383
|
+
if cut >= min_break:
|
|
384
|
+
j = i + cut + 1
|
|
385
|
+
chunks.append(text[i:j])
|
|
386
|
+
i = j
|
|
387
|
+
return chunks
|
app/telegram/parser.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from app.git.service import GitWorktreeService
|
|
6
|
+
from app.jobs.schemas import JobMode, JobRequest
|
|
7
|
+
from app.models import ModelName
|
|
8
|
+
from app.projects.registry import ProjectRegistry
|
|
9
|
+
from app.telegram.conversation import (
|
|
10
|
+
ConversationContextBuilder,
|
|
11
|
+
SQLiteConversationStore,
|
|
12
|
+
is_ambiguous_followup,
|
|
13
|
+
)
|
|
14
|
+
from app.telegram.i18n import (
|
|
15
|
+
command_parse_error_disabled_project,
|
|
16
|
+
command_parse_error_empty_instruction,
|
|
17
|
+
command_parse_error_empty_instruction_plan_ask,
|
|
18
|
+
command_parse_error_no_previous_job_context,
|
|
19
|
+
command_parse_error_unknown_project,
|
|
20
|
+
instruction_frame_labels,
|
|
21
|
+
language_from_settings_store,
|
|
22
|
+
localize_git_branch_validation_message,
|
|
23
|
+
)
|
|
24
|
+
from app.telegram.model_preferences import InMemoryModelPreferenceStore, ModelPreference
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandParseError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MODEL_OPTION_PATTERN = "|".join(model.value for model in ModelName)
|
|
32
|
+
|
|
33
|
+
_SLASH_PLAN_ASK = re.compile(r"^/(plan|ask)\b\s*", re.IGNORECASE)
|
|
34
|
+
_PREFIX_PLAN_ASK = re.compile(
|
|
35
|
+
r"^(plan|ask|계획|질문)\s*[::]\s*",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
_REPLY_JOB_ID_PATTERN = re.compile(
|
|
39
|
+
r"\bJob ID:\s*`?([A-Za-z0-9_.:-]+)`?",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _job_mode_from_plan_ask_keyword(key: str) -> JobMode:
|
|
45
|
+
lowered = key.lower()
|
|
46
|
+
if lowered in ("plan", "계획"):
|
|
47
|
+
return JobMode.PLAN
|
|
48
|
+
if lowered in ("ask", "질문"):
|
|
49
|
+
return JobMode.ASK
|
|
50
|
+
raise AssertionError(key)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_reply_job_id(text: str) -> str | None:
|
|
54
|
+
match = _REPLY_JOB_ID_PATTERN.search(text)
|
|
55
|
+
return match.group(1) if match else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CommandParser:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
project_registry: ProjectRegistry,
|
|
62
|
+
default_model: ModelName,
|
|
63
|
+
model_preferences: InMemoryModelPreferenceStore | None = None,
|
|
64
|
+
conversation_store: SQLiteConversationStore | None = None,
|
|
65
|
+
conversation_recent_limit: int = 10,
|
|
66
|
+
advanced_settings_store=None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self._project_registry = project_registry
|
|
69
|
+
self._default_model = default_model
|
|
70
|
+
self._model_preferences = model_preferences
|
|
71
|
+
self._conversation_store = conversation_store
|
|
72
|
+
self._conversation_recent_limit = conversation_recent_limit
|
|
73
|
+
self._advanced_settings_store = advanced_settings_store
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def _extract_options(
|
|
77
|
+
text: str,
|
|
78
|
+
) -> tuple[ModelName | None, str | None, bool | None, str]:
|
|
79
|
+
remaining = text
|
|
80
|
+
model: ModelName | None = None
|
|
81
|
+
branch: str | None = None
|
|
82
|
+
commit: bool | None = None
|
|
83
|
+
|
|
84
|
+
model_match = re.search(
|
|
85
|
+
rf"\bmodel:\s*({_MODEL_OPTION_PATTERN})\b",
|
|
86
|
+
remaining,
|
|
87
|
+
flags=re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
if model_match:
|
|
90
|
+
model = ModelName(model_match.group(1).lower())
|
|
91
|
+
remaining = re.sub(
|
|
92
|
+
rf"\bmodel:\s*({_MODEL_OPTION_PATTERN})\b",
|
|
93
|
+
"",
|
|
94
|
+
remaining,
|
|
95
|
+
flags=re.IGNORECASE,
|
|
96
|
+
).strip()
|
|
97
|
+
|
|
98
|
+
branch_match = re.search(r"\bbranch:\s*([A-Za-z0-9._/\-]+)", remaining, flags=re.IGNORECASE)
|
|
99
|
+
if branch_match:
|
|
100
|
+
branch = branch_match.group(1)
|
|
101
|
+
remaining = re.sub(r"\bbranch:\s*([A-Za-z0-9._/\-]+)", "", remaining, flags=re.IGNORECASE).strip()
|
|
102
|
+
|
|
103
|
+
no_commit_match = re.search(r"\bno\s+commit\b", remaining, flags=re.IGNORECASE)
|
|
104
|
+
if no_commit_match:
|
|
105
|
+
commit = False
|
|
106
|
+
remaining = re.sub(r"\bno\s+commit\b", "", remaining, flags=re.IGNORECASE).strip()
|
|
107
|
+
|
|
108
|
+
return model, branch, commit, remaining
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _strip_leading_job_mode(text: str) -> tuple[JobMode, str]:
|
|
112
|
+
stripped = text.strip()
|
|
113
|
+
slash = _SLASH_PLAN_ASK.match(stripped)
|
|
114
|
+
if slash:
|
|
115
|
+
key = slash.group(1).lower()
|
|
116
|
+
mode = JobMode.PLAN if key == "plan" else JobMode.ASK
|
|
117
|
+
return mode, stripped[slash.end() :].strip()
|
|
118
|
+
prefix = _PREFIX_PLAN_ASK.match(stripped)
|
|
119
|
+
if prefix:
|
|
120
|
+
mode = _job_mode_from_plan_ask_keyword(prefix.group(1))
|
|
121
|
+
return mode, stripped[prefix.end() :].strip()
|
|
122
|
+
return JobMode.AGENT, stripped
|
|
123
|
+
|
|
124
|
+
def parse_natural(
|
|
125
|
+
self,
|
|
126
|
+
text: str,
|
|
127
|
+
project_name: str,
|
|
128
|
+
chat_id: int,
|
|
129
|
+
user_id: int | None,
|
|
130
|
+
message_id: int | None = None,
|
|
131
|
+
reply_to_message_id: int | None = None,
|
|
132
|
+
reply_to_text: str | None = None,
|
|
133
|
+
) -> JobRequest:
|
|
134
|
+
lang = language_from_settings_store(self._advanced_settings_store)
|
|
135
|
+
mode, stripped = self._strip_leading_job_mode(text)
|
|
136
|
+
|
|
137
|
+
model, branch, commit, remaining = self._extract_options(stripped)
|
|
138
|
+
if mode in (JobMode.PLAN, JobMode.ASK):
|
|
139
|
+
branch = None
|
|
140
|
+
commit = False
|
|
141
|
+
|
|
142
|
+
if not remaining:
|
|
143
|
+
if mode in (JobMode.PLAN, JobMode.ASK):
|
|
144
|
+
raise CommandParseError(command_parse_error_empty_instruction_plan_ask(lang))
|
|
145
|
+
raise CommandParseError(command_parse_error_empty_instruction(lang))
|
|
146
|
+
|
|
147
|
+
entry = self._project_registry.get(project_name)
|
|
148
|
+
if not entry:
|
|
149
|
+
raise CommandParseError(command_parse_error_unknown_project(project_name, lang))
|
|
150
|
+
if not entry.enabled:
|
|
151
|
+
raise CommandParseError(command_parse_error_disabled_project(project_name, lang))
|
|
152
|
+
|
|
153
|
+
selected_model: ModelName
|
|
154
|
+
selected_model_id: str | None = None
|
|
155
|
+
if model is not None:
|
|
156
|
+
selected_model = model
|
|
157
|
+
elif self._model_preferences is not None:
|
|
158
|
+
selection = self._model_preferences.get_explicit_selection(project_name, chat_id)
|
|
159
|
+
if selection is None:
|
|
160
|
+
selection = ModelPreference(entry.default_model)
|
|
161
|
+
selected_model = selection.provider
|
|
162
|
+
selected_model_id = selection.model_id
|
|
163
|
+
else:
|
|
164
|
+
selected_model = entry.default_model
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
mode == JobMode.AGENT
|
|
168
|
+
and branch is None
|
|
169
|
+
and reply_to_message_id is not None
|
|
170
|
+
and self._conversation_store is not None
|
|
171
|
+
):
|
|
172
|
+
branch = self._conversation_store.get_bound_branch(
|
|
173
|
+
project_name,
|
|
174
|
+
chat_id,
|
|
175
|
+
reply_to_message_id,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if branch is not None:
|
|
179
|
+
branch_err = GitWorktreeService.validate_branch_token(branch)
|
|
180
|
+
if branch_err:
|
|
181
|
+
raise CommandParseError(localize_git_branch_validation_message(branch_err, lang))
|
|
182
|
+
|
|
183
|
+
instruction_body = remaining.strip()
|
|
184
|
+
reply_job_id: str | None = None
|
|
185
|
+
if reply_to_message_id is not None and self._conversation_store is not None:
|
|
186
|
+
reply_job_id = self._conversation_store.get_job_id_for_message_id(
|
|
187
|
+
project_name,
|
|
188
|
+
chat_id,
|
|
189
|
+
reply_to_message_id,
|
|
190
|
+
)
|
|
191
|
+
reply_prefix = ""
|
|
192
|
+
if reply_to_message_id is not None and self._conversation_store is not None:
|
|
193
|
+
reply_prefix = self._conversation_store.format_reply_context(
|
|
194
|
+
project_name,
|
|
195
|
+
chat_id,
|
|
196
|
+
reply_to_message_id,
|
|
197
|
+
lang,
|
|
198
|
+
).strip()
|
|
199
|
+
if not reply_prefix and reply_to_message_id is not None and reply_to_text:
|
|
200
|
+
frame = instruction_frame_labels(lang)
|
|
201
|
+
if self._conversation_store is not None:
|
|
202
|
+
extracted_reply_job_id = _extract_reply_job_id(reply_to_text)
|
|
203
|
+
if extracted_reply_job_id:
|
|
204
|
+
reply_job_id = extracted_reply_job_id
|
|
205
|
+
reply_prefix = self._conversation_store.format_job_context(
|
|
206
|
+
project_name,
|
|
207
|
+
chat_id,
|
|
208
|
+
extracted_reply_job_id,
|
|
209
|
+
lang,
|
|
210
|
+
).strip()
|
|
211
|
+
if not reply_prefix:
|
|
212
|
+
reply_prefix = "\n".join(
|
|
213
|
+
[
|
|
214
|
+
frame.reply_message_open,
|
|
215
|
+
f"message_id={reply_to_message_id}:",
|
|
216
|
+
f" text: {reply_to_text.strip()}",
|
|
217
|
+
frame.reply_message_close,
|
|
218
|
+
]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
chain_message_ids: set[int] = set()
|
|
222
|
+
if reply_to_message_id is not None and self._conversation_store is not None:
|
|
223
|
+
chain_message_ids = self._conversation_store.collect_reply_chain_message_ids(
|
|
224
|
+
project_name,
|
|
225
|
+
chat_id,
|
|
226
|
+
reply_to_message_id,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if is_ambiguous_followup(instruction_body) and self._conversation_store is not None:
|
|
230
|
+
entries = self._conversation_store.list_recent(
|
|
231
|
+
project_name,
|
|
232
|
+
chat_id,
|
|
233
|
+
self._conversation_recent_limit,
|
|
234
|
+
)
|
|
235
|
+
filtered = [
|
|
236
|
+
e
|
|
237
|
+
for e in entries
|
|
238
|
+
if e.message_id is None or e.message_id not in chain_message_ids
|
|
239
|
+
]
|
|
240
|
+
if not filtered:
|
|
241
|
+
if not reply_prefix:
|
|
242
|
+
raise CommandParseError(command_parse_error_no_previous_job_context(lang))
|
|
243
|
+
instruction = f"{reply_prefix}\n\n{instruction_body}".strip()
|
|
244
|
+
else:
|
|
245
|
+
inner = ConversationContextBuilder.build(filtered, instruction_body, lang)
|
|
246
|
+
instruction = f"{reply_prefix}\n\n{inner}".strip() if reply_prefix else inner
|
|
247
|
+
elif reply_prefix:
|
|
248
|
+
instruction = f"{reply_prefix}\n\n{instruction_body}".strip()
|
|
249
|
+
else:
|
|
250
|
+
instruction = instruction_body
|
|
251
|
+
|
|
252
|
+
effective_commit = False if mode in (JobMode.PLAN, JobMode.ASK) else (True if commit is None else commit)
|
|
253
|
+
|
|
254
|
+
return JobRequest(
|
|
255
|
+
project=project_name,
|
|
256
|
+
model=selected_model,
|
|
257
|
+
model_id=selected_model_id,
|
|
258
|
+
instruction=instruction,
|
|
259
|
+
mode=mode,
|
|
260
|
+
branch=branch,
|
|
261
|
+
commit=effective_commit,
|
|
262
|
+
chat_id=chat_id,
|
|
263
|
+
requested_by=user_id,
|
|
264
|
+
message_id=message_id,
|
|
265
|
+
reply_to_message_id=reply_to_message_id,
|
|
266
|
+
job_id=reply_job_id,
|
|
267
|
+
)
|