AstrBot 4.7.4__py3-none-any.whl → 4.8.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.
Files changed (46) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/astr_agent_run_util.py +15 -1
  3. astrbot/core/config/default.py +58 -1
  4. astrbot/core/db/__init__.py +30 -1
  5. astrbot/core/db/sqlite.py +55 -1
  6. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +1 -1
  7. astrbot/core/platform/manager.py +67 -9
  8. astrbot/core/platform/platform.py +99 -2
  9. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +4 -3
  10. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +4 -6
  11. astrbot/core/platform/sources/discord/discord_platform_adapter.py +1 -2
  12. astrbot/core/platform/sources/lark/lark_adapter.py +1 -3
  13. astrbot/core/platform/sources/misskey/misskey_adapter.py +1 -2
  14. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +2 -0
  15. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +1 -3
  16. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +32 -9
  17. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +13 -1
  18. astrbot/core/platform/sources/satori/satori_adapter.py +1 -2
  19. astrbot/core/platform/sources/slack/client.py +50 -39
  20. astrbot/core/platform/sources/slack/slack_adapter.py +21 -7
  21. astrbot/core/platform/sources/slack/slack_event.py +3 -3
  22. astrbot/core/platform/sources/telegram/tg_adapter.py +1 -2
  23. astrbot/core/platform/sources/webchat/webchat_adapter.py +95 -29
  24. astrbot/core/platform/sources/webchat/webchat_event.py +33 -33
  25. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +1 -2
  26. astrbot/core/platform/sources/wecom/wecom_adapter.py +51 -9
  27. astrbot/core/platform/sources/wecom/wecom_event.py +1 -1
  28. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +26 -9
  29. astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +27 -5
  30. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +52 -11
  31. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +1 -1
  32. astrbot/core/platform_message_history_mgr.py +3 -3
  33. astrbot/core/provider/sources/whisper_api_source.py +43 -11
  34. astrbot/core/utils/tencent_record_helper.py +1 -1
  35. astrbot/core/utils/webhook_utils.py +47 -0
  36. astrbot/dashboard/routes/__init__.py +2 -0
  37. astrbot/dashboard/routes/chat.py +300 -70
  38. astrbot/dashboard/routes/config.py +19 -0
  39. astrbot/dashboard/routes/knowledge_base.py +1 -1
  40. astrbot/dashboard/routes/platform.py +100 -0
  41. astrbot/dashboard/server.py +3 -1
  42. {astrbot-4.7.4.dist-info → astrbot-4.8.0.dist-info}/METADATA +48 -37
  43. {astrbot-4.7.4.dist-info → astrbot-4.8.0.dist-info}/RECORD +46 -44
  44. {astrbot-4.7.4.dist-info → astrbot-4.8.0.dist-info}/WHEEL +0 -0
  45. {astrbot-4.7.4.dist-info → astrbot-4.8.0.dist-info}/entry_points.txt +0 -0
  46. {astrbot-4.7.4.dist-info → astrbot-4.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,11 @@
1
1
  import asyncio
2
2
  import json
3
+ import mimetypes
3
4
  import os
4
5
  import uuid
5
6
  from contextlib import asynccontextmanager
6
7
 
7
- from quart import Response as QuartResponse
8
- from quart import g, make_response, request
8
+ from quart import g, make_response, request, send_file
9
9
 
10
10
  from astrbot.core import logger
11
11
  from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -44,7 +44,7 @@ class ChatRoute(Route):
44
44
  self.update_session_display_name,
45
45
  ),
46
46
  "/chat/get_file": ("GET", self.get_file),
47
- "/chat/post_image": ("POST", self.post_image),
47
+ "/chat/get_attachment": ("GET", self.get_attachment),
48
48
  "/chat/post_file": ("POST", self.post_file),
49
49
  }
50
50
  self.core_lifecycle = core_lifecycle
@@ -73,52 +73,184 @@ class ChatRoute(Route):
73
73
  if not real_file_path.startswith(real_imgs_dir):
74
74
  return Response().error("Invalid file path").__dict__
75
75
 
