yee88 0.1.0__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.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from ...config import ConfigError
|
|
9
|
+
from ...context import RunContext
|
|
10
|
+
from ...directives import DirectiveError
|
|
11
|
+
from ...transport_runtime import ResolvedMessage
|
|
12
|
+
from ..context import _format_context
|
|
13
|
+
from ..files import (
|
|
14
|
+
default_upload_name,
|
|
15
|
+
default_upload_path,
|
|
16
|
+
deny_reason,
|
|
17
|
+
format_bytes,
|
|
18
|
+
normalize_relative_path,
|
|
19
|
+
parse_file_command,
|
|
20
|
+
parse_file_prompt,
|
|
21
|
+
resolve_path_within_root,
|
|
22
|
+
write_bytes_atomic,
|
|
23
|
+
ZipTooLargeError,
|
|
24
|
+
zip_directory,
|
|
25
|
+
)
|
|
26
|
+
from ..topic_state import TopicStateStore
|
|
27
|
+
from ..topics import _maybe_update_topic_context, _topic_key
|
|
28
|
+
from ..types import TelegramDocument, TelegramIncomingMessage
|
|
29
|
+
from .reply import make_reply
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..bridge import TelegramBridgeConfig
|
|
33
|
+
|
|
34
|
+
FILE_PUT_USAGE = "usage: `/file put <path>`"
|
|
35
|
+
FILE_GET_USAGE = "usage: `/file get <path>`"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class _FilePutPlan:
|
|
40
|
+
resolved: ResolvedMessage
|
|
41
|
+
run_root: Path
|
|
42
|
+
path_value: str | None
|
|
43
|
+
force: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class _FilePutResult:
|
|
48
|
+
name: str
|
|
49
|
+
rel_path: Path | None
|
|
50
|
+
size: int | None
|
|
51
|
+
error: str | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class _SavedFilePut:
|
|
56
|
+
context: RunContext | None
|
|
57
|
+
rel_path: Path
|
|
58
|
+
size: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class _SavedFilePutGroup:
|
|
63
|
+
context: RunContext | None
|
|
64
|
+
base_dir: Path | None
|
|
65
|
+
saved: list[_FilePutResult]
|
|
66
|
+
failed: list[_FilePutResult]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_file_put_paths(
|
|
70
|
+
plan: _FilePutPlan,
|
|
71
|
+
*,
|
|
72
|
+
cfg: TelegramBridgeConfig,
|
|
73
|
+
require_dir: bool,
|
|
74
|
+
) -> tuple[Path | None, Path | None, str | None]:
|
|
75
|
+
path_value = plan.path_value
|
|
76
|
+
if not path_value:
|
|
77
|
+
return None, None, None
|
|
78
|
+
if require_dir or path_value.endswith("/"):
|
|
79
|
+
base_dir = normalize_relative_path(path_value)
|
|
80
|
+
if base_dir is None:
|
|
81
|
+
return None, None, "invalid upload path."
|
|
82
|
+
deny_rule = deny_reason(base_dir, cfg.files.deny_globs)
|
|
83
|
+
if deny_rule is not None:
|
|
84
|
+
return None, None, f"path denied by rule: {deny_rule}"
|
|
85
|
+
base_target = resolve_path_within_root(plan.run_root, base_dir)
|
|
86
|
+
if base_target is None:
|
|
87
|
+
return None, None, "upload path escapes the repo root."
|
|
88
|
+
if base_target.exists() and not base_target.is_dir():
|
|
89
|
+
return None, None, "upload path is a file."
|
|
90
|
+
return base_dir, None, None
|
|
91
|
+
rel_path = normalize_relative_path(path_value)
|
|
92
|
+
if rel_path is None:
|
|
93
|
+
return None, None, "invalid upload path."
|
|
94
|
+
return None, rel_path, None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _check_file_permissions(
|
|
98
|
+
cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
|
|
99
|
+
) -> bool:
|
|
100
|
+
reply = make_reply(cfg, msg)
|
|
101
|
+
sender_id = msg.sender_id
|
|
102
|
+
if sender_id is None:
|
|
103
|
+
await reply(text="cannot verify sender for file transfer.")
|
|
104
|
+
return False
|
|
105
|
+
if cfg.files.allowed_user_ids:
|
|
106
|
+
if sender_id not in cfg.files.allowed_user_ids:
|
|
107
|
+
await reply(text="file transfer is not allowed for this user.")
|
|
108
|
+
return False
|
|
109
|
+
return True
|
|
110
|
+
if msg.is_private:
|
|
111
|
+
return True
|
|
112
|
+
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
|
113
|
+
if member is None:
|
|
114
|
+
await reply(text="failed to verify file transfer permissions.")
|
|
115
|
+
return False
|
|
116
|
+
if member.status in {"creator", "administrator"}:
|
|
117
|
+
return True
|
|
118
|
+
await reply(text="file transfer is restricted to group admins.")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _prepare_file_put_plan(
|
|
123
|
+
cfg: TelegramBridgeConfig,
|
|
124
|
+
msg: TelegramIncomingMessage,
|
|
125
|
+
args_text: str,
|
|
126
|
+
ambient_context: RunContext | None,
|
|
127
|
+
topic_store: TopicStateStore | None,
|
|
128
|
+
) -> _FilePutPlan | None:
|
|
129
|
+
reply = make_reply(cfg, msg)
|
|
130
|
+
if not await _check_file_permissions(cfg, msg):
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
resolved = cfg.runtime.resolve_message(
|
|
134
|
+
text=args_text,
|
|
135
|
+
reply_text=msg.reply_to_text,
|
|
136
|
+
ambient_context=ambient_context,
|
|
137
|
+
chat_id=msg.chat_id,
|
|
138
|
+
)
|
|
139
|
+
except DirectiveError as exc:
|
|
140
|
+
await reply(text=f"error:\n{exc}")
|
|
141
|
+
return None
|
|
142
|
+
topic_key = _topic_key(msg, cfg) if topic_store is not None else None
|
|
143
|
+
await _maybe_update_topic_context(
|
|
144
|
+
cfg=cfg,
|
|
145
|
+
topic_store=topic_store,
|
|
146
|
+
topic_key=topic_key,
|
|
147
|
+
context=resolved.context,
|
|
148
|
+
context_source=resolved.context_source,
|
|
149
|
+
)
|
|
150
|
+
if resolved.context is None or resolved.context.project is None:
|
|
151
|
+
await reply(text="no project context available for file upload.")
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
run_root = cfg.runtime.resolve_run_cwd(resolved.context)
|
|
155
|
+
except ConfigError as exc:
|
|
156
|
+
await reply(text=f"error:\n{exc}")
|
|
157
|
+
return None
|
|
158
|
+
if run_root is None:
|
|
159
|
+
await reply(text="no project context available for file upload.")
|
|
160
|
+
return None
|
|
161
|
+
path_value, force, error = parse_file_prompt(resolved.prompt, allow_empty=True)
|
|
162
|
+
if error is not None:
|
|
163
|
+
await reply(text=error)
|
|
164
|
+
return None
|
|
165
|
+
return _FilePutPlan(
|
|
166
|
+
resolved=resolved,
|
|
167
|
+
run_root=run_root,
|
|
168
|
+
path_value=path_value,
|
|
169
|
+
force=force,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _format_file_put_failures(failed: Sequence[_FilePutResult]) -> str | None:
|
|
174
|
+
if not failed:
|
|
175
|
+
return None
|
|
176
|
+
errors = ", ".join(
|
|
177
|
+
f"`{item.name}` ({item.error})" for item in failed if item.error is not None
|
|
178
|
+
)
|
|
179
|
+
if not errors:
|
|
180
|
+
return None
|
|
181
|
+
return f"failed: {errors}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _save_document_payload(
|
|
185
|
+
cfg: TelegramBridgeConfig,
|
|
186
|
+
*,
|
|
187
|
+
document: TelegramDocument,
|
|
188
|
+
run_root: Path,
|
|
189
|
+
rel_path: Path | None,
|
|
190
|
+
base_dir: Path | None,
|
|
191
|
+
force: bool,
|
|
192
|
+
) -> _FilePutResult:
|
|
193
|
+
name = default_upload_name(document.file_name, None)
|
|
194
|
+
if (
|
|
195
|
+
document.file_size is not None
|
|
196
|
+
and document.file_size > cfg.files.max_upload_bytes
|
|
197
|
+
):
|
|
198
|
+
return _FilePutResult(
|
|
199
|
+
name=name,
|
|
200
|
+
rel_path=None,
|
|
201
|
+
size=None,
|
|
202
|
+
error="file is too large to upload.",
|
|
203
|
+
)
|
|
204
|
+
file_info = await cfg.bot.get_file(document.file_id)
|
|
205
|
+
if file_info is None:
|
|
206
|
+
return _FilePutResult(
|
|
207
|
+
name=name,
|
|
208
|
+
rel_path=None,
|
|
209
|
+
size=None,
|
|
210
|
+
error="failed to fetch file metadata.",
|
|
211
|
+
)
|
|
212
|
+
file_path = file_info.file_path
|
|
213
|
+
name = default_upload_name(document.file_name, file_path)
|
|
214
|
+
resolved_path = rel_path
|
|
215
|
+
if resolved_path is None:
|
|
216
|
+
if base_dir is None:
|
|
217
|
+
resolved_path = default_upload_path(
|
|
218
|
+
cfg.files.uploads_dir, document.file_name, file_path
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
resolved_path = base_dir / name
|
|
222
|
+
deny_rule = deny_reason(resolved_path, cfg.files.deny_globs)
|
|
223
|
+
if deny_rule is not None:
|
|
224
|
+
return _FilePutResult(
|
|
225
|
+
name=name,
|
|
226
|
+
rel_path=None,
|
|
227
|
+
size=None,
|
|
228
|
+
error=f"path denied by rule: {deny_rule}",
|
|
229
|
+
)
|
|
230
|
+
target = resolve_path_within_root(run_root, resolved_path)
|
|
231
|
+
if target is None:
|
|
232
|
+
return _FilePutResult(
|
|
233
|
+
name=name,
|
|
234
|
+
rel_path=None,
|
|
235
|
+
size=None,
|
|
236
|
+
error="upload path escapes the repo root.",
|
|
237
|
+
)
|
|
238
|
+
if target.exists():
|
|
239
|
+
if target.is_dir():
|
|
240
|
+
return _FilePutResult(
|
|
241
|
+
name=name,
|
|
242
|
+
rel_path=None,
|
|
243
|
+
size=None,
|
|
244
|
+
error="upload target is a directory.",
|
|
245
|
+
)
|
|
246
|
+
if not force:
|
|
247
|
+
return _FilePutResult(
|
|
248
|
+
name=name,
|
|
249
|
+
rel_path=None,
|
|
250
|
+
size=None,
|
|
251
|
+
error="file already exists; use --force to overwrite.",
|
|
252
|
+
)
|
|
253
|
+
payload = await cfg.bot.download_file(file_path)
|
|
254
|
+
if payload is None:
|
|
255
|
+
return _FilePutResult(
|
|
256
|
+
name=name,
|
|
257
|
+
rel_path=None,
|
|
258
|
+
size=None,
|
|
259
|
+
error="failed to download file.",
|
|
260
|
+
)
|
|
261
|
+
if len(payload) > cfg.files.max_upload_bytes:
|
|
262
|
+
return _FilePutResult(
|
|
263
|
+
name=name,
|
|
264
|
+
rel_path=None,
|
|
265
|
+
size=None,
|
|
266
|
+
error="file is too large to upload.",
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
write_bytes_atomic(target, payload)
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
return _FilePutResult(
|
|
272
|
+
name=name,
|
|
273
|
+
rel_path=None,
|
|
274
|
+
size=None,
|
|
275
|
+
error=f"failed to write file: {exc}",
|
|
276
|
+
)
|
|
277
|
+
return _FilePutResult(
|
|
278
|
+
name=name,
|
|
279
|
+
rel_path=resolved_path,
|
|
280
|
+
size=len(payload),
|
|
281
|
+
error=None,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def _handle_file_command(
|
|
286
|
+
cfg: TelegramBridgeConfig,
|
|
287
|
+
msg: TelegramIncomingMessage,
|
|
288
|
+
args_text: str,
|
|
289
|
+
ambient_context: RunContext | None,
|
|
290
|
+
topic_store: TopicStateStore | None,
|
|
291
|
+
) -> None:
|
|
292
|
+
reply = make_reply(cfg, msg)
|
|
293
|
+
command, rest, error = parse_file_command(args_text)
|
|
294
|
+
if error is not None:
|
|
295
|
+
await reply(text=error)
|
|
296
|
+
return
|
|
297
|
+
if command == "put":
|
|
298
|
+
await _handle_file_put(cfg, msg, rest, ambient_context, topic_store)
|
|
299
|
+
else:
|
|
300
|
+
await _handle_file_get(cfg, msg, rest, ambient_context, topic_store)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _handle_file_put_default(
|
|
304
|
+
cfg: TelegramBridgeConfig,
|
|
305
|
+
msg: TelegramIncomingMessage,
|
|
306
|
+
ambient_context: RunContext | None,
|
|
307
|
+
topic_store: TopicStateStore | None,
|
|
308
|
+
) -> None:
|
|
309
|
+
await _handle_file_put(cfg, msg, "", ambient_context, topic_store)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _save_file_put(
|
|
313
|
+
cfg: TelegramBridgeConfig,
|
|
314
|
+
msg: TelegramIncomingMessage,
|
|
315
|
+
args_text: str,
|
|
316
|
+
ambient_context: RunContext | None,
|
|
317
|
+
topic_store: TopicStateStore | None,
|
|
318
|
+
) -> _SavedFilePut | None:
|
|
319
|
+
reply = make_reply(cfg, msg)
|
|
320
|
+
document = msg.document
|
|
321
|
+
if document is None:
|
|
322
|
+
await reply(text=FILE_PUT_USAGE)
|
|
323
|
+
return None
|
|
324
|
+
plan = await _prepare_file_put_plan(
|
|
325
|
+
cfg,
|
|
326
|
+
msg,
|
|
327
|
+
args_text,
|
|
328
|
+
ambient_context,
|
|
329
|
+
topic_store,
|
|
330
|
+
)
|
|
331
|
+
if plan is None:
|
|
332
|
+
return None
|
|
333
|
+
base_dir, rel_path, error = resolve_file_put_paths(
|
|
334
|
+
plan,
|
|
335
|
+
cfg=cfg,
|
|
336
|
+
require_dir=False,
|
|
337
|
+
)
|
|
338
|
+
if error is not None:
|
|
339
|
+
await reply(text=error)
|
|
340
|
+
return None
|
|
341
|
+
result = await _save_document_payload(
|
|
342
|
+
cfg,
|
|
343
|
+
document=document,
|
|
344
|
+
run_root=plan.run_root,
|
|
345
|
+
rel_path=rel_path,
|
|
346
|
+
base_dir=base_dir,
|
|
347
|
+
force=plan.force,
|
|
348
|
+
)
|
|
349
|
+
if result.error is not None:
|
|
350
|
+
await reply(text=result.error)
|
|
351
|
+
return None
|
|
352
|
+
if result.rel_path is None or result.size is None:
|
|
353
|
+
await reply(text="failed to save file.")
|
|
354
|
+
return None
|
|
355
|
+
return _SavedFilePut(
|
|
356
|
+
context=plan.resolved.context,
|
|
357
|
+
rel_path=result.rel_path,
|
|
358
|
+
size=result.size,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
async def _handle_file_put(
|
|
363
|
+
cfg: TelegramBridgeConfig,
|
|
364
|
+
msg: TelegramIncomingMessage,
|
|
365
|
+
args_text: str,
|
|
366
|
+
ambient_context: RunContext | None,
|
|
367
|
+
topic_store: TopicStateStore | None,
|
|
368
|
+
) -> None:
|
|
369
|
+
reply = make_reply(cfg, msg)
|
|
370
|
+
saved = await _save_file_put(
|
|
371
|
+
cfg,
|
|
372
|
+
msg,
|
|
373
|
+
args_text,
|
|
374
|
+
ambient_context,
|
|
375
|
+
topic_store,
|
|
376
|
+
)
|
|
377
|
+
if saved is None:
|
|
378
|
+
return
|
|
379
|
+
context_label = _format_context(cfg.runtime, saved.context)
|
|
380
|
+
await reply(
|
|
381
|
+
text=(
|
|
382
|
+
f"saved `{saved.rel_path.as_posix()}` "
|
|
383
|
+
f"in `{context_label}` ({format_bytes(saved.size)})"
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _handle_file_put_group(
|
|
389
|
+
cfg: TelegramBridgeConfig,
|
|
390
|
+
msg: TelegramIncomingMessage,
|
|
391
|
+
args_text: str,
|
|
392
|
+
messages: Sequence[TelegramIncomingMessage],
|
|
393
|
+
ambient_context: RunContext | None,
|
|
394
|
+
topic_store: TopicStateStore | None,
|
|
395
|
+
) -> None:
|
|
396
|
+
reply = make_reply(cfg, msg)
|
|
397
|
+
saved_group = await _save_file_put_group(
|
|
398
|
+
cfg,
|
|
399
|
+
msg,
|
|
400
|
+
args_text,
|
|
401
|
+
messages,
|
|
402
|
+
ambient_context,
|
|
403
|
+
topic_store,
|
|
404
|
+
)
|
|
405
|
+
if saved_group is None:
|
|
406
|
+
return
|
|
407
|
+
context_label = _format_context(cfg.runtime, saved_group.context)
|
|
408
|
+
total_bytes = sum(item.size or 0 for item in saved_group.saved)
|
|
409
|
+
dir_label: Path | None = saved_group.base_dir
|
|
410
|
+
if dir_label is None and saved_group.saved:
|
|
411
|
+
first_path = saved_group.saved[0].rel_path
|
|
412
|
+
if first_path is not None:
|
|
413
|
+
dir_label = first_path.parent
|
|
414
|
+
if saved_group.saved:
|
|
415
|
+
saved_names = ", ".join(f"`{item.name}`" for item in saved_group.saved)
|
|
416
|
+
if dir_label is not None:
|
|
417
|
+
dir_text = dir_label.as_posix()
|
|
418
|
+
if not dir_text.endswith("/"):
|
|
419
|
+
dir_text = f"{dir_text}/"
|
|
420
|
+
text = (
|
|
421
|
+
f"saved {saved_names} to `{dir_text}` "
|
|
422
|
+
f"in `{context_label}` ({format_bytes(total_bytes)})"
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
text = (
|
|
426
|
+
f"saved {saved_names} in `{context_label}` "
|
|
427
|
+
f"({format_bytes(total_bytes)})"
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
text = "failed to upload files."
|
|
431
|
+
failure_text = _format_file_put_failures(saved_group.failed)
|
|
432
|
+
if failure_text is not None:
|
|
433
|
+
text = f"{text}\n\n{failure_text}"
|
|
434
|
+
await reply(text=text)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def _save_file_put_group(
|
|
438
|
+
cfg: TelegramBridgeConfig,
|
|
439
|
+
msg: TelegramIncomingMessage,
|
|
440
|
+
args_text: str,
|
|
441
|
+
messages: Sequence[TelegramIncomingMessage],
|
|
442
|
+
ambient_context: RunContext | None,
|
|
443
|
+
topic_store: TopicStateStore | None,
|
|
444
|
+
) -> _SavedFilePutGroup | None:
|
|
445
|
+
reply = make_reply(cfg, msg)
|
|
446
|
+
documents = [item.document for item in messages if item.document is not None]
|
|
447
|
+
if not documents:
|
|
448
|
+
await reply(text=FILE_PUT_USAGE)
|
|
449
|
+
return None
|
|
450
|
+
plan = await _prepare_file_put_plan(
|
|
451
|
+
cfg,
|
|
452
|
+
msg,
|
|
453
|
+
args_text,
|
|
454
|
+
ambient_context,
|
|
455
|
+
topic_store,
|
|
456
|
+
)
|
|
457
|
+
if plan is None:
|
|
458
|
+
return None
|
|
459
|
+
base_dir, _, error = resolve_file_put_paths(
|
|
460
|
+
plan,
|
|
461
|
+
cfg=cfg,
|
|
462
|
+
require_dir=True,
|
|
463
|
+
)
|
|
464
|
+
if error is not None:
|
|
465
|
+
await reply(text=error)
|
|
466
|
+
return None
|
|
467
|
+
saved: list[_FilePutResult] = []
|
|
468
|
+
failed: list[_FilePutResult] = []
|
|
469
|
+
for document in documents:
|
|
470
|
+
result = await _save_document_payload(
|
|
471
|
+
cfg,
|
|
472
|
+
document=document,
|
|
473
|
+
run_root=plan.run_root,
|
|
474
|
+
rel_path=None,
|
|
475
|
+
base_dir=base_dir,
|
|
476
|
+
force=plan.force,
|
|
477
|
+
)
|
|
478
|
+
if result.error is None:
|
|
479
|
+
saved.append(result)
|
|
480
|
+
else:
|
|
481
|
+
failed.append(result)
|
|
482
|
+
return _SavedFilePutGroup(
|
|
483
|
+
context=plan.resolved.context,
|
|
484
|
+
base_dir=base_dir,
|
|
485
|
+
saved=saved,
|
|
486
|
+
failed=failed,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def _handle_file_get(
|
|
491
|
+
cfg: TelegramBridgeConfig,
|
|
492
|
+
msg: TelegramIncomingMessage,
|
|
493
|
+
args_text: str,
|
|
494
|
+
ambient_context: RunContext | None,
|
|
495
|
+
topic_store: TopicStateStore | None,
|
|
496
|
+
) -> None:
|
|
497
|
+
reply = make_reply(cfg, msg)
|
|
498
|
+
if not await _check_file_permissions(cfg, msg):
|
|
499
|
+
return
|
|
500
|
+
try:
|
|
501
|
+
resolved = cfg.runtime.resolve_message(
|
|
502
|
+
text=args_text,
|
|
503
|
+
reply_text=msg.reply_to_text,
|
|
504
|
+
ambient_context=ambient_context,
|
|
505
|
+
chat_id=msg.chat_id,
|
|
506
|
+
)
|
|
507
|
+
except DirectiveError as exc:
|
|
508
|
+
await reply(text=f"error:\n{exc}")
|
|
509
|
+
return
|
|
510
|
+
topic_key = _topic_key(msg, cfg) if topic_store is not None else None
|
|
511
|
+
await _maybe_update_topic_context(
|
|
512
|
+
cfg=cfg,
|
|
513
|
+
topic_store=topic_store,
|
|
514
|
+
topic_key=topic_key,
|
|
515
|
+
context=resolved.context,
|
|
516
|
+
context_source=resolved.context_source,
|
|
517
|
+
)
|
|
518
|
+
if resolved.context is None or resolved.context.project is None:
|
|
519
|
+
await reply(text="no project context available for file download.")
|
|
520
|
+
return
|
|
521
|
+
try:
|
|
522
|
+
run_root = cfg.runtime.resolve_run_cwd(resolved.context)
|
|
523
|
+
except ConfigError as exc:
|
|
524
|
+
await reply(text=f"error:\n{exc}")
|
|
525
|
+
return
|
|
526
|
+
if run_root is None:
|
|
527
|
+
await reply(text="no project context available for file download.")
|
|
528
|
+
return
|
|
529
|
+
path_value = resolved.prompt
|
|
530
|
+
if not path_value.strip():
|
|
531
|
+
await reply(text=FILE_GET_USAGE)
|
|
532
|
+
return
|
|
533
|
+
rel_path = normalize_relative_path(path_value)
|
|
534
|
+
if rel_path is None:
|
|
535
|
+
await reply(text="invalid download path.")
|
|
536
|
+
return
|
|
537
|
+
deny_rule = deny_reason(rel_path, cfg.files.deny_globs)
|
|
538
|
+
if deny_rule is not None:
|
|
539
|
+
await reply(text=f"path denied by rule: {deny_rule}")
|
|
540
|
+
return
|
|
541
|
+
target = resolve_path_within_root(run_root, rel_path)
|
|
542
|
+
if target is None:
|
|
543
|
+
await reply(text="download path escapes the repo root.")
|
|
544
|
+
return
|
|
545
|
+
if not target.exists():
|
|
546
|
+
await reply(text="file does not exist.")
|
|
547
|
+
return
|
|
548
|
+
if target.is_dir():
|
|
549
|
+
try:
|
|
550
|
+
payload = zip_directory(
|
|
551
|
+
run_root,
|
|
552
|
+
rel_path,
|
|
553
|
+
cfg.files.deny_globs,
|
|
554
|
+
max_bytes=cfg.files.max_download_bytes,
|
|
555
|
+
)
|
|
556
|
+
except ZipTooLargeError:
|
|
557
|
+
await reply(text="file is too large to send.")
|
|
558
|
+
return
|
|
559
|
+
except OSError as exc:
|
|
560
|
+
await reply(text=f"failed to read directory: {exc}")
|
|
561
|
+
return
|
|
562
|
+
filename = f"{rel_path.name or 'archive'}.zip"
|
|
563
|
+
else:
|
|
564
|
+
try:
|
|
565
|
+
size = target.stat().st_size
|
|
566
|
+
if size > cfg.files.max_download_bytes:
|
|
567
|
+
await reply(text="file is too large to send.")
|
|
568
|
+
return
|
|
569
|
+
payload = target.read_bytes()
|
|
570
|
+
except OSError as exc:
|
|
571
|
+
await reply(text=f"failed to read file: {exc}")
|
|
572
|
+
return
|
|
573
|
+
filename = target.name
|
|
574
|
+
if len(payload) > cfg.files.max_download_bytes:
|
|
575
|
+
await reply(text="file is too large to send.")
|
|
576
|
+
return
|
|
577
|
+
sent = await cfg.bot.send_document(
|
|
578
|
+
chat_id=msg.chat_id,
|
|
579
|
+
filename=filename,
|
|
580
|
+
content=payload,
|
|
581
|
+
reply_to_message_id=msg.message_id,
|
|
582
|
+
message_thread_id=msg.thread_id,
|
|
583
|
+
)
|
|
584
|
+
if sent is None:
|
|
585
|
+
await reply(text="failed to send file.")
|
|
586
|
+
return
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F401
|
|
4
|
+
|
|
5
|
+
from .agent import _handle_agent_command as handle_agent_command
|
|
6
|
+
from .dispatch import _dispatch_command as dispatch_command
|
|
7
|
+
from .executor import _run_engine as run_engine
|
|
8
|
+
from .executor import _should_show_resume_line as should_show_resume_line
|
|
9
|
+
from .file_transfer import _handle_file_command as handle_file_command
|
|
10
|
+
from .file_transfer import _handle_file_put_default as handle_file_put_default
|
|
11
|
+
from .file_transfer import _save_file_put as save_file_put
|
|
12
|
+
from .media import _handle_media_group as handle_media_group
|
|
13
|
+
from .menu import _reserved_commands as get_reserved_commands
|
|
14
|
+
from .menu import _set_command_menu as set_command_menu
|
|
15
|
+
from .model import _handle_model_command as handle_model_command
|
|
16
|
+
from .parse import _parse_slash_command as parse_slash_command
|
|
17
|
+
from .reasoning import _handle_reasoning_command as handle_reasoning_command
|
|
18
|
+
from .topics import _handle_chat_new_command as handle_chat_new_command
|
|
19
|
+
from .topics import _handle_chat_ctx_command as handle_chat_ctx_command
|
|
20
|
+
from .topics import _handle_ctx_command as handle_ctx_command
|
|
21
|
+
from .topics import _handle_new_command as handle_new_command
|
|
22
|
+
from .topics import _handle_topic_command as handle_topic_command
|
|
23
|
+
from .trigger import _handle_trigger_command as handle_trigger_command
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"dispatch_command",
|
|
27
|
+
"get_reserved_commands",
|
|
28
|
+
"handle_agent_command",
|
|
29
|
+
"handle_chat_ctx_command",
|
|
30
|
+
"handle_chat_new_command",
|
|
31
|
+
"handle_ctx_command",
|
|
32
|
+
"handle_file_command",
|
|
33
|
+
"handle_file_put_default",
|
|
34
|
+
"handle_media_group",
|
|
35
|
+
"handle_model_command",
|
|
36
|
+
"handle_new_command",
|
|
37
|
+
"handle_reasoning_command",
|
|
38
|
+
"handle_topic_command",
|
|
39
|
+
"handle_trigger_command",
|
|
40
|
+
"parse_slash_command",
|
|
41
|
+
"run_engine",
|
|
42
|
+
"save_file_put",
|
|
43
|
+
"set_command_menu",
|
|
44
|
+
"should_show_resume_line",
|
|
45
|
+
]
|