AstrBot 4.10.2__py3-none-any.whl → 4.10.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
  2. astrbot/builtin_stars/astrbot/main.py +120 -0
  3. astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
  4. astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
  5. astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
  6. astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
  7. astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
  8. astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
  9. astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
  10. astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
  11. astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
  12. astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
  13. astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
  14. astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
  15. astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
  16. astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
  17. astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
  18. astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
  19. astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
  20. astrbot/builtin_stars/builtin_commands/main.py +237 -0
  21. astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
  22. astrbot/builtin_stars/python_interpreter/main.py +536 -0
  23. astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
  24. astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
  25. astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
  26. astrbot/builtin_stars/reminder/main.py +266 -0
  27. astrbot/builtin_stars/reminder/metadata.yaml +4 -0
  28. astrbot/builtin_stars/session_controller/main.py +114 -0
  29. astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
  30. astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
  31. astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
  32. astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
  33. astrbot/builtin_stars/web_searcher/main.py +436 -0
  34. astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
  35. astrbot/cli/__init__.py +1 -1
  36. astrbot/core/agent/message.py +32 -1
  37. astrbot/core/agent/runners/tool_loop_agent_runner.py +26 -8
  38. astrbot/core/astr_agent_hooks.py +6 -0
  39. astrbot/core/backup/__init__.py +26 -0
  40. astrbot/core/backup/constants.py +77 -0
  41. astrbot/core/backup/exporter.py +477 -0
  42. astrbot/core/backup/importer.py +761 -0
  43. astrbot/core/config/astrbot_config.py +2 -0
  44. astrbot/core/config/default.py +47 -6
  45. astrbot/core/knowledge_base/chunking/recursive.py +10 -2
  46. astrbot/core/log.py +1 -1
  47. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
  48. astrbot/core/pipeline/result_decorate/stage.py +65 -57
  49. astrbot/core/pipeline/waking_check/stage.py +31 -3
  50. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
  51. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
  52. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
  53. astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
  54. astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
  55. astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
  56. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
  57. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
  58. astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
  59. astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
  60. astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
  61. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
  62. astrbot/core/provider/entities.py +41 -10
  63. astrbot/core/provider/provider.py +3 -1
  64. astrbot/core/provider/sources/anthropic_source.py +140 -30
  65. astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
  66. astrbot/core/provider/sources/gemini_source.py +112 -29
  67. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
  68. astrbot/core/provider/sources/openai_source.py +93 -56
  69. astrbot/core/provider/sources/xai_source.py +29 -0
  70. astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
  71. astrbot/core/star/context.py +1 -1
  72. astrbot/core/star/star_manager.py +52 -13
  73. astrbot/core/utils/astrbot_path.py +34 -0
  74. astrbot/core/utils/pip_installer.py +20 -1
  75. astrbot/dashboard/routes/__init__.py +2 -0
  76. astrbot/dashboard/routes/backup.py +1093 -0
  77. astrbot/dashboard/routes/config.py +45 -0
  78. astrbot/dashboard/routes/log.py +44 -10
  79. astrbot/dashboard/server.py +9 -1
  80. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
  81. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/RECORD +84 -44
  82. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
  83. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
  84. {astrbot-4.10.2.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1093 @@
1
+ """备份管理 API 路由"""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import time
9
+ import traceback
10
+ import uuid
11
+ import zipfile
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ import jwt
16
+ from quart import request, send_file
17
+
18
+ from astrbot.core import logger
19
+ from astrbot.core.backup.exporter import AstrBotExporter
20
+ from astrbot.core.backup.importer import AstrBotImporter
21
+ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
22
+ from astrbot.core.db import BaseDatabase
23
+ from astrbot.core.utils.astrbot_path import (
24
+ get_astrbot_backups_path,
25
+ get_astrbot_data_path,
26
+ )
27
+
28
+ from .route import Response, Route, RouteContext
29
+
30
+ # 分片上传常量
31
+ CHUNK_SIZE = 1024 * 1024 # 1MB
32
+ UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时)
33
+
34
+
35
+ def secure_filename(filename: str) -> str:
36
+ """清洗文件名,移除路径遍历字符和危险字符
37
+
38
+ Args:
39
+ filename: 原始文件名
40
+
41
+ Returns:
42
+ 安全的文件名
43
+ """
44
+ # 跨平台处理:先将反斜杠替换为正斜杠,再取文件名
45
+ filename = filename.replace("\\", "/")
46
+ # 仅保留文件名部分,移除路径
47
+ filename = os.path.basename(filename)
48
+
49
+ # 替换路径遍历字符
50
+ filename = filename.replace("..", "_")
51
+
52
+ # 仅保留字母、数字、下划线、连字符、点
53
+ filename = re.sub(r"[^\w\-.]", "_", filename)
54
+
55
+ # 移除前导点(隐藏文件)和尾部点
56
+ filename = filename.strip(".")
57
+
58
+ # 如果文件名为空或只包含下划线,生成一个默认名称
59
+ if not filename or filename.replace("_", "") == "":
60
+ filename = "backup"
61
+
62
+ return filename
63
+
64
+
65
+ def generate_unique_filename(original_filename: str) -> str:
66
+ """生成唯一的文件名,在原文件名后添加时间戳后缀避免重名
67
+
68
+ Args:
69
+ original_filename: 原始文件名(已清洗)
70
+
71
+ Returns:
72
+ 添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名}
73
+ """
74
+ name, ext = os.path.splitext(original_filename)
75
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
76
+ return f"{name}_{timestamp}{ext}"
77
+
78
+
79
+ class BackupRoute(Route):
80
+ """备份管理路由
81
+
82
+ 提供备份导出、导入、列表等 API 接口
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ context: RouteContext,
88
+ db: BaseDatabase,
89
+ core_lifecycle: AstrBotCoreLifecycle,
90
+ ) -> None:
91
+ super().__init__(context)
92
+ self.db = db
93
+ self.core_lifecycle = core_lifecycle
94
+ self.backup_dir = get_astrbot_backups_path()
95
+ self.data_dir = get_astrbot_data_path()
96
+ self.chunks_dir = os.path.join(self.backup_dir, ".chunks")
97
+
98
+ # 任务状态跟踪
99
+ self.backup_tasks: dict[str, dict] = {}
100
+ self.backup_progress: dict[str, dict] = {}
101
+
102
+ # 分片上传会话跟踪
103
+ # upload_id -> {filename, total_chunks, received_chunks, last_activity, chunk_dir}
104
+ self.upload_sessions: dict[str, dict] = {}
105
+
106
+ # 后台清理任务句柄
107
+ self._cleanup_task: asyncio.Task | None = None
108
+
109
+ # 注册路由
110
+ self.routes = {
111
+ "/backup/list": ("GET", self.list_backups),
112
+ "/backup/export": ("POST", self.export_backup),
113
+ "/backup/upload": ("POST", self.upload_backup), # 上传文件(兼容小文件)
114
+ "/backup/upload/init": ("POST", self.upload_init), # 分片上传初始化
115
+ "/backup/upload/chunk": ("POST", self.upload_chunk), # 上传分片
116
+ "/backup/upload/complete": ("POST", self.upload_complete), # 完成分片上传
117
+ "/backup/upload/abort": ("POST", self.upload_abort), # 取消上传
118
+ "/backup/check": ("POST", self.check_backup), # 预检查
119
+ "/backup/import": ("POST", self.import_backup), # 确认导入
120
+ "/backup/progress": ("GET", self.get_progress),
121
+ "/backup/download": ("GET", self.download_backup),
122
+ "/backup/delete": ("POST", self.delete_backup),
123
+ "/backup/rename": ("POST", self.rename_backup), # 重命名备份
124
+ }
125
+ self.register_routes()
126
+
127
+ def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None:
128
+ """初始化任务状态"""
129
+ self.backup_tasks[task_id] = {
130
+ "type": task_type,
131
+ "status": status,
132
+ "result": None,
133
+ "error": None,
134
+ }
135
+ self.backup_progress[task_id] = {
136
+ "status": status,
137
+ "stage": "waiting",
138
+ "current": 0,
139
+ "total": 100,
140
+ "message": "",
141
+ }
142
+
143
+ def _set_task_result(
144
+ self,
145
+ task_id: str,
146
+ status: str,
147
+ result: dict | None = None,
148
+ error: str | None = None,
149
+ ) -> None:
150
+ """设置任务结果"""
151
+ if task_id in self.backup_tasks:
152
+ self.backup_tasks[task_id]["status"] = status
153
+ self.backup_tasks[task_id]["result"] = result
154
+ self.backup_tasks[task_id]["error"] = error
155
+ if task_id in self.backup_progress:
156
+ self.backup_progress[task_id]["status"] = status
157
+
158
+ def _update_progress(
159
+ self,
160
+ task_id: str,
161
+ *,
162
+ status: str | None = None,
163
+ stage: str | None = None,
164
+ current: int | None = None,
165
+ total: int | None = None,
166
+ message: str | None = None,
167
+ ) -> None:
168
+ """更新任务进度"""
169
+ if task_id not in self.backup_progress:
170
+ return
171
+ p = self.backup_progress[task_id]
172
+ if status is not None:
173
+ p["status"] = status
174
+ if stage is not None:
175
+ p["stage"] = stage
176
+ if current is not None:
177
+ p["current"] = current
178
+ if total is not None:
179
+ p["total"] = total
180
+ if message is not None:
181
+ p["message"] = message
182
+
183
+ def _make_progress_callback(self, task_id: str):
184
+ """创建进度回调函数"""
185
+
186
+ async def _callback(stage: str, current: int, total: int, message: str = ""):
187
+ self._update_progress(
188
+ task_id,
189
+ status="processing",
190
+ stage=stage,
191
+ current=current,
192
+ total=total,
193
+ message=message,
194
+ )
195
+
196
+ return _callback
197
+
198
+ def _ensure_cleanup_task_started(self):
199
+ """确保后台清理任务已启动(在异步上下文中延迟启动)"""
200
+ if self._cleanup_task is None or self._cleanup_task.done():
201
+ try:
202
+ self._cleanup_task = asyncio.create_task(
203
+ self._cleanup_expired_uploads()
204
+ )
205
+ except RuntimeError:
206
+ # 如果没有运行中的事件循环,跳过(等待下次异步调用时启动)
207
+ pass
208
+
209
+ async def _cleanup_expired_uploads(self):
210
+ """定期清理过期的上传会话
211
+
212
+ 基于 last_activity 字段判断过期,避免清理活跃的上传会话。
213
+ """
214
+ while True:
215
+ try:
216
+ await asyncio.sleep(300) # 每5分钟检查一次
217
+ current_time = time.time()
218
+ expired_sessions = []
219
+
220
+ for upload_id, session in self.upload_sessions.items():
221
+ # 使用 last_activity 判断过期,而非 created_at
222
+ last_activity = session.get("last_activity", session["created_at"])
223
+ if current_time - last_activity > UPLOAD_EXPIRE_SECONDS:
224
+ expired_sessions.append(upload_id)
225
+
226
+ for upload_id in expired_sessions:
227
+ await self._cleanup_upload_session(upload_id)
228
+ logger.info(f"清理过期的上传会话: {upload_id}")
229
+
230
+ except asyncio.CancelledError:
231
+ # 任务被取消,正常退出
232
+ break
233
+ except Exception as e:
234
+ logger.error(f"清理过期上传会话失败: {e}")
235
+
236
+ async def _cleanup_upload_session(self, upload_id: str):
237
+ """清理上传会话"""
238
+ if upload_id in self.upload_sessions:
239
+ session = self.upload_sessions[upload_id]
240
+ chunk_dir = session.get("chunk_dir")
241
+ if chunk_dir and os.path.exists(chunk_dir):
242
+ try:
243
+ shutil.rmtree(chunk_dir)
244
+ except Exception as e:
245
+ logger.warning(f"清理分片目录失败: {e}")
246
+ del self.upload_sessions[upload_id]
247
+
248
+ def _get_backup_manifest(self, zip_path: str) -> dict | None:
249
+ """从备份文件读取 manifest.json
250
+
251
+ Args:
252
+ zip_path: ZIP 文件路径
253
+
254
+ Returns:
255
+ dict | None: manifest 内容,如果不是有效备份则返回 None
256
+ """
257
+ try:
258
+ with zipfile.ZipFile(zip_path, "r") as zf:
259
+ if "manifest.json" in zf.namelist():
260
+ manifest_data = zf.read("manifest.json")
261
+ return json.loads(manifest_data.decode("utf-8"))
262
+ else:
263
+ # 没有 manifest.json,不是有效的 AstrBot 备份
264
+ return None
265
+ except Exception as e:
266
+ logger.debug(f"读取备份 manifest 失败: {e}")
267
+ return None # 无法读取,不是有效备份
268
+
269
+ async def list_backups(self):
270
+ # 确保后台清理任务已启动
271
+ self._ensure_cleanup_task_started()
272
+
273
+ """获取备份列表
274
+
275
+ Query 参数:
276
+ - page: 页码 (默认 1)
277
+ - page_size: 每页数量 (默认 20)
278
+ """
279
+ try:
280
+ page = request.args.get("page", 1, type=int)
281
+ page_size = request.args.get("page_size", 20, type=int)
282
+
283
+ # 确保备份目录存在
284
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
285
+
286
+ # 获取所有备份文件
287
+ backup_files = []
288
+ for filename in os.listdir(self.backup_dir):
289
+ # 只处理 .zip 文件,排除隐藏文件和目录
290
+ if not filename.endswith(".zip") or filename.startswith("."):
291
+ continue
292
+
293
+ file_path = os.path.join(self.backup_dir, filename)
294
+ if not os.path.isfile(file_path):
295
+ continue
296
+
297
+ # 读取 manifest.json 获取备份信息
298
+ # 如果返回 None,说明不是有效的 AstrBot 备份,跳过
299
+ manifest = self._get_backup_manifest(file_path)
300
+ if manifest is None:
301
+ logger.debug(f"跳过无效备份文件: {filename}")
302
+ continue
303
+
304
+ stat = os.stat(file_path)
305
+ backup_files.append(
306
+ {
307
+ "filename": filename,
308
+ "size": stat.st_size,
309
+ "created_at": stat.st_mtime,
310
+ "type": manifest.get(
311
+ "origin", "exported"
312
+ ), # 老版本没有 origin 默认为 exported
313
+ "astrbot_version": manifest.get("astrbot_version", "未知"),
314
+ "exported_at": manifest.get("exported_at"),
315
+ }
316
+ )
317
+
318
+ # 按创建时间倒序排序
319
+ backup_files.sort(key=lambda x: x["created_at"], reverse=True)
320
+
321
+ # 分页
322
+ start = (page - 1) * page_size
323
+ end = start + page_size
324
+ items = backup_files[start:end]
325
+
326
+ return (
327
+ Response()
328
+ .ok(
329
+ {
330
+ "items": items,
331
+ "total": len(backup_files),
332
+ "page": page,
333
+ "page_size": page_size,
334
+ }
335
+ )
336
+ .__dict__
337
+ )
338
+ except Exception as e:
339
+ logger.error(f"获取备份列表失败: {e}")
340
+ logger.error(traceback.format_exc())
341
+ return Response().error(f"获取备份列表失败: {e!s}").__dict__
342
+
343
+ async def export_backup(self):
344
+ """创建备份
345
+
346
+ 返回:
347
+ - task_id: 任务ID,用于查询导出进度
348
+ """
349
+ try:
350
+ # 生成任务ID
351
+ task_id = str(uuid.uuid4())
352
+
353
+ # 初始化任务状态
354
+ self._init_task(task_id, "export", "pending")
355
+
356
+ # 启动后台导出任务
357
+ asyncio.create_task(self._background_export_task(task_id))
358
+
359
+ return (
360
+ Response()
361
+ .ok(
362
+ {
363
+ "task_id": task_id,
364
+ "message": "export task created, processing in background",
365
+ }
366
+ )
367
+ .__dict__
368
+ )
369
+ except Exception as e:
370
+ logger.error(f"创建备份失败: {e}")
371
+ logger.error(traceback.format_exc())
372
+ return Response().error(f"创建备份失败: {e!s}").__dict__
373
+
374
+ async def _background_export_task(self, task_id: str):
375
+ """后台导出任务"""
376
+ try:
377
+ self._update_progress(task_id, status="processing", message="正在初始化...")
378
+
379
+ # 获取知识库管理器
380
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
381
+
382
+ exporter = AstrBotExporter(
383
+ main_db=self.db,
384
+ kb_manager=kb_manager,
385
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
386
+ )
387
+
388
+ # 创建进度回调
389
+ progress_callback = self._make_progress_callback(task_id)
390
+
391
+ # 执行导出
392
+ zip_path = await exporter.export_all(
393
+ output_dir=self.backup_dir,
394
+ progress_callback=progress_callback,
395
+ )
396
+
397
+ # 设置成功结果
398
+ self._set_task_result(
399
+ task_id,
400
+ "completed",
401
+ result={
402
+ "filename": os.path.basename(zip_path),
403
+ "path": zip_path,
404
+ "size": os.path.getsize(zip_path),
405
+ },
406
+ )
407
+ except Exception as e:
408
+ logger.error(f"后台导出任务 {task_id} 失败: {e}")
409
+ logger.error(traceback.format_exc())
410
+ self._set_task_result(task_id, "failed", error=str(e))
411
+
412
+ async def upload_backup(self):
413
+ """上传备份文件
414
+
415
+ 将备份文件上传到服务器,返回保存的文件名。
416
+ 上传后应调用 check_backup 进行预检查。
417
+
418
+ Form Data:
419
+ - file: 备份文件 (.zip)
420
+
421
+ 返回:
422
+ - filename: 保存的文件名
423
+ """
424
+ try:
425
+ files = await request.files
426
+ if "file" not in files:
427
+ return Response().error("缺少备份文件").__dict__
428
+
429
+ file = files["file"]
430
+ if not file.filename or not file.filename.endswith(".zip"):
431
+ return Response().error("请上传 ZIP 格式的备份文件").__dict__
432
+
433
+ # 清洗文件名并生成唯一名称,防止路径遍历和覆盖
434
+ safe_filename = secure_filename(file.filename)
435
+ unique_filename = generate_unique_filename(safe_filename)
436
+
437
+ # 保存上传的文件
438
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
439
+ zip_path = os.path.join(self.backup_dir, unique_filename)
440
+ await file.save(zip_path)
441
+
442
+ logger.info(
443
+ f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})"
444
+ )
445
+
446
+ return (
447
+ Response()
448
+ .ok(
449
+ {
450
+ "filename": unique_filename,
451
+ "original_filename": file.filename,
452
+ "size": os.path.getsize(zip_path),
453
+ }
454
+ )
455
+ .__dict__
456
+ )
457
+ except Exception as e:
458
+ logger.error(f"上传备份文件失败: {e}")
459
+ logger.error(traceback.format_exc())
460
+ return Response().error(f"上传备份文件失败: {e!s}").__dict__
461
+
462
+ async def upload_init(self):
463
+ """初始化分片上传
464
+
465
+ 创建一个上传会话,返回 upload_id 供后续分片上传使用。
466
+
467
+ JSON Body:
468
+ - filename: 原始文件名
469
+ - total_size: 文件总大小(字节)
470
+
471
+ 返回:
472
+ - upload_id: 上传会话 ID
473
+ - chunk_size: 分片大小(由后端决定)
474
+ - total_chunks: 分片总数(由后端根据 total_size 和 chunk_size 计算)
475
+ """
476
+ try:
477
+ data = await request.json
478
+ filename = data.get("filename")
479
+ total_size = data.get("total_size", 0)
480
+
481
+ if not filename:
482
+ return Response().error("缺少 filename 参数").__dict__
483
+
484
+ if not filename.endswith(".zip"):
485
+ return Response().error("请上传 ZIP 格式的备份文件").__dict__
486
+
487
+ if total_size <= 0:
488
+ return Response().error("无效的文件大小").__dict__
489
+
490
+ # 由后端计算分片总数,确保前后端一致
491
+ import math
492
+
493
+ total_chunks = math.ceil(total_size / CHUNK_SIZE)
494
+
495
+ # 生成上传 ID
496
+ upload_id = str(uuid.uuid4())
497
+
498
+ # 创建分片存储目录
499
+ chunk_dir = os.path.join(self.chunks_dir, upload_id)
500
+ Path(chunk_dir).mkdir(parents=True, exist_ok=True)
501
+
502
+ # 清洗文件名
503
+ safe_filename = secure_filename(filename)
504
+ unique_filename = generate_unique_filename(safe_filename)
505
+
506
+ # 创建上传会话
507
+ current_time = time.time()
508
+ self.upload_sessions[upload_id] = {
509
+ "filename": unique_filename,
510
+ "original_filename": filename,
511
+ "total_size": total_size,
512
+ "total_chunks": total_chunks,
513
+ "received_chunks": set(),
514
+ "created_at": current_time,
515
+ "last_activity": current_time, # 用于判断会话是否活跃
516
+ "chunk_dir": chunk_dir,
517
+ }
518
+
519
+ logger.info(
520
+ f"初始化分片上传: upload_id={upload_id}, "
521
+ f"filename={unique_filename}, total_chunks={total_chunks}"
522
+ )
523
+
524
+ return (
525
+ Response()
526
+ .ok(
527
+ {
528
+ "upload_id": upload_id,
529
+ "chunk_size": CHUNK_SIZE,
530
+ "total_chunks": total_chunks,
531
+ "filename": unique_filename,
532
+ }
533
+ )
534
+ .__dict__
535
+ )
536
+ except Exception as e:
537
+ logger.error(f"初始化分片上传失败: {e}")
538
+ logger.error(traceback.format_exc())
539
+ return Response().error(f"初始化分片上传失败: {e!s}").__dict__
540
+
541
+ async def upload_chunk(self):
542
+ """上传分片
543
+
544
+ 上传单个分片数据。
545
+
546
+ Form Data:
547
+ - upload_id: 上传会话 ID
548
+ - chunk_index: 分片索引(从 0 开始)
549
+ - chunk: 分片数据
550
+
551
+ 返回:
552
+ - received: 已接收的分片数量
553
+ - total: 分片总数
554
+ """
555
+ try:
556
+ form = await request.form
557
+ files = await request.files
558
+
559
+ upload_id = form.get("upload_id")
560
+ chunk_index_str = form.get("chunk_index")
561
+
562
+ if not upload_id or chunk_index_str is None:
563
+ return Response().error("缺少必要参数").__dict__
564
+
565
+ try:
566
+ chunk_index = int(chunk_index_str)
567
+ except ValueError:
568
+ return Response().error("无效的分片索引").__dict__
569
+
570
+ if "chunk" not in files:
571
+ return Response().error("缺少分片数据").__dict__
572
+
573
+ # 验证上传会话
574
+ if upload_id not in self.upload_sessions:
575
+ return Response().error("上传会话不存在或已过期").__dict__
576
+
577
+ session = self.upload_sessions[upload_id]
578
+
579
+ # 验证分片索引
580
+ if chunk_index < 0 or chunk_index >= session["total_chunks"]:
581
+ return Response().error("分片索引超出范围").__dict__
582
+
583
+ # 保存分片
584
+ chunk_file = files["chunk"]
585
+ chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part")
586
+ await chunk_file.save(chunk_path)
587
+
588
+ # 记录已接收的分片,并更新最后活动时间
589
+ session["received_chunks"].add(chunk_index)
590
+ session["last_activity"] = time.time() # 刷新活动时间,防止活跃上传被清理
591
+
592
+ received_count = len(session["received_chunks"])
593
+ total_chunks = session["total_chunks"]
594
+
595
+ logger.debug(
596
+ f"接收分片: upload_id={upload_id}, "
597
+ f"chunk={chunk_index + 1}/{total_chunks}"
598
+ )
599
+
600
+ return (
601
+ Response()
602
+ .ok(
603
+ {
604
+ "received": received_count,
605
+ "total": total_chunks,
606
+ "chunk_index": chunk_index,
607
+ }
608
+ )
609
+ .__dict__
610
+ )
611
+ except Exception as e:
612
+ logger.error(f"上传分片失败: {e}")
613
+ logger.error(traceback.format_exc())
614
+ return Response().error(f"上传分片失败: {e!s}").__dict__
615
+
616
+ def _mark_backup_as_uploaded(self, zip_path: str) -> None:
617
+ """修改备份文件的 manifest.json,将 origin 设置为 uploaded
618
+
619
+ 使用 zipfile 的 append 模式添加新的 manifest.json,
620
+ ZIP 规范中后添加的同名文件会覆盖先前的文件。
621
+
622
+ Args:
623
+ zip_path: ZIP 文件路径
624
+ """
625
+ try:
626
+ # 读取原有 manifest
627
+ manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()}
628
+ with zipfile.ZipFile(zip_path, "r") as zf:
629
+ if "manifest.json" in zf.namelist():
630
+ manifest_data = zf.read("manifest.json")
631
+ manifest = json.loads(manifest_data.decode("utf-8"))
632
+ manifest["origin"] = "uploaded"
633
+ manifest["uploaded_at"] = datetime.now().isoformat()
634
+
635
+ # 使用 append 模式添加新的 manifest.json
636
+ # ZIP 规范中,后添加的同名文件会覆盖先前的
637
+ with zipfile.ZipFile(zip_path, "a") as zf:
638
+ new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2)
639
+ zf.writestr("manifest.json", new_manifest)
640
+
641
+ logger.debug(f"已标记备份为上传来源: {zip_path}")
642
+ except Exception as e:
643
+ logger.warning(f"标记备份来源失败: {e}")
644
+
645
+ async def upload_complete(self):
646
+ """完成分片上传
647
+
648
+ 合并所有分片为完整文件。
649
+
650
+ JSON Body:
651
+ - upload_id: 上传会话 ID
652
+
653
+ 返回:
654
+ - filename: 合并后的文件名
655
+ - size: 文件大小
656
+ """
657
+ try:
658
+ data = await request.json
659
+ upload_id = data.get("upload_id")
660
+
661
+ if not upload_id:
662
+ return Response().error("缺少 upload_id 参数").__dict__
663
+
664
+ # 验证上传会话
665
+ if upload_id not in self.upload_sessions:
666
+ return Response().error("上传会话不存在或已过期").__dict__
667
+
668
+ session = self.upload_sessions[upload_id]
669
+
670
+ # 检查是否所有分片都已接收
671
+ received = session["received_chunks"]
672
+ total = session["total_chunks"]
673
+
674
+ if len(received) != total:
675
+ missing = set(range(total)) - received
676
+ return (
677
+ Response()
678
+ .error(f"分片不完整,缺少: {sorted(missing)[:10]}...")
679
+ .__dict__
680
+ )
681
+
682
+ # 合并分片
683
+ chunk_dir = session["chunk_dir"]
684
+ filename = session["filename"]
685
+
686
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
687
+ output_path = os.path.join(self.backup_dir, filename)
688
+
689
+ try:
690
+ with open(output_path, "wb") as outfile:
691
+ for i in range(total):
692
+ chunk_path = os.path.join(chunk_dir, f"{i}.part")
693
+ with open(chunk_path, "rb") as chunk_file:
694
+ # 分块读取,避免内存溢出
695
+ while True:
696
+ data_block = chunk_file.read(8192)
697
+ if not data_block:
698
+ break
699
+ outfile.write(data_block)
700
+
701
+ file_size = os.path.getsize(output_path)
702
+
703
+ # 标记备份为上传来源(修改 manifest.json 中的 origin 字段)
704
+ self._mark_backup_as_uploaded(output_path)
705
+
706
+ logger.info(
707
+ f"分片上传完成: {filename}, size={file_size}, chunks={total}"
708
+ )
709
+
710
+ # 清理分片目录
711
+ await self._cleanup_upload_session(upload_id)
712
+
713
+ return (
714
+ Response()
715
+ .ok(
716
+ {
717
+ "filename": filename,
718
+ "original_filename": session["original_filename"],
719
+ "size": file_size,
720
+ }
721
+ )
722
+ .__dict__
723
+ )
724
+ except Exception as e:
725
+ # 如果合并失败,删除不完整的文件
726
+ if os.path.exists(output_path):
727
+ os.remove(output_path)
728
+ raise e
729
+
730
+ except Exception as e:
731
+ logger.error(f"完成分片上传失败: {e}")
732
+ logger.error(traceback.format_exc())
733
+ return Response().error(f"完成分片上传失败: {e!s}").__dict__
734
+
735
+ async def upload_abort(self):
736
+ """取消分片上传
737
+
738
+ 取消上传并清理已上传的分片。
739
+
740
+ JSON Body:
741
+ - upload_id: 上传会话 ID
742
+ """
743
+ try:
744
+ data = await request.json
745
+ upload_id = data.get("upload_id")
746
+
747
+ if not upload_id:
748
+ return Response().error("缺少 upload_id 参数").__dict__
749
+
750
+ if upload_id not in self.upload_sessions:
751
+ # 会话已不存在,可能已过期或已完成
752
+ return Response().ok(message="上传已取消").__dict__
753
+
754
+ # 清理会话
755
+ await self._cleanup_upload_session(upload_id)
756
+
757
+ logger.info(f"取消分片上传: {upload_id}")
758
+
759
+ return Response().ok(message="上传已取消").__dict__
760
+ except Exception as e:
761
+ logger.error(f"取消上传失败: {e}")
762
+ logger.error(traceback.format_exc())
763
+ return Response().error(f"取消上传失败: {e!s}").__dict__
764
+
765
+ async def check_backup(self):
766
+ """预检查备份文件
767
+
768
+ 检查备份文件的版本兼容性,返回确认信息。
769
+ 用户确认后调用 import_backup 执行导入。
770
+
771
+ JSON Body:
772
+ - filename: 已上传的备份文件名
773
+
774
+ 返回:
775
+ - ImportPreCheckResult: 预检查结果
776
+ """
777
+ try:
778
+ data = await request.json
779
+ filename = data.get("filename")
780
+ if not filename:
781
+ return Response().error("缺少 filename 参数").__dict__
782
+
783
+ # 安全检查 - 防止路径遍历
784
+ if ".." in filename or "/" in filename or "\\" in filename:
785
+ return Response().error("无效的文件名").__dict__
786
+
787
+ zip_path = os.path.join(self.backup_dir, filename)
788
+ if not os.path.exists(zip_path):
789
+ return Response().error(f"备份文件不存在: {filename}").__dict__
790
+
791
+ # 获取知识库管理器(用于构造 importer)
792
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
793
+
794
+ importer = AstrBotImporter(
795
+ main_db=self.db,
796
+ kb_manager=kb_manager,
797
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
798
+ )
799
+
800
+ # 执行预检查
801
+ check_result = importer.pre_check(zip_path)
802
+
803
+ return Response().ok(check_result.to_dict()).__dict__
804
+ except Exception as e:
805
+ logger.error(f"预检查备份文件失败: {e}")
806
+ logger.error(traceback.format_exc())
807
+ return Response().error(f"预检查备份文件失败: {e!s}").__dict__
808
+
809
+ async def import_backup(self):
810
+ """执行备份导入
811
+
812
+ 在用户确认后执行实际的导入操作。
813
+ 需要先调用 upload_backup 上传文件,再调用 check_backup 预检查。
814
+
815
+ JSON Body:
816
+ - filename: 已上传的备份文件名(必填)
817
+ - confirmed: 用户已确认(必填,必须为 true)
818
+
819
+ 返回:
820
+ - task_id: 任务ID,用于查询导入进度
821
+ """
822
+ try:
823
+ data = await request.json
824
+ filename = data.get("filename")
825
+ confirmed = data.get("confirmed", False)
826
+
827
+ if not filename:
828
+ return Response().error("缺少 filename 参数").__dict__
829
+
830
+ if not confirmed:
831
+ return (
832
+ Response()
833
+ .error("请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。")
834
+ .__dict__
835
+ )
836
+
837
+ # 安全检查 - 防止路径遍历
838
+ if ".." in filename or "/" in filename or "\\" in filename:
839
+ return Response().error("无效的文件名").__dict__
840
+
841
+ zip_path = os.path.join(self.backup_dir, filename)
842
+ if not os.path.exists(zip_path):
843
+ return Response().error(f"备份文件不存在: {filename}").__dict__
844
+
845
+ # 生成任务ID
846
+ task_id = str(uuid.uuid4())
847
+
848
+ # 初始化任务状态
849
+ self._init_task(task_id, "import", "pending")
850
+
851
+ # 启动后台导入任务
852
+ asyncio.create_task(self._background_import_task(task_id, zip_path))
853
+
854
+ return (
855
+ Response()
856
+ .ok(
857
+ {
858
+ "task_id": task_id,
859
+ "message": "import task created, processing in background",
860
+ }
861
+ )
862
+ .__dict__
863
+ )
864
+ except Exception as e:
865
+ logger.error(f"导入备份失败: {e}")
866
+ logger.error(traceback.format_exc())
867
+ return Response().error(f"导入备份失败: {e!s}").__dict__
868
+
869
+ async def _background_import_task(self, task_id: str, zip_path: str):
870
+ """后台导入任务"""
871
+ try:
872
+ self._update_progress(task_id, status="processing", message="正在初始化...")
873
+
874
+ # 获取知识库管理器
875
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
876
+
877
+ importer = AstrBotImporter(
878
+ main_db=self.db,
879
+ kb_manager=kb_manager,
880
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
881
+ )
882
+
883
+ # 创建进度回调
884
+ progress_callback = self._make_progress_callback(task_id)
885
+
886
+ # 执行导入
887
+ result = await importer.import_all(
888
+ zip_path=zip_path,
889
+ mode="replace",
890
+ progress_callback=progress_callback,
891
+ )
892
+
893
+ # 设置结果
894
+ if result.success:
895
+ self._set_task_result(
896
+ task_id,
897
+ "completed",
898
+ result=result.to_dict(),
899
+ )
900
+ else:
901
+ self._set_task_result(
902
+ task_id,
903
+ "failed",
904
+ error="; ".join(result.errors),
905
+ )
906
+ except Exception as e:
907
+ logger.error(f"后台导入任务 {task_id} 失败: {e}")
908
+ logger.error(traceback.format_exc())
909
+ self._set_task_result(task_id, "failed", error=str(e))
910
+
911
+ async def get_progress(self):
912
+ """获取任务进度
913
+
914
+ Query 参数:
915
+ - task_id: 任务 ID (必填)
916
+ """
917
+ try:
918
+ task_id = request.args.get("task_id")
919
+ if not task_id:
920
+ return Response().error("缺少参数 task_id").__dict__
921
+
922
+ if task_id not in self.backup_tasks:
923
+ return Response().error("找不到该任务").__dict__
924
+
925
+ task_info = self.backup_tasks[task_id]
926
+ status = task_info["status"]
927
+
928
+ response_data = {
929
+ "task_id": task_id,
930
+ "type": task_info["type"],
931
+ "status": status,
932
+ }
933
+
934
+ # 如果任务正在处理,返回进度信息
935
+ if status == "processing" and task_id in self.backup_progress:
936
+ response_data["progress"] = self.backup_progress[task_id]
937
+
938
+ # 如果任务完成,返回结果
939
+ if status == "completed":
940
+ response_data["result"] = task_info["result"]
941
+
942
+ # 如果任务失败,返回错误信息
943
+ if status == "failed":
944
+ response_data["error"] = task_info["error"]
945
+
946
+ return Response().ok(response_data).__dict__
947
+ except Exception as e:
948
+ logger.error(f"获取任务进度失败: {e}")
949
+ logger.error(traceback.format_exc())
950
+ return Response().error(f"获取任务进度失败: {e!s}").__dict__
951
+
952
+ async def download_backup(self):
953
+ """下载备份文件
954
+
955
+ Query 参数:
956
+ - filename: 备份文件名 (必填)
957
+ - token: JWT token (必填,用于浏览器原生下载鉴权)
958
+
959
+ 注意: 此路由已被添加到 auth_middleware 白名单中,
960
+ 使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。
961
+ """
962
+ try:
963
+ filename = request.args.get("filename")
964
+ token = request.args.get("token")
965
+
966
+ if not filename:
967
+ return Response().error("缺少参数 filename").__dict__
968
+
969
+ if not token:
970
+ return Response().error("缺少参数 token").__dict__
971
+
972
+ # 验证 JWT token
973
+ try:
974
+ jwt_secret = self.config.get("dashboard", {}).get("jwt_secret")
975
+ if not jwt_secret:
976
+ return Response().error("服务器配置错误").__dict__
977
+
978
+ jwt.decode(token, jwt_secret, algorithms=["HS256"])
979
+ except jwt.ExpiredSignatureError:
980
+ return Response().error("Token 已过期,请刷新页面后重试").__dict__
981
+ except jwt.InvalidTokenError:
982
+ return Response().error("Token 无效").__dict__
983
+
984
+ # 安全检查 - 防止路径遍历
985
+ if ".." in filename or "/" in filename or "\\" in filename:
986
+ return Response().error("无效的文件名").__dict__
987
+
988
+ file_path = os.path.join(self.backup_dir, filename)
989
+ if not os.path.exists(file_path):
990
+ return Response().error("备份文件不存在").__dict__
991
+
992
+ return await send_file(
993
+ file_path,
994
+ as_attachment=True,
995
+ attachment_filename=filename,
996
+ )
997
+ except Exception as e:
998
+ logger.error(f"下载备份失败: {e}")
999
+ logger.error(traceback.format_exc())
1000
+ return Response().error(f"下载备份失败: {e!s}").__dict__
1001
+
1002
+ async def delete_backup(self):
1003
+ """删除备份文件
1004
+
1005
+ Body:
1006
+ - filename: 备份文件名 (必填)
1007
+ """
1008
+ try:
1009
+ data = await request.json
1010
+ filename = data.get("filename")
1011
+ if not filename:
1012
+ return Response().error("缺少参数 filename").__dict__
1013
+
1014
+ # 安全检查 - 防止路径遍历
1015
+ if ".." in filename or "/" in filename or "\\" in filename:
1016
+ return Response().error("无效的文件名").__dict__
1017
+
1018
+ file_path = os.path.join(self.backup_dir, filename)
1019
+ if not os.path.exists(file_path):
1020
+ return Response().error("备份文件不存在").__dict__
1021
+
1022
+ os.remove(file_path)
1023
+ return Response().ok(message="删除备份成功").__dict__
1024
+ except Exception as e:
1025
+ logger.error(f"删除备份失败: {e}")
1026
+ logger.error(traceback.format_exc())
1027
+ return Response().error(f"删除备份失败: {e!s}").__dict__
1028
+
1029
+ async def rename_backup(self):
1030
+ """重命名备份文件
1031
+
1032
+ Body:
1033
+ - filename: 当前文件名 (必填)
1034
+ - new_name: 新文件名 (必填,不含扩展名)
1035
+ """
1036
+ try:
1037
+ data = await request.json
1038
+ filename = data.get("filename")
1039
+ new_name = data.get("new_name")
1040
+
1041
+ if not filename:
1042
+ return Response().error("缺少参数 filename").__dict__
1043
+
1044
+ if not new_name:
1045
+ return Response().error("缺少参数 new_name").__dict__
1046
+
1047
+ # 安全检查 - 防止路径遍历
1048
+ if ".." in filename or "/" in filename or "\\" in filename:
1049
+ return Response().error("无效的文件名").__dict__
1050
+
1051
+ # 清洗新文件名(移除路径和危险字符)
1052
+ new_name = secure_filename(new_name)
1053
+
1054
+ # 移除新文件名中的扩展名(如果有的话)
1055
+ if new_name.endswith(".zip"):
1056
+ new_name = new_name[:-4]
1057
+
1058
+ # 验证新文件名不为空
1059
+ if not new_name or new_name.replace("_", "") == "":
1060
+ return Response().error("新文件名无效").__dict__
1061
+
1062
+ # 强制使用 .zip 扩展名
1063
+ new_filename = f"{new_name}.zip"
1064
+
1065
+ # 检查原文件是否存在
1066
+ old_path = os.path.join(self.backup_dir, filename)
1067
+ if not os.path.exists(old_path):
1068
+ return Response().error("备份文件不存在").__dict__
1069
+
1070
+ # 检查新文件名是否已存在
1071
+ new_path = os.path.join(self.backup_dir, new_filename)
1072
+ if os.path.exists(new_path):
1073
+ return Response().error(f"文件名 '{new_filename}' 已存在").__dict__
1074
+
1075
+ # 执行重命名
1076
+ os.rename(old_path, new_path)
1077
+
1078
+ logger.info(f"备份文件重命名: {filename} -> {new_filename}")
1079
+
1080
+ return (
1081
+ Response()
1082
+ .ok(
1083
+ {
1084
+ "old_filename": filename,
1085
+ "new_filename": new_filename,
1086
+ }
1087
+ )
1088
+ .__dict__
1089
+ )
1090
+ except Exception as e:
1091
+ logger.error(f"重命名备份失败: {e}")
1092
+ logger.error(traceback.format_exc())
1093
+ return Response().error(f"重命名备份失败: {e!s}").__dict__