AstrBot 4.10.1__py3-none-any.whl → 4.10.3__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.
- astrbot/builtin_stars/astrbot/long_term_memory.py +186 -0
- astrbot/builtin_stars/astrbot/main.py +128 -0
- astrbot/builtin_stars/astrbot/metadata.yaml +4 -0
- astrbot/builtin_stars/astrbot/process_llm_request.py +245 -0
- astrbot/builtin_stars/builtin_commands/commands/__init__.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/admin.py +77 -0
- astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py +173 -0
- astrbot/builtin_stars/builtin_commands/commands/conversation.py +366 -0
- astrbot/builtin_stars/builtin_commands/commands/help.py +88 -0
- astrbot/builtin_stars/builtin_commands/commands/llm.py +20 -0
- astrbot/builtin_stars/builtin_commands/commands/persona.py +142 -0
- astrbot/builtin_stars/builtin_commands/commands/plugin.py +120 -0
- astrbot/builtin_stars/builtin_commands/commands/provider.py +329 -0
- astrbot/builtin_stars/builtin_commands/commands/setunset.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/sid.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/t2i.py +23 -0
- astrbot/builtin_stars/builtin_commands/commands/tool.py +31 -0
- astrbot/builtin_stars/builtin_commands/commands/tts.py +36 -0
- astrbot/builtin_stars/builtin_commands/commands/utils/rst_scene.py +26 -0
- astrbot/builtin_stars/builtin_commands/main.py +237 -0
- astrbot/builtin_stars/builtin_commands/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/main.py +537 -0
- astrbot/builtin_stars/python_interpreter/metadata.yaml +4 -0
- astrbot/builtin_stars/python_interpreter/requirements.txt +1 -0
- astrbot/builtin_stars/python_interpreter/shared/api.py +22 -0
- astrbot/builtin_stars/reminder/main.py +266 -0
- astrbot/builtin_stars/reminder/metadata.yaml +4 -0
- astrbot/builtin_stars/session_controller/main.py +114 -0
- astrbot/builtin_stars/session_controller/metadata.yaml +5 -0
- astrbot/builtin_stars/web_searcher/engines/__init__.py +111 -0
- astrbot/builtin_stars/web_searcher/engines/bing.py +30 -0
- astrbot/builtin_stars/web_searcher/engines/sogo.py +52 -0
- astrbot/builtin_stars/web_searcher/main.py +436 -0
- astrbot/builtin_stars/web_searcher/metadata.yaml +4 -0
- astrbot/cli/__init__.py +1 -1
- astrbot/core/agent/message.py +9 -0
- astrbot/core/agent/runners/tool_loop_agent_runner.py +2 -1
- astrbot/core/backup/__init__.py +26 -0
- astrbot/core/backup/constants.py +77 -0
- astrbot/core/backup/exporter.py +476 -0
- astrbot/core/backup/importer.py +761 -0
- astrbot/core/config/default.py +1 -1
- astrbot/core/log.py +1 -1
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +1 -1
- astrbot/core/pipeline/waking_check/stage.py +2 -1
- astrbot/core/provider/entities.py +32 -9
- astrbot/core/provider/provider.py +3 -1
- astrbot/core/provider/sources/anthropic_source.py +80 -27
- astrbot/core/provider/sources/fishaudio_tts_api_source.py +14 -6
- astrbot/core/provider/sources/gemini_source.py +75 -26
- astrbot/core/provider/sources/openai_source.py +68 -25
- astrbot/core/star/command_management.py +45 -4
- astrbot/core/star/context.py +1 -1
- astrbot/core/star/star_manager.py +11 -13
- astrbot/core/utils/astrbot_path.py +34 -0
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/backup.py +589 -0
- astrbot/dashboard/routes/command.py +2 -1
- astrbot/dashboard/routes/log.py +44 -10
- astrbot/dashboard/server.py +8 -1
- {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/METADATA +2 -2
- {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/RECORD +65 -26
- {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/WHEEL +0 -0
- {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/entry_points.txt +0 -0
- {astrbot-4.10.1.dist-info → astrbot-4.10.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""备份管理 API 路由"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import traceback
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from quart import request, send_file
|
|
12
|
+
|
|
13
|
+
from astrbot.core import logger
|
|
14
|
+
from astrbot.core.backup.exporter import AstrBotExporter
|
|
15
|
+
from astrbot.core.backup.importer import AstrBotImporter
|
|
16
|
+
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|
17
|
+
from astrbot.core.db import BaseDatabase
|
|
18
|
+
from astrbot.core.utils.astrbot_path import (
|
|
19
|
+
get_astrbot_backups_path,
|
|
20
|
+
get_astrbot_data_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from .route import Response, Route, RouteContext
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def secure_filename(filename: str) -> str:
|
|
27
|
+
"""清洗文件名,移除路径遍历字符和危险字符
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
filename: 原始文件名
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
安全的文件名
|
|
34
|
+
"""
|
|
35
|
+
# 跨平台处理:先将反斜杠替换为正斜杠,再取文件名
|
|
36
|
+
filename = filename.replace("\\", "/")
|
|
37
|
+
# 仅保留文件名部分,移除路径
|
|
38
|
+
filename = os.path.basename(filename)
|
|
39
|
+
|
|
40
|
+
# 替换路径遍历字符
|
|
41
|
+
filename = filename.replace("..", "_")
|
|
42
|
+
|
|
43
|
+
# 仅保留字母、数字、下划线、连字符、点
|
|
44
|
+
filename = re.sub(r"[^\w\-.]", "_", filename)
|
|
45
|
+
|
|
46
|
+
# 移除前导点(隐藏文件)和尾部点
|
|
47
|
+
filename = filename.strip(".")
|
|
48
|
+
|
|
49
|
+
# 如果文件名为空或只包含下划线,生成一个默认名称
|
|
50
|
+
if not filename or filename.replace("_", "") == "":
|
|
51
|
+
filename = "backup"
|
|
52
|
+
|
|
53
|
+
return filename
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_unique_filename(original_filename: str) -> str:
|
|
57
|
+
"""生成唯一的文件名,添加时间戳前缀
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
original_filename: 原始文件名(已清洗)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
唯一的文件名
|
|
64
|
+
"""
|
|
65
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
66
|
+
name, ext = os.path.splitext(original_filename)
|
|
67
|
+
return f"uploaded_{timestamp}_{name}{ext}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BackupRoute(Route):
|
|
71
|
+
"""备份管理路由
|
|
72
|
+
|
|
73
|
+
提供备份导出、导入、列表等 API 接口
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
context: RouteContext,
|
|
79
|
+
db: BaseDatabase,
|
|
80
|
+
core_lifecycle: AstrBotCoreLifecycle,
|
|
81
|
+
) -> None:
|
|
82
|
+
super().__init__(context)
|
|
83
|
+
self.db = db
|
|
84
|
+
self.core_lifecycle = core_lifecycle
|
|
85
|
+
self.backup_dir = get_astrbot_backups_path()
|
|
86
|
+
self.data_dir = get_astrbot_data_path()
|
|
87
|
+
|
|
88
|
+
# 任务状态跟踪
|
|
89
|
+
self.backup_tasks: dict[str, dict] = {}
|
|
90
|
+
self.backup_progress: dict[str, dict] = {}
|
|
91
|
+
|
|
92
|
+
# 注册路由
|
|
93
|
+
self.routes = {
|
|
94
|
+
"/backup/list": ("GET", self.list_backups),
|
|
95
|
+
"/backup/export": ("POST", self.export_backup),
|
|
96
|
+
"/backup/upload": ("POST", self.upload_backup), # 上传文件
|
|
97
|
+
"/backup/check": ("POST", self.check_backup), # 预检查
|
|
98
|
+
"/backup/import": ("POST", self.import_backup), # 确认导入
|
|
99
|
+
"/backup/progress": ("GET", self.get_progress),
|
|
100
|
+
"/backup/download": ("GET", self.download_backup),
|
|
101
|
+
"/backup/delete": ("POST", self.delete_backup),
|
|
102
|
+
}
|
|
103
|
+
self.register_routes()
|
|
104
|
+
|
|
105
|
+
def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None:
|
|
106
|
+
"""初始化任务状态"""
|
|
107
|
+
self.backup_tasks[task_id] = {
|
|
108
|
+
"type": task_type,
|
|
109
|
+
"status": status,
|
|
110
|
+
"result": None,
|
|
111
|
+
"error": None,
|
|
112
|
+
}
|
|
113
|
+
self.backup_progress[task_id] = {
|
|
114
|
+
"status": status,
|
|
115
|
+
"stage": "waiting",
|
|
116
|
+
"current": 0,
|
|
117
|
+
"total": 100,
|
|
118
|
+
"message": "",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
def _set_task_result(
|
|
122
|
+
self,
|
|
123
|
+
task_id: str,
|
|
124
|
+
status: str,
|
|
125
|
+
result: dict | None = None,
|
|
126
|
+
error: str | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""设置任务结果"""
|
|
129
|
+
if task_id in self.backup_tasks:
|
|
130
|
+
self.backup_tasks[task_id]["status"] = status
|
|
131
|
+
self.backup_tasks[task_id]["result"] = result
|
|
132
|
+
self.backup_tasks[task_id]["error"] = error
|
|
133
|
+
if task_id in self.backup_progress:
|
|
134
|
+
self.backup_progress[task_id]["status"] = status
|
|
135
|
+
|
|
136
|
+
def _update_progress(
|
|
137
|
+
self,
|
|
138
|
+
task_id: str,
|
|
139
|
+
*,
|
|
140
|
+
status: str | None = None,
|
|
141
|
+
stage: str | None = None,
|
|
142
|
+
current: int | None = None,
|
|
143
|
+
total: int | None = None,
|
|
144
|
+
message: str | None = None,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""更新任务进度"""
|
|
147
|
+
if task_id not in self.backup_progress:
|
|
148
|
+
return
|
|
149
|
+
p = self.backup_progress[task_id]
|
|
150
|
+
if status is not None:
|
|
151
|
+
p["status"] = status
|
|
152
|
+
if stage is not None:
|
|
153
|
+
p["stage"] = stage
|
|
154
|
+
if current is not None:
|
|
155
|
+
p["current"] = current
|
|
156
|
+
if total is not None:
|
|
157
|
+
p["total"] = total
|
|
158
|
+
if message is not None:
|
|
159
|
+
p["message"] = message
|
|
160
|
+
|
|
161
|
+
def _make_progress_callback(self, task_id: str):
|
|
162
|
+
"""创建进度回调函数"""
|
|
163
|
+
|
|
164
|
+
async def _callback(stage: str, current: int, total: int, message: str = ""):
|
|
165
|
+
self._update_progress(
|
|
166
|
+
task_id,
|
|
167
|
+
status="processing",
|
|
168
|
+
stage=stage,
|
|
169
|
+
current=current,
|
|
170
|
+
total=total,
|
|
171
|
+
message=message,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return _callback
|
|
175
|
+
|
|
176
|
+
async def list_backups(self):
|
|
177
|
+
"""获取备份列表
|
|
178
|
+
|
|
179
|
+
Query 参数:
|
|
180
|
+
- page: 页码 (默认 1)
|
|
181
|
+
- page_size: 每页数量 (默认 20)
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
page = request.args.get("page", 1, type=int)
|
|
185
|
+
page_size = request.args.get("page_size", 20, type=int)
|
|
186
|
+
|
|
187
|
+
# 确保备份目录存在
|
|
188
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
# 获取所有备份文件
|
|
191
|
+
backup_files = []
|
|
192
|
+
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
|
+
)
|
|
203
|
+
|
|
204
|
+
# 按创建时间倒序排序
|
|
205
|
+
backup_files.sort(key=lambda x: x["created_at"], reverse=True)
|
|
206
|
+
|
|
207
|
+
# 分页
|
|
208
|
+
start = (page - 1) * page_size
|
|
209
|
+
end = start + page_size
|
|
210
|
+
items = backup_files[start:end]
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
Response()
|
|
214
|
+
.ok(
|
|
215
|
+
{
|
|
216
|
+
"items": items,
|
|
217
|
+
"total": len(backup_files),
|
|
218
|
+
"page": page,
|
|
219
|
+
"page_size": page_size,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
.__dict__
|
|
223
|
+
)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(f"获取备份列表失败: {e}")
|
|
226
|
+
logger.error(traceback.format_exc())
|
|
227
|
+
return Response().error(f"获取备份列表失败: {e!s}").__dict__
|
|
228
|
+
|
|
229
|
+
async def export_backup(self):
|
|
230
|
+
"""创建备份
|
|
231
|
+
|
|
232
|
+
返回:
|
|
233
|
+
- task_id: 任务ID,用于查询导出进度
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
# 生成任务ID
|
|
237
|
+
task_id = str(uuid.uuid4())
|
|
238
|
+
|
|
239
|
+
# 初始化任务状态
|
|
240
|
+
self._init_task(task_id, "export", "pending")
|
|
241
|
+
|
|
242
|
+
# 启动后台导出任务
|
|
243
|
+
asyncio.create_task(self._background_export_task(task_id))
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
Response()
|
|
247
|
+
.ok(
|
|
248
|
+
{
|
|
249
|
+
"task_id": task_id,
|
|
250
|
+
"message": "export task created, processing in background",
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
.__dict__
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f"创建备份失败: {e}")
|
|
257
|
+
logger.error(traceback.format_exc())
|
|
258
|
+
return Response().error(f"创建备份失败: {e!s}").__dict__
|
|
259
|
+
|
|
260
|
+
async def _background_export_task(self, task_id: str):
|
|
261
|
+
"""后台导出任务"""
|
|
262
|
+
try:
|
|
263
|
+
self._update_progress(task_id, status="processing", message="正在初始化...")
|
|
264
|
+
|
|
265
|
+
# 获取知识库管理器
|
|
266
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
267
|
+
|
|
268
|
+
exporter = AstrBotExporter(
|
|
269
|
+
main_db=self.db,
|
|
270
|
+
kb_manager=kb_manager,
|
|
271
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# 创建进度回调
|
|
275
|
+
progress_callback = self._make_progress_callback(task_id)
|
|
276
|
+
|
|
277
|
+
# 执行导出
|
|
278
|
+
zip_path = await exporter.export_all(
|
|
279
|
+
output_dir=self.backup_dir,
|
|
280
|
+
progress_callback=progress_callback,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# 设置成功结果
|
|
284
|
+
self._set_task_result(
|
|
285
|
+
task_id,
|
|
286
|
+
"completed",
|
|
287
|
+
result={
|
|
288
|
+
"filename": os.path.basename(zip_path),
|
|
289
|
+
"path": zip_path,
|
|
290
|
+
"size": os.path.getsize(zip_path),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
except Exception as e:
|
|
294
|
+
logger.error(f"后台导出任务 {task_id} 失败: {e}")
|
|
295
|
+
logger.error(traceback.format_exc())
|
|
296
|
+
self._set_task_result(task_id, "failed", error=str(e))
|
|
297
|
+
|
|
298
|
+
async def upload_backup(self):
|
|
299
|
+
"""上传备份文件
|
|
300
|
+
|
|
301
|
+
将备份文件上传到服务器,返回保存的文件名。
|
|
302
|
+
上传后应调用 check_backup 进行预检查。
|
|
303
|
+
|
|
304
|
+
Form Data:
|
|
305
|
+
- file: 备份文件 (.zip)
|
|
306
|
+
|
|
307
|
+
返回:
|
|
308
|
+
- filename: 保存的文件名
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
files = await request.files
|
|
312
|
+
if "file" not in files:
|
|
313
|
+
return Response().error("缺少备份文件").__dict__
|
|
314
|
+
|
|
315
|
+
file = files["file"]
|
|
316
|
+
if not file.filename or not file.filename.endswith(".zip"):
|
|
317
|
+
return Response().error("请上传 ZIP 格式的备份文件").__dict__
|
|
318
|
+
|
|
319
|
+
# 清洗文件名并生成唯一名称,防止路径遍历和覆盖
|
|
320
|
+
safe_filename = secure_filename(file.filename)
|
|
321
|
+
unique_filename = generate_unique_filename(safe_filename)
|
|
322
|
+
|
|
323
|
+
# 保存上传的文件
|
|
324
|
+
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
|
|
325
|
+
zip_path = os.path.join(self.backup_dir, unique_filename)
|
|
326
|
+
await file.save(zip_path)
|
|
327
|
+
|
|
328
|
+
logger.info(
|
|
329
|
+
f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
Response()
|
|
334
|
+
.ok(
|
|
335
|
+
{
|
|
336
|
+
"filename": unique_filename,
|
|
337
|
+
"original_filename": file.filename,
|
|
338
|
+
"size": os.path.getsize(zip_path),
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
.__dict__
|
|
342
|
+
)
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"上传备份文件失败: {e}")
|
|
345
|
+
logger.error(traceback.format_exc())
|
|
346
|
+
return Response().error(f"上传备份文件失败: {e!s}").__dict__
|
|
347
|
+
|
|
348
|
+
async def check_backup(self):
|
|
349
|
+
"""预检查备份文件
|
|
350
|
+
|
|
351
|
+
检查备份文件的版本兼容性,返回确认信息。
|
|
352
|
+
用户确认后调用 import_backup 执行导入。
|
|
353
|
+
|
|
354
|
+
JSON Body:
|
|
355
|
+
- filename: 已上传的备份文件名
|
|
356
|
+
|
|
357
|
+
返回:
|
|
358
|
+
- ImportPreCheckResult: 预检查结果
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
data = await request.json
|
|
362
|
+
filename = data.get("filename")
|
|
363
|
+
if not filename:
|
|
364
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
365
|
+
|
|
366
|
+
# 安全检查 - 防止路径遍历
|
|
367
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
368
|
+
return Response().error("无效的文件名").__dict__
|
|
369
|
+
|
|
370
|
+
zip_path = os.path.join(self.backup_dir, filename)
|
|
371
|
+
if not os.path.exists(zip_path):
|
|
372
|
+
return Response().error(f"备份文件不存在: {filename}").__dict__
|
|
373
|
+
|
|
374
|
+
# 获取知识库管理器(用于构造 importer)
|
|
375
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
376
|
+
|
|
377
|
+
importer = AstrBotImporter(
|
|
378
|
+
main_db=self.db,
|
|
379
|
+
kb_manager=kb_manager,
|
|
380
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# 执行预检查
|
|
384
|
+
check_result = importer.pre_check(zip_path)
|
|
385
|
+
|
|
386
|
+
return Response().ok(check_result.to_dict()).__dict__
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.error(f"预检查备份文件失败: {e}")
|
|
389
|
+
logger.error(traceback.format_exc())
|
|
390
|
+
return Response().error(f"预检查备份文件失败: {e!s}").__dict__
|
|
391
|
+
|
|
392
|
+
async def import_backup(self):
|
|
393
|
+
"""执行备份导入
|
|
394
|
+
|
|
395
|
+
在用户确认后执行实际的导入操作。
|
|
396
|
+
需要先调用 upload_backup 上传文件,再调用 check_backup 预检查。
|
|
397
|
+
|
|
398
|
+
JSON Body:
|
|
399
|
+
- filename: 已上传的备份文件名(必填)
|
|
400
|
+
- confirmed: 用户已确认(必填,必须为 true)
|
|
401
|
+
|
|
402
|
+
返回:
|
|
403
|
+
- task_id: 任务ID,用于查询导入进度
|
|
404
|
+
"""
|
|
405
|
+
try:
|
|
406
|
+
data = await request.json
|
|
407
|
+
filename = data.get("filename")
|
|
408
|
+
confirmed = data.get("confirmed", False)
|
|
409
|
+
|
|
410
|
+
if not filename:
|
|
411
|
+
return Response().error("缺少 filename 参数").__dict__
|
|
412
|
+
|
|
413
|
+
if not confirmed:
|
|
414
|
+
return (
|
|
415
|
+
Response()
|
|
416
|
+
.error("请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。")
|
|
417
|
+
.__dict__
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# 安全检查 - 防止路径遍历
|
|
421
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
422
|
+
return Response().error("无效的文件名").__dict__
|
|
423
|
+
|
|
424
|
+
zip_path = os.path.join(self.backup_dir, filename)
|
|
425
|
+
if not os.path.exists(zip_path):
|
|
426
|
+
return Response().error(f"备份文件不存在: {filename}").__dict__
|
|
427
|
+
|
|
428
|
+
# 生成任务ID
|
|
429
|
+
task_id = str(uuid.uuid4())
|
|
430
|
+
|
|
431
|
+
# 初始化任务状态
|
|
432
|
+
self._init_task(task_id, "import", "pending")
|
|
433
|
+
|
|
434
|
+
# 启动后台导入任务
|
|
435
|
+
asyncio.create_task(self._background_import_task(task_id, zip_path))
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
Response()
|
|
439
|
+
.ok(
|
|
440
|
+
{
|
|
441
|
+
"task_id": task_id,
|
|
442
|
+
"message": "import task created, processing in background",
|
|
443
|
+
}
|
|
444
|
+
)
|
|
445
|
+
.__dict__
|
|
446
|
+
)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.error(f"导入备份失败: {e}")
|
|
449
|
+
logger.error(traceback.format_exc())
|
|
450
|
+
return Response().error(f"导入备份失败: {e!s}").__dict__
|
|
451
|
+
|
|
452
|
+
async def _background_import_task(self, task_id: str, zip_path: str):
|
|
453
|
+
"""后台导入任务"""
|
|
454
|
+
try:
|
|
455
|
+
self._update_progress(task_id, status="processing", message="正在初始化...")
|
|
456
|
+
|
|
457
|
+
# 获取知识库管理器
|
|
458
|
+
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
|
|
459
|
+
|
|
460
|
+
importer = AstrBotImporter(
|
|
461
|
+
main_db=self.db,
|
|
462
|
+
kb_manager=kb_manager,
|
|
463
|
+
config_path=os.path.join(self.data_dir, "cmd_config.json"),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# 创建进度回调
|
|
467
|
+
progress_callback = self._make_progress_callback(task_id)
|
|
468
|
+
|
|
469
|
+
# 执行导入
|
|
470
|
+
result = await importer.import_all(
|
|
471
|
+
zip_path=zip_path,
|
|
472
|
+
mode="replace",
|
|
473
|
+
progress_callback=progress_callback,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# 设置结果
|
|
477
|
+
if result.success:
|
|
478
|
+
self._set_task_result(
|
|
479
|
+
task_id,
|
|
480
|
+
"completed",
|
|
481
|
+
result=result.to_dict(),
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
self._set_task_result(
|
|
485
|
+
task_id,
|
|
486
|
+
"failed",
|
|
487
|
+
error="; ".join(result.errors),
|
|
488
|
+
)
|
|
489
|
+
except Exception as e:
|
|
490
|
+
logger.error(f"后台导入任务 {task_id} 失败: {e}")
|
|
491
|
+
logger.error(traceback.format_exc())
|
|
492
|
+
self._set_task_result(task_id, "failed", error=str(e))
|
|
493
|
+
|
|
494
|
+
async def get_progress(self):
|
|
495
|
+
"""获取任务进度
|
|
496
|
+
|
|
497
|
+
Query 参数:
|
|
498
|
+
- task_id: 任务 ID (必填)
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
task_id = request.args.get("task_id")
|
|
502
|
+
if not task_id:
|
|
503
|
+
return Response().error("缺少参数 task_id").__dict__
|
|
504
|
+
|
|
505
|
+
if task_id not in self.backup_tasks:
|
|
506
|
+
return Response().error("找不到该任务").__dict__
|
|
507
|
+
|
|
508
|
+
task_info = self.backup_tasks[task_id]
|
|
509
|
+
status = task_info["status"]
|
|
510
|
+
|
|
511
|
+
response_data = {
|
|
512
|
+
"task_id": task_id,
|
|
513
|
+
"type": task_info["type"],
|
|
514
|
+
"status": status,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# 如果任务正在处理,返回进度信息
|
|
518
|
+
if status == "processing" and task_id in self.backup_progress:
|
|
519
|
+
response_data["progress"] = self.backup_progress[task_id]
|
|
520
|
+
|
|
521
|
+
# 如果任务完成,返回结果
|
|
522
|
+
if status == "completed":
|
|
523
|
+
response_data["result"] = task_info["result"]
|
|
524
|
+
|
|
525
|
+
# 如果任务失败,返回错误信息
|
|
526
|
+
if status == "failed":
|
|
527
|
+
response_data["error"] = task_info["error"]
|
|
528
|
+
|
|
529
|
+
return Response().ok(response_data).__dict__
|
|
530
|
+
except Exception as e:
|
|
531
|
+
logger.error(f"获取任务进度失败: {e}")
|
|
532
|
+
logger.error(traceback.format_exc())
|
|
533
|
+
return Response().error(f"获取任务进度失败: {e!s}").__dict__
|
|
534
|
+
|
|
535
|
+
async def download_backup(self):
|
|
536
|
+
"""下载备份文件
|
|
537
|
+
|
|
538
|
+
Query 参数:
|
|
539
|
+
- filename: 备份文件名 (必填)
|
|
540
|
+
"""
|
|
541
|
+
try:
|
|
542
|
+
filename = request.args.get("filename")
|
|
543
|
+
if not filename:
|
|
544
|
+
return Response().error("缺少参数 filename").__dict__
|
|
545
|
+
|
|
546
|
+
# 安全检查 - 防止路径遍历
|
|
547
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
548
|
+
return Response().error("无效的文件名").__dict__
|
|
549
|
+
|
|
550
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
551
|
+
if not os.path.exists(file_path):
|
|
552
|
+
return Response().error("备份文件不存在").__dict__
|
|
553
|
+
|
|
554
|
+
return await send_file(
|
|
555
|
+
file_path,
|
|
556
|
+
as_attachment=True,
|
|
557
|
+
attachment_filename=filename,
|
|
558
|
+
)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
logger.error(f"下载备份失败: {e}")
|
|
561
|
+
logger.error(traceback.format_exc())
|
|
562
|
+
return Response().error(f"下载备份失败: {e!s}").__dict__
|
|
563
|
+
|
|
564
|
+
async def delete_backup(self):
|
|
565
|
+
"""删除备份文件
|
|
566
|
+
|
|
567
|
+
Body:
|
|
568
|
+
- filename: 备份文件名 (必填)
|
|
569
|
+
"""
|
|
570
|
+
try:
|
|
571
|
+
data = await request.json
|
|
572
|
+
filename = data.get("filename")
|
|
573
|
+
if not filename:
|
|
574
|
+
return Response().error("缺少参数 filename").__dict__
|
|
575
|
+
|
|
576
|
+
# 安全检查 - 防止路径遍历
|
|
577
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
578
|
+
return Response().error("无效的文件名").__dict__
|
|
579
|
+
|
|
580
|
+
file_path = os.path.join(self.backup_dir, filename)
|
|
581
|
+
if not os.path.exists(file_path):
|
|
582
|
+
return Response().error("备份文件不存在").__dict__
|
|
583
|
+
|
|
584
|
+
os.remove(file_path)
|
|
585
|
+
return Response().ok(message="删除备份成功").__dict__
|
|
586
|
+
except Exception as e:
|
|
587
|
+
logger.error(f"删除备份失败: {e}")
|
|
588
|
+
logger.error(traceback.format_exc())
|
|
589
|
+
return Response().error(f"删除备份失败: {e!s}").__dict__
|
|
@@ -61,12 +61,13 @@ class CommandRoute(Route):
|
|
|
61
61
|
data = await request.get_json()
|
|
62
62
|
handler_full_name = data.get("handler_full_name")
|
|
63
63
|
new_name = data.get("new_name")
|
|
64
|
+
aliases = data.get("aliases")
|
|
64
65
|
|
|
65
66
|
if not handler_full_name or not new_name:
|
|
66
67
|
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
|
|
67
68
|
|
|
68
69
|
try:
|
|
69
|
-
await rename_command_service(handler_full_name, new_name)
|
|
70
|
+
await rename_command_service(handler_full_name, new_name, aliases=aliases)
|
|
70
71
|
except ValueError as exc:
|
|
71
72
|
return Response().error(str(exc)).__dict__
|
|
72
73
|
|
astrbot/dashboard/routes/log.py
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
3
5
|
from typing import cast
|
|
4
6
|
|
|
5
7
|
from quart import Response as QuartResponse
|
|
6
|
-
from quart import make_response
|
|
8
|
+
from quart import make_response, request
|
|
7
9
|
|
|
8
10
|
from astrbot.core import LogBroker, logger
|
|
9
11
|
|
|
10
12
|
from .route import Response, Route, RouteContext
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def _format_log_sse(log: dict, ts: float) -> str:
|
|
16
|
+
"""辅助函数:格式化 SSE 消息"""
|
|
17
|
+
payload = {
|
|
18
|
+
"type": "log",
|
|
19
|
+
**log,
|
|
20
|
+
}
|
|
21
|
+
return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
22
|
+
|
|
23
|
+
|
|
13
24
|
class LogRoute(Route):
|
|
14
25
|
def __init__(self, context: RouteContext, log_broker: LogBroker) -> None:
|
|
15
26
|
super().__init__(context)
|
|
@@ -21,21 +32,44 @@ class LogRoute(Route):
|
|
|
21
32
|
methods=["GET"],
|
|
22
33
|
)
|
|
23
34
|
|
|
24
|
-
async def
|
|
35
|
+
async def _replay_cached_logs(
|
|
36
|
+
self, last_event_id: str
|
|
37
|
+
) -> AsyncGenerator[str, None]:
|
|
38
|
+
"""辅助生成器:重放缓存的日志"""
|
|
39
|
+
try:
|
|
40
|
+
last_ts = float(last_event_id)
|
|
41
|
+
cached_logs = list(self.log_broker.log_cache)
|
|
42
|
+
|
|
43
|
+
for log_item in cached_logs:
|
|
44
|
+
log_ts = float(log_item.get("time", 0))
|
|
45
|
+
|
|
46
|
+
if log_ts > last_ts:
|
|
47
|
+
yield _format_log_sse(log_item, log_ts)
|
|
48
|
+
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Log SSE 补发历史错误: {e}")
|
|
53
|
+
|
|
54
|
+
async def log(self) -> QuartResponse:
|
|
55
|
+
last_event_id = request.headers.get("Last-Event-ID")
|
|
56
|
+
|
|
25
57
|
async def stream():
|
|
26
58
|
queue = None
|
|
27
59
|
try:
|
|
60
|
+
if last_event_id:
|
|
61
|
+
async for event in self._replay_cached_logs(last_event_id):
|
|
62
|
+
yield event
|
|
63
|
+
|
|
28
64
|
queue = self.log_broker.register()
|
|
29
65
|
while True:
|
|
30
66
|
message = await queue.get()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
|
67
|
+
current_ts = message.get("time", time.time())
|
|
68
|
+
yield _format_log_sse(message, current_ts)
|
|
69
|
+
|
|
36
70
|
except asyncio.CancelledError:
|
|
37
71
|
pass
|
|
38
|
-
except
|
|
72
|
+
except Exception as e:
|
|
39
73
|
logger.error(f"Log SSE 连接错误: {e}")
|
|
40
74
|
finally:
|
|
41
75
|
if queue:
|
|
@@ -53,7 +87,7 @@ class LogRoute(Route):
|
|
|
53
87
|
},
|
|
54
88
|
),
|
|
55
89
|
)
|
|
56
|
-
response.timeout = None
|
|
90
|
+
response.timeout = None # type: ignore
|
|
57
91
|
return response
|
|
58
92
|
|
|
59
93
|
async def log_history(self):
|
|
@@ -69,6 +103,6 @@ class LogRoute(Route):
|
|
|
69
103
|
)
|
|
70
104
|
.__dict__
|
|
71
105
|
)
|
|
72
|
-
except
|
|
106
|
+
except Exception as e:
|
|
73
107
|
logger.error(f"获取日志历史失败: {e}")
|
|
74
108
|
return Response().error(f"获取日志历史失败: {e}").__dict__
|