76
- with open(real_file_path, "rb") as f:
77
- filename_ext = os.path.splitext(filename)[1].lower()
78
-
79
- if filename_ext == ".wav":
80
- return QuartResponse(f.read(), mimetype="audio/wav")
81
- if filename_ext[1:] in self.supported_imgs:
82
- return QuartResponse(f.read(), mimetype="image/jpeg")
83
- return QuartResponse(f.read())
76
+ filename_ext = os.path.splitext(filename)[1].lower()
77
+ if filename_ext == ".wav":
78
+ return await send_file(real_file_path, mimetype="audio/wav")
79
+ if filename_ext[1:] in self.supported_imgs:
80
+ return await send_file(real_file_path, mimetype="image/jpeg")
81
+ return await send_file(real_file_path)
84
82
 
85
83
  except (FileNotFoundError, OSError):
86
84
  return Response().error("File access error").__dict__
87
85
 
88
- async def post_image(self):
89
- post_data = await request.files
90
- if "file" not in post_data:
91
- return Response().error("Missing key: file").__dict__
86
+ async def get_attachment(self):
87
+ """Get attachment file by attachment_id."""
88
+ attachment_id = request.args.get("attachment_id")
89
+ if not attachment_id:
90
+ return Response().error("Missing key: attachment_id").__dict__
92
91
 
93
- file = post_data["file"]
94
- filename = str(uuid.uuid4()) + ".jpg"
95
- path = os.path.join(self.imgs_dir, filename)
96
- await file.save(path)
92
+ try:
93
+ attachment = await self.db.get_attachment_by_id(attachment_id)
94
+ if not attachment:
95
+ return Response().error("Attachment not found").__dict__
97
96
 
98
- return Response().ok(data={"filename": filename}).__dict__
97
+ file_path = attachment.path
98
+ real_file_path = os.path.realpath(file_path)
99
+
100
+ return await send_file(real_file_path, mimetype=attachment.mime_type)
101
+
102
+ except (FileNotFoundError, OSError):
103
+ return Response().error("File access error").__dict__
99
104
 
100
105
  async def post_file(self):
106
+ """Upload a file and create an attachment record, return attachment_id."""
101
107
  post_data = await request.files
102
108
  if "file" not in post_data:
103
109
  return Response().error("Missing key: file").__dict__
104
110
 
105
111
  file = post_data["file"]
106
- filename = f"{uuid.uuid4()!s}"
107
- # 通过文件格式判断文件类型
108
- if file.content_type.startswith("audio"):
109
- filename += ".wav"
112
+ filename = file.filename or f"{uuid.uuid4()!s}"
113
+ content_type = file.content_type or "application/octet-stream"
114
+
115
+ # 根据 content_type 判断文件类型并添加扩展名
116
+ if content_type.startswith("image"):
117
+ attach_type = "image"
118
+ elif content_type.startswith("audio"):
119
+ attach_type = "record"
120
+ elif content_type.startswith("video"):
121
+ attach_type = "video"
122
+ else:
123
+ attach_type = "file"
110
124
 
111
125
  path = os.path.join(self.imgs_dir, filename)
112
126
  await file.save(path)
113
127
 
