AstrBot 4.7.3__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 (52) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/message.py +21 -5
  3. astrbot/core/astr_agent_run_util.py +15 -1
  4. astrbot/core/config/default.py +113 -1
  5. astrbot/core/db/__init__.py +30 -1
  6. astrbot/core/db/sqlite.py +55 -1
  7. astrbot/core/message/components.py +6 -1
  8. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +64 -5
  9. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +1 -1
  10. astrbot/core/platform/manager.py +67 -9
  11. astrbot/core/platform/platform.py +99 -2
  12. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +19 -5
  13. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +5 -7
  14. astrbot/core/platform/sources/discord/discord_platform_adapter.py +1 -2
  15. astrbot/core/platform/sources/lark/lark_adapter.py +1 -3
  16. astrbot/core/platform/sources/misskey/misskey_adapter.py +1 -2
  17. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +2 -0
  18. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +1 -3
  19. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +32 -9
  20. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +13 -1
  21. astrbot/core/platform/sources/satori/satori_adapter.py +1 -2
  22. astrbot/core/platform/sources/slack/client.py +50 -39
  23. astrbot/core/platform/sources/slack/slack_adapter.py +21 -7
  24. astrbot/core/platform/sources/slack/slack_event.py +3 -3
  25. astrbot/core/platform/sources/telegram/tg_adapter.py +4 -3
  26. astrbot/core/platform/sources/webchat/webchat_adapter.py +95 -29
  27. astrbot/core/platform/sources/webchat/webchat_event.py +33 -33
  28. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +1 -2
  29. astrbot/core/platform/sources/wecom/wecom_adapter.py +51 -9
  30. astrbot/core/platform/sources/wecom/wecom_event.py +1 -1
  31. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +26 -9
  32. astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +27 -5
  33. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +52 -11
  34. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +1 -1
  35. astrbot/core/platform_message_history_mgr.py +3 -3
  36. astrbot/core/provider/provider.py +35 -0
  37. astrbot/core/provider/sources/whisper_api_source.py +43 -11
  38. astrbot/core/utils/file_extract.py +23 -0
  39. astrbot/core/utils/tencent_record_helper.py +1 -1
  40. astrbot/core/utils/webhook_utils.py +47 -0
  41. astrbot/dashboard/routes/__init__.py +2 -0
  42. astrbot/dashboard/routes/chat.py +300 -70
  43. astrbot/dashboard/routes/config.py +32 -165
  44. astrbot/dashboard/routes/knowledge_base.py +1 -1
  45. astrbot/dashboard/routes/platform.py +100 -0
  46. astrbot/dashboard/routes/plugin.py +65 -6
  47. astrbot/dashboard/server.py +3 -1
  48. {astrbot-4.7.3.dist-info → astrbot-4.8.0.dist-info}/METADATA +48 -37
  49. {astrbot-4.7.3.dist-info → astrbot-4.8.0.dist-info}/RECORD +52 -49
  50. {astrbot-4.7.3.dist-info → astrbot-4.8.0.dist-info}/WHEEL +0 -0
  51. {astrbot-4.7.3.dist-info → astrbot-4.8.0.dist-info}/entry_points.txt +0 -0
  52. {astrbot-4.7.3.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")