AstrBot 4.10.3__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 (42) hide show
  1. astrbot/builtin_stars/astrbot/main.py +2 -10
  2. astrbot/builtin_stars/python_interpreter/main.py +130 -131
  3. astrbot/cli/__init__.py +1 -1
  4. astrbot/core/agent/message.py +23 -1
  5. astrbot/core/agent/runners/tool_loop_agent_runner.py +24 -7
  6. astrbot/core/astr_agent_hooks.py +6 -0
  7. astrbot/core/backup/exporter.py +1 -0
  8. astrbot/core/config/astrbot_config.py +2 -0
  9. astrbot/core/config/default.py +47 -6
  10. astrbot/core/knowledge_base/chunking/recursive.py +10 -2
  11. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +184 -174
  12. astrbot/core/pipeline/result_decorate/stage.py +65 -57
  13. astrbot/core/pipeline/waking_check/stage.py +29 -2
  14. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +15 -29
  15. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +1 -6
  16. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +15 -1
  17. astrbot/core/platform/sources/lark/lark_adapter.py +2 -10
  18. astrbot/core/platform/sources/misskey/misskey_adapter.py +0 -5
  19. astrbot/core/platform/sources/misskey/misskey_utils.py +0 -3
  20. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +4 -9
  21. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +4 -9
  22. astrbot/core/platform/sources/satori/satori_adapter.py +6 -1
  23. astrbot/core/platform/sources/slack/slack_adapter.py +3 -6
  24. astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
  25. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +3 -5
  26. astrbot/core/provider/entities.py +9 -1
  27. astrbot/core/provider/sources/anthropic_source.py +60 -3
  28. astrbot/core/provider/sources/gemini_source.py +37 -3
  29. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -1
  30. astrbot/core/provider/sources/openai_source.py +25 -31
  31. astrbot/core/provider/sources/xai_source.py +29 -0
  32. astrbot/core/provider/sources/xinference_stt_provider.py +24 -12
  33. astrbot/core/star/star_manager.py +41 -0
  34. astrbot/core/utils/pip_installer.py +20 -1
  35. astrbot/dashboard/routes/backup.py +519 -15
  36. astrbot/dashboard/routes/config.py +45 -0
  37. astrbot/dashboard/server.py +1 -0
  38. {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/METADATA +1 -1
  39. {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/RECORD +42 -41
  40. {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/WHEEL +0 -0
  41. {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/entry_points.txt +0 -0
  42. {astrbot-4.10.3.dist-info → astrbot-4.10.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,18 @@
1
1
  """备份管理 API 路由"""
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import os
5
6
  import re
7
+ import shutil
8
+ import time
6
9
  import traceback
7
10
  import uuid
11
+ import zipfile
8
12
  from datetime import datetime
9
13
  from pathlib import Path
10
14
 
15
+ import jwt
11
16
  from quart import request, send_file
12
17
 
13
18
  from astrbot.core import logger
@@ -22,6 +27,10 @@ from astrbot.core.utils.astrbot_path import (
22
27
 
23
28
  from .route import Response, Route, RouteContext
24
29
 
30
+ # 分片上传常量
31
+ CHUNK_SIZE = 1024 * 1024 # 1MB
32
+ UPLOAD_EXPIRE_SECONDS = 3600 # 上传会话过期时间(1小时)
33
+
25
34
 
26
35
  def secure_filename(filename: str) -> str:
27
36
  """清洗文件名,移除路径遍历字符和危险字符
@@ -54,17 +63,17 @@ def secure_filename(filename: str) -> str:
54
63
 
55
64
 
56
65
  def generate_unique_filename(original_filename: str) -> str:
57
- """生成唯一的文件名,添加时间戳前缀
66
+ """生成唯一的文件名,在原文件名后添加时间戳后缀避免重名
58
67
 
59
68
  Args:
60
69
  original_filename: 原始文件名(已清洗)
61
70
 
62
71
  Returns:
63
- 唯一的文件名
72
+ 添加了时间戳后缀的唯一文件名,格式为 {原文件名}_{YYYYMMDD_HHMMSS}.{扩展名}
64
73
  """
65
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
66
74
  name, ext = os.path.splitext(original_filename)
67
- return f"uploaded_{timestamp}_{name}{ext}"
75
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
76
+ return f"{name}_{timestamp}{ext}"
68
77
 
69
78
 
70
79
  class BackupRoute(Route):
@@ -84,21 +93,34 @@ class BackupRoute(Route):
84
93
  self.core_lifecycle = core_lifecycle
85
94
  self.backup_dir = get_astrbot_backups_path()
86
95
  self.data_dir = get_astrbot_data_path()
96
+ self.chunks_dir = os.path.join(self.backup_dir, ".chunks")
87
97
 
88
98
  # 任务状态跟踪
89
99
  self.backup_tasks: dict[str, dict] = {}
90
100
  self.backup_progress: dict[str, dict] = {}
91
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
+
92
109
  # 注册路由
93
110
  self.routes = {
94
111
  "/backup/list": ("GET", self.list_backups),
95
112
  "/backup/export": ("POST", self.export_backup),
96
- "/backup/upload": ("POST", self.upload_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), # 取消上传
97
118
  "/backup/check": ("POST", self.check_backup), # 预检查
98
119
  "/backup/import": ("POST", self.import_backup), # 确认导入
99
120
  "/backup/progress": ("GET", self.get_progress),
100
121
  "/backup/download": ("GET", self.download_backup),
101
122
  "/backup/delete": ("POST", self.delete_backup),
123
+ "/backup/rename": ("POST", self.rename_backup), # 重命名备份
102
124
  }
103
125
  self.register_routes()
104
126
 
@@ -173,7 +195,81 @@ class BackupRoute(Route):
173
195
 
174
196
  return _callback
175
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
+
176
269
  async def list_backups(self):
270
+ # 确保后台清理任务已启动
271
+ self._ensure_cleanup_task_started()
272
+
177
273
  """获取备份列表
178
274
 
179
275
  Query 参数:
@@ -190,16 +286,34 @@ class BackupRoute(Route):
190
286
  # 获取所有备份文件
191
287
  backup_files = []
192
288
  for filename in os.listdir(self.backup_dir):
193
- if filename.endswith(".zip") and filename.startswith("astrbot_backup_"):
194
- file_path = os.path.join(self.backup_dir, filename)
195
- stat = os.stat(file_path)
196
- backup_files.append(
197
- {
198
- "filename": filename,
199
- "size": stat.st_size,
200
- "created_at": stat.st_mtime,
201
- }
202
- )
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
+ )
203
317
 
204
318
  # 按创建时间倒序排序
205
319
  backup_files.sort(key=lambda x: x["created_at"], reverse=True)
@@ -345,6 +459,309 @@ class BackupRoute(Route):
345
459
  logger.error(traceback.format_exc())
346
460
  return Response().error(f"上传备份文件失败: {e!s}").__dict__
347
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
+
348
765
  async def check_backup(self):
349
766
  """预检查备份文件
350
767
 
@@ -537,12 +954,33 @@ class BackupRoute(Route):
537
954
 
538
955
  Query 参数:
539
956
  - filename: 备份文件名 (必填)
957
+ - token: JWT token (必填,用于浏览器原生下载鉴权)
958
+
959
+ 注意: 此路由已被添加到 auth_middleware 白名单中,
960
+ 使用 URL 参数中的 token 进行鉴权,以支持浏览器原生下载。
540
961
  """
541
962
  try:
542
963
  filename = request.args.get("filename")
964
+ token = request.args.get("token")
965
+
543
966
  if not filename:
544
967
  return Response().error("缺少参数 filename").__dict__
545
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
+
546
984
  # 安全检查 - 防止路径遍历
547
985
  if ".." in filename or "/" in filename or "\\" in filename:
548
986
  return Response().error("无效的文件名").__dict__
@@ -587,3 +1025,69 @@ class BackupRoute(Route):
587
1025
  logger.error(f"删除备份失败: {e}")
588
1026
  logger.error(traceback.format_exc())
589
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__
@@ -46,6 +46,46 @@ def try_cast(value: Any, type_: str):
46
46
  return None
47
47
 
48
48
 
49
+ def _expect_type(value, expected_type, path_key, errors, expected_name=None):
50
+ if not isinstance(value, expected_type):
51
+ errors.append(
52
+ f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
53
+ f"得到了 {type(value).__name__}"
54
+ )
55
+ return False
56
+ return True
57
+
58
+
59
+ def _validate_template_list(value, meta, path_key, errors, validate_fn):
60
+ if not _expect_type(value, list, path_key, errors, "list"):
61
+ return
62
+
63
+ templates = meta.get("templates")
64
+ if not isinstance(templates, dict):
65
+ templates = {}
66
+
67
+ for idx, item in enumerate(value):
68
+ item_path = f"{path_key}[{idx}]"
69
+ if not _expect_type(item, dict, item_path, errors, "dict"):
70
+ continue
71
+
72
+ template_key = item.get("__template_key") or item.get("template")
73
+ if not template_key:
74
+ errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
75
+ continue
76
+
77
+ template_meta = templates.get(template_key)
78
+ if not template_meta:
79
+ errors.append(f"未知模板 {item_path}: {template_key}")
80
+ continue
81
+
82
+ validate_fn(
83
+ item,
84
+ template_meta.get("items", {}),
85
+ path=f"{item_path}.",
86
+ )
87
+
88
+
49
89
  def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
50
90
  errors = []
51
91
 
@@ -61,6 +101,11 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
61
101
  if value is None:
62
102
  data[key] = DEFAULT_VALUE_MAP[meta["type"]]
63
103
  continue
104
+
105
+ if meta["type"] == "template_list":
106
+ _validate_template_list(value, meta, f"{path}{key}", errors, validate)
107
+ continue
108
+
64
109
  if meta["type"] == "list" and not isinstance(value, list):
65
110
  errors.append(
66
111
  f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
@@ -115,6 +115,7 @@ class AstrBotDashboard:
115
115
  "/api/file",
116
116
  "/api/platform/webhook",
117
117
  "/api/stat/start-time",
118
+ "/api/backup/download", # 备份下载使用 URL 参数传递 token
118
119
  ]
119
120
  if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
120
121
  return None