114
- return Response().ok(data={"filename": filename}).__dict__
128
+ # 创建 attachment 记录
129
+ attachment = await self.db.insert_attachment(
130
+ path=path,
131
+ type=attach_type,
132
+ mime_type=content_type,
133
+ )
134
+
135
+ if not attachment:
136
+ return Response().error("Failed to create attachment").__dict__
137
+
138
+ filename = os.path.basename(attachment.path)
139
+
140
+ return (
141
+ Response()
142
+ .ok(
143
+ data={
144
+ "attachment_id": attachment.attachment_id,
145
+ "filename": filename,
146
+ "type": attach_type,
147
+ }
148
+ )
149
+ .__dict__
150
+ )
151
+
152
+ async def _build_user_message_parts(self, message: str | list) -> list[dict]:
153
+ """构建用户消息的部分列表
154
+
155
+ Args:
156
+ message: 文本消息 (str) 或消息段列表 (list)
157
+ """
158
+ parts = []
159
+
160
+ if isinstance(message, list):
161
+ for part in message:
162
+ part_type = part.get("type")
163
+ if part_type == "plain":
164
+ parts.append({"type": "plain", "text": part.get("text", "")})
165
+ elif part_type == "reply":
166
+ parts.append(
167
+ {"type": "reply", "message_id": part.get("message_id")}
168
+ )
169
+ elif attachment_id := part.get("attachment_id"):
170
+ attachment = await self.db.get_attachment_by_id(attachment_id)
171
+ if attachment:
172
+ parts.append(
173
+ {
174
+ "type": attachment.type,
175
+ "attachment_id": attachment.attachment_id,
176
+ "filename": os.path.basename(attachment.path),
177
+ "path": attachment.path, # will be deleted
178
+ }
179
+ )
180
+ return parts
181
+
182
+ if message:
183
+ parts.append({"type": "plain", "text": message})
184
+
185
+ return parts
186
+
187
+ async def _create_attachment_from_file(
188
+ self, filename: str, attach_type: str
189
+ ) -> dict | None:
190
+ """从本地文件创建 attachment 并返回消息部分
191
+
192
+ 用于处理 bot 回复中的媒体文件
193
+
194
+ Args:
195
+ filename: 存储的文件名
196
+ attach_type: 附件类型 (image, record, file, video)
197
+ """
198
+ file_path = os.path.join(self.imgs_dir, os.path.basename(filename))
199
+ if not os.path.exists(file_path):
200
+ return None
201
+
202
+ # guess mime type
203
+ mime_type, _ = mimetypes.guess_type(filename)
204
+ if not mime_type:
205
+ mime_type = "application/octet-stream"
206
+
207
+ # insert attachment
208
+ attachment = await self.db.insert_attachment(
209
+ path=file_path,
210
+ type=attach_type,
211
+ mime_type=mime_type,
212
+ )
213
+ if not attachment:
214
+ return None
215
+
216
+ return {
217
+ "type": attach_type,
218
+ "attachment_id": attachment.attachment_id,
219
+ "filename": os.path.basename(file_path),
220
+ }
221
+
222
+ async def _save_bot_message(
223
+ self,
224
+ webchat_conv_id: str,
225
+ text: str,
226
+ media_parts: list,
227
+ reasoning: str,
228
+ ):
229
+ """保存 bot 消息到历史记录,返回保存的记录"""
230
+ bot_message_parts = []
231
+ if text:
232
+ bot_message_parts.append({"type": "plain", "text": text})
233
+ bot_message_parts.extend(media_parts)
234
+
235
+ new_his = {"type": "bot", "message": bot_message_parts}
236
+ if reasoning:
237
+ new_his["reasoning"] = reasoning
238
+
239
+ record = await self.platform_history_mgr.insert(
240
+ platform_id="webchat",
241
+ user_id=webchat_conv_id,
242
+ content=new_his,
243
+ sender_id="bot",
244
+ sender_name="bot",
245
+ )
246
+ return record
115
247
 
116
248
  async def chat(self):
117
249
  username = g.get("username", "guest")
118
250
 
119
251
  post_data = await request.json
120
- if "message" not in post_data and "image_url" not in post_data:
121
- return Response().error("Missing key: message or image_url").__dict__
252
+ if "message" not in post_data and "files" not in post_data:
253
+ return Response().error("Missing key: message or files").__dict__
122
254
 
123
255
  if "session_id" not in post_data and "conversation_id" not in post_data:
124
256
  return (
@@ -126,44 +258,40 @@ class ChatRoute(Route):
126
258
  )
127
259
 
128
260
  message = post_data["message"]
129
- # conversation_id = post_data["conversation_id"]
130
261
  session_id = post_data.get("session_id", post_data.get("conversation_id"))
131
- image_url = post_data.get("image_url")
132
- audio_url = post_data.get("audio_url")
133
262
  selected_provider = post_data.get("selected_provider")
134
263
  selected_model = post_data.get("selected_model")
135
- enable_streaming = post_data.get("enable_streaming", True) # 默认为 True
264
+ enable_streaming = post_data.get("enable_streaming", True)
136
265
 
137
- if not message and not image_url and not audio_url:
138
- return (
139
- Response()
140
- .error("Message and image_url and audio_url are empty")
141
- .__dict__
266
+ # 检查消息是否为空
267
+ if isinstance(message, list):
268
+ has_content = any(
269
+ part.get("type") in ("plain", "image", "record", "file", "video")
270
+ for part in message
142
271
  )
272
+ if not has_content:
273
+ return (
274
+ Response()
275
+ .error("Message content is empty (reply only is not allowed)")
276
+ .__dict__
277
+ )
278
+ elif not message:
279
+ return Response().error("Message are both empty").__dict__
280
+
143
281
  if not session_id:
144
282
  return Response().error("session_id is empty").__dict__
145
283
 
146
- # 追加用户消息
147
284
  webchat_conv_id = session_id
148
-
149
- # 获取会话特定的队列
150
285
  back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
151
286
 
152
- new_his = {"type": "user", "message": message}
153
- if image_url:
154
- new_his["image_url"] = image_url
155
- if audio_url:
156
- new_his["audio_url"] = audio_url
157
- await self.platform_history_mgr.insert(
158
- platform_id="webchat",
159
- user_id=webchat_conv_id,
160
- content=new_his,
161
- sender_id=username,
162
- sender_name=username,
163
- )
287
+ # 构建用户消息段(包含 path 用于传递给 adapter)
288
+ message_parts = await self._build_user_message_parts(message)
164
289
 
165
290
  async def stream():
166
291
  client_disconnected = False
292
+ accumulated_parts = []
293
+ accumulated_text = ""
294
+ accumulated_reasoning = ""
167
295
 
168
296
  try:
169
297
  async with track_conversation(self.running_convs, webchat_conv_id):
@@ -182,16 +310,17 @@ class ChatRoute(Route):
182
310
  continue
183
311
 
184
312
  result_text = result["data"]
185
- type = result.get("type")
313
+ msg_type = result.get("type")
186
314
  streaming = result.get("streaming", False)
187
315
 
316
+ # 发送 SSE 数据
188
317
  try:
189
318
  if not client_disconnected:
190
319
  yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
191
320
  except Exception as e:
192
321
  if not client_disconnected:
193
322
  logger.debug(
194
- f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
323
+ f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
195
324
  )
196
325
  client_disconnected = True
197
326
 
@@ -202,24 +331,68 @@ class ChatRoute(Route):
202
331
  logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
203
332
  client_disconnected = True
204
333
 
205
- if type == "end":
334
+ # 累积消息部分
335
+ if msg_type == "plain":
336
+ chain_type = result.get("chain_type", "normal")
337
+ if chain_type == "reasoning":
338
+ accumulated_reasoning += result_text
339
+ else:
340
+ accumulated_text += result_text
341
+ elif msg_type == "image":
342
+ filename = result_text.replace("[IMAGE]", "")
343
+ part = await self._create_attachment_from_file(
344
+ filename, "image"
345
+ )
346
+ if part:
347
+ accumulated_parts.append(part)
348
+ elif msg_type == "record":
349
+ filename = result_text.replace("[RECORD]", "")
350
+ part = await self._create_attachment_from_file(
351
+ filename, "record"
352
+ )
353
+ if part:
354
+ accumulated_parts.append(part)
355
+ elif msg_type == "file":
356
+ # 格式: [FILE]filename
357
+ filename = result_text.replace("[FILE]", "")
358
+ part = await self._create_attachment_from_file(
359
+ filename, "file"
360
+ )
361
+ if part:
362
+ accumulated_parts.append(part)
363
+
364
+ # 消息结束处理
365
+ if msg_type == "end":
206
366
  break
207
367
  elif (
208
- (streaming and type == "complete")
368
+ (streaming and msg_type == "complete")
209
369
  or not streaming
210
- or type == "break"
370
+ or msg_type == "break"
211
371
  ):
212
- # 追加机器人消息
213
- new_his = {"type": "bot", "message": result_text}
214
- if "reasoning" in result:
215
- new_his["reasoning"] = result["reasoning"]
216
- await self.platform_history_mgr.insert(
217
- platform_id="webchat",
218
- user_id=webchat_conv_id,
219
- content=new_his,
220
- sender_id="bot",
221
- sender_name="bot",
372
+ saved_record = await self._save_bot_message(
373
+ webchat_conv_id,
374
+ accumulated_text,
375
+ accumulated_parts,
376
+ accumulated_reasoning,
222
377
  )
378
+ # 发送保存的消息信息给前端
379
+ if saved_record and not client_disconnected:
380
+ saved_info = {
381
+ "type": "message_saved",
382
+ "data": {
383
+ "id": saved_record.id,
384
+ "created_at": saved_record.created_at.astimezone().isoformat(),
385
+ },
386
+ }
387
+ try:
388
+ yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
389
+ except Exception:
390
+ pass
391
+ # 重置累积变量 (对于 break 后的下一段消息)
392
+ if msg_type == "break":
393
+ accumulated_parts = []
394
+ accumulated_text = ""
395
+ accumulated_reasoning = ""
223
396
  except BaseException as e:
224
397
  logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
225
398
 
@@ -230,9 +403,7 @@ class ChatRoute(Route):
230
403
  username,
231
404
  webchat_conv_id,
232
405
  {
233
- "message": message,
234
- "image_url": image_url, # list
235
- "audio_url": audio_url,
406
+ "message": message_parts,
236
407
  "selected_provider": selected_provider,
237
408
  "selected_model": selected_model,
238
409
  "enable_streaming": enable_streaming,
@@ -240,6 +411,19 @@ class ChatRoute(Route):
240
411
  ),
241
412
  )
242
413
 
414
+ message_parts_for_storage = []
415
+ for part in message_parts:
416
+ part_copy = {k: v for k, v in part.items() if k != "path"}
417
+ message_parts_for_storage.append(part_copy)
418
+
419
+ await self.platform_history_mgr.insert(
420
+ platform_id="webchat",
421
+ user_id=webchat_conv_id,
422
+ content={"type": "user", "message": message_parts_for_storage},
423
+ sender_id=username,
424
+ sender_name=username,
425
+ )
426
+
243
427
  response = await make_response(
244
428
  stream(),
245
429
  {
@@ -249,7 +433,7 @@ class ChatRoute(Route):
249
433
  "Connection": "keep-alive",
250
434
  },
251
435
  )
252
- response.timeout = None # fix SSE auto disconnect issue
436
+ response.timeout = None # fix SSE auto disconnect issue # pyright: ignore[reportAttributeAccessIssue]
253
437
  return response
254
438
 
255
439
  async def delete_webchat_session(self):
@@ -271,6 +455,17 @@ class ChatRoute(Route):
271
455
  unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
272
456
  await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
273
457
 
458
+ # 获取消息历史中的所有附件 ID 并删除附件
459
+ history_list = await self.platform_history_mgr.get(
460
+ platform_id=session.platform_id,
461
+ user_id=session_id,
462
+ page=1,
463
+ page_size=100000, # 获取足够多的记录
464
+ )
465
+ attachment_ids = self._extract_attachment_ids(history_list)
466
+ if attachment_ids:
467
+ await self._delete_attachments(attachment_ids)
468
+
274
469
  # 删除消息历史
275
470
  await self.platform_history_mgr.delete(
276
471
  platform_id=session.platform_id,
@@ -297,6 +492,41 @@ class ChatRoute(Route):
297
492
 
298
493
  return Response().ok().__dict__
299
494
 
495
+ def _extract_attachment_ids(self, history_list) -> list[str]:
496
+ """从消息历史中提取所有 attachment_id"""
497
+ attachment_ids = []
498
+ for history in history_list:
499
+ content = history.content
500
+ if not content or "message" not in content:
501
+ continue
502
+ message_parts = content.get("message", [])
503
+ for part in message_parts:
504
+ if isinstance(part, dict) and "attachment_id" in part:
505
+ attachment_ids.append(part["attachment_id"])
506
+ return attachment_ids
507
+
508
+ async def _delete_attachments(self, attachment_ids: list[str]):
509
+ """删除附件(包括数据库记录和磁盘文件)"""
510
+ try:
511
+ attachments = await self.db.get_attachments(attachment_ids)
512
+ for attachment in attachments:
513
+ if not os.path.exists(attachment.path):
514
+ continue
515
+ try:
516
+ os.remove(attachment.path)
517
+ except OSError as e:
518
+ logger.warning(
519
+ f"Failed to delete attachment file {attachment.path}: {e}"
520
+ )
521
+ except Exception as e:
522
+ logger.warning(f"Failed to get attachments: {e}")
523
+
524
+ # 批量删除数据库记录
525
+ try:
526
+ await self.db.delete_attachments(attachment_ids)
527
+ except Exception as e:
528
+ logger.warning(f"Failed to delete attachments: {e}")
529
+
300
530
  async def new_session(self):
301
531
  """Create a new Platform session (default: webchat)."""
302
532
  username = g.get("username", "guest")
@@ -2,6 +2,7 @@ import asyncio
2
2
  import inspect
3
3
  import os
4
4
  import traceback
5
+ import uuid
5
6
 
6
7
  from quart import request
7
8
 
@@ -13,6 +14,7 @@ from astrbot.core.config.default import (
13
14
  CONFIG_METADATA_3_SYSTEM,
14
15
  DEFAULT_CONFIG,
15
16
  DEFAULT_VALUE_MAP,
17
+ WEBHOOK_SUPPORTED_PLATFORMS,
16
18
  )
17
19
  from astrbot.core.config.i18n_utils import ConfigMetadataI18n
18
20
  from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -555,6 +557,15 @@ class ConfigRoute(Route):
555
557
 
556
558
  async def post_new_platform(self):
557
559
  new_platform_config = await request.json
560
+
561
+ # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,自动生成 webhook_uuid
562
+ platform_type = new_platform_config.get("type", "")
563
+ if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
564
+ if new_platform_config.get("unified_webhook_mode", False):
565
+ # 如果没有 webhook_uuid 或为空,自动生成
566
+ if not new_platform_config.get("webhook_uuid"):
567
+ new_platform_config["webhook_uuid"] = uuid.uuid4().hex[:16]
568
+
558
569
  self.config["platform"].append(new_platform_config)
559
570
  try:
560
571
  save_config(self.config, self.config, is_core=True)
@@ -584,6 +595,14 @@ class ConfigRoute(Route):
584
595
  if not platform_id or not new_config:
585
596
  return Response().error("参数错误").__dict__
586
597
 
598
+ # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
599
+ platform_type = new_config.get("type", "")
600
+ if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
601
+ if new_config.get("unified_webhook_mode", False):
602
+ # 如果没有 webhook_uuid 或为空,自动生成
603
+ if not new_config.get("webhook_uuid"):
604
+ new_config["webhook_uuid"] = uuid.uuid4().hex
605
+
587
606
  for i, platform in enumerate(self.config["platform"]):
588
607
  if platform["id"] == platform_id:
589
608
  self.config["platform"][i] = new_config
@@ -274,7 +274,7 @@ class KnowledgeBaseRoute(Route):
274
274
  except Exception as e:
275
275
  return (
276
276
  Response()
277
- .error(f"测试重排序模型失败: {e!s},请检查控制台日志输出。")
277
+ .error(f"测试重排序模型失败: {e!s},请检查平台日志输出。")
278
278
  .__dict__
279
279
  )
280
280
 
@@ -0,0 +1,100 @@
1
+ """统一 Webhook 路由
2
+
3
+ 提供统一的 webhook 回调入口,支持多个平台使用同一端口接收回调。
4
+ """
5
+
6
+ from quart import request
7
+
8
+ from astrbot.core import logger
9
+ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
10
+ from astrbot.core.platform import Platform
11
+
12
+ from .route import Response, Route, RouteContext
13
+
14
+
15
+ class PlatformRoute(Route):
16
+ """统一 Webhook 路由"""
17
+
18
+ def __init__(
19
+ self,
20
+ context: RouteContext,
21
+ core_lifecycle: AstrBotCoreLifecycle,
22
+ ) -> None:
23
+ super().__init__(context)
24
+ self.core_lifecycle = core_lifecycle
25
+ self.platform_manager = core_lifecycle.platform_manager
26
+
27
+ self._register_webhook_routes()
28
+
29
+ def _register_webhook_routes(self):
30
+ """注册 webhook 路由"""
31
+ # 统一 webhook 入口,支持 GET 和 POST
32
+ self.app.add_url_rule(
33
+ "/api/platform/webhook/<webhook_uuid>",
34
+ view_func=self.unified_webhook_callback,
35
+ methods=["GET", "POST"],
36
+ )
37
+
38
+ # 平台统计信息接口
39
+ self.app.add_url_rule(
40
+ "/api/platform/stats",
41
+ view_func=self.get_platform_stats,
42
+ methods=["GET"],
43
+ )
44
+
45
+ async def unified_webhook_callback(self, webhook_uuid: str):
46
+ """统一 webhook 回调入口
47
+
48
+ Args:
49
+ webhook_uuid: 平台配置中的 webhook_uuid
50
+
51
+ Returns:
52
+ 根据平台适配器返回相应的响应
53
+ """
54
+ # 根据 webhook_uuid 查找对应的平台
55
+ platform_adapter = self._find_platform_by_uuid(webhook_uuid)
56
+
57
+ if not platform_adapter:
58
+ logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台")
59
+ return Response().error("未找到对应平台").__dict__, 404
60
+
61
+ # 调用平台适配器的 webhook_callback 方法
62
+ try:
63
+ result = await platform_adapter.webhook_callback(request)
64
+ return result
65
+ except NotImplementedError:
66
+ logger.error(
67
+ f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法"
68
+ )
69
+ return Response().error("平台未支持统一 Webhook 模式").__dict__, 500
70
+ except Exception as e:
71
+ logger.error(f"处理 webhook 回调时发生错误: {e}", exc_info=True)
72
+ return Response().error("处理回调失败").__dict__, 500
73
+
74
+ def _find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None:
75
+ """根据 webhook_uuid 查找对应的平台适配器
76
+
77
+ Args:
78
+ webhook_uuid: webhook UUID
79
+
80
+ Returns:
81
+ 平台适配器实例,未找到则返回 None
82
+ """
83
+ for platform in self.platform_manager.platform_insts:
84
+ if platform.config.get("webhook_uuid") == webhook_uuid:
85
+ if platform.config.get("unified_webhook_mode", False):
86
+ return platform
87
+ return None
88
+
89
+ async def get_platform_stats(self):
90
+ """获取所有平台的统计信息
91
+
92
+ Returns:
93
+ 包含平台统计信息的响应
94
+ """
95
+ try:
96
+ stats = self.platform_manager.get_all_stats()
97
+ return Response().ok(stats).__dict__
98
+ except Exception as e:
99
+ logger.error(f"获取平台统计信息失败: {e}", exc_info=True)
100
+ return Response().error(f"获取统计信息失败: {e}").__dict__, 500
@@ -16,6 +16,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
16
16
  from astrbot.core.utils.io import get_local_ip_addresses
17
17
 
18
18
  from .routes import *
19
+ from .routes.platform import PlatformRoute
19
20
  from .routes.route import Response, RouteContext
20
21
  from .routes.session_management import SessionManagementRoute
21
22
  from .routes.t2i import T2iRoute
@@ -79,6 +80,7 @@ class AstrBotDashboard:
79
80
  self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
80
81
  self.t2i_route = T2iRoute(self.context, core_lifecycle)
81
82
  self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
83
+ self.platform_route = PlatformRoute(self.context, core_lifecycle)
82
84
 
83
85
  self.app.add_url_rule(
84
86
  "/api/plug/<path:subpath>",
@@ -102,7 +104,7 @@ class AstrBotDashboard:
102
104
  async def auth_middleware(self):
103
105
  if not request.path.startswith("/api"):
104
106
  return None
105
- allowed_endpoints = ["/api/auth/login", "/api/file"]
107
+ allowed_endpoints = ["/api/auth/login", "/api/file", "/api/platform/webhook"]
106
108
  if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
107
109
  return None
108
110
  # 声明 JWT