AstrBot 4.3.5__py3-none-any.whl → 4.5.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 (68) hide show
  1. astrbot/core/agent/runners/tool_loop_agent_runner.py +31 -2
  2. astrbot/core/astrbot_config_mgr.py +23 -51
  3. astrbot/core/config/default.py +92 -12
  4. astrbot/core/conversation_mgr.py +36 -1
  5. astrbot/core/core_lifecycle.py +24 -5
  6. astrbot/core/db/migration/migra_45_to_46.py +44 -0
  7. astrbot/core/db/vec_db/base.py +33 -2
  8. astrbot/core/db/vec_db/faiss_impl/document_storage.py +310 -52
  9. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +31 -3
  10. astrbot/core/db/vec_db/faiss_impl/vec_db.py +81 -23
  11. astrbot/core/file_token_service.py +6 -1
  12. astrbot/core/initial_loader.py +6 -3
  13. astrbot/core/knowledge_base/chunking/__init__.py +11 -0
  14. astrbot/core/knowledge_base/chunking/base.py +24 -0
  15. astrbot/core/knowledge_base/chunking/fixed_size.py +57 -0
  16. astrbot/core/knowledge_base/chunking/recursive.py +155 -0
  17. astrbot/core/knowledge_base/kb_db_sqlite.py +299 -0
  18. astrbot/core/knowledge_base/kb_helper.py +348 -0
  19. astrbot/core/knowledge_base/kb_mgr.py +287 -0
  20. astrbot/core/knowledge_base/models.py +114 -0
  21. astrbot/core/knowledge_base/parsers/__init__.py +15 -0
  22. astrbot/core/knowledge_base/parsers/base.py +50 -0
  23. astrbot/core/knowledge_base/parsers/markitdown_parser.py +25 -0
  24. astrbot/core/knowledge_base/parsers/pdf_parser.py +100 -0
  25. astrbot/core/knowledge_base/parsers/text_parser.py +41 -0
  26. astrbot/core/knowledge_base/parsers/util.py +13 -0
  27. astrbot/core/knowledge_base/retrieval/__init__.py +16 -0
  28. astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
  29. astrbot/core/knowledge_base/retrieval/manager.py +273 -0
  30. astrbot/core/knowledge_base/retrieval/rank_fusion.py +138 -0
  31. astrbot/core/knowledge_base/retrieval/sparse_retriever.py +130 -0
  32. astrbot/core/pipeline/process_stage/method/llm_request.py +29 -7
  33. astrbot/core/pipeline/process_stage/utils.py +80 -0
  34. astrbot/core/platform/astr_message_event.py +8 -7
  35. astrbot/core/platform/sources/misskey/misskey_adapter.py +380 -44
  36. astrbot/core/platform/sources/misskey/misskey_api.py +581 -45
  37. astrbot/core/platform/sources/misskey/misskey_event.py +76 -41
  38. astrbot/core/platform/sources/misskey/misskey_utils.py +254 -43
  39. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
  40. astrbot/core/platform/sources/satori/satori_adapter.py +27 -1
  41. astrbot/core/platform/sources/satori/satori_event.py +270 -99
  42. astrbot/core/provider/manager.py +14 -9
  43. astrbot/core/provider/provider.py +67 -0
  44. astrbot/core/provider/sources/anthropic_source.py +4 -4
  45. astrbot/core/provider/sources/dashscope_source.py +10 -9
  46. astrbot/core/provider/sources/dify_source.py +6 -8
  47. astrbot/core/provider/sources/gemini_embedding_source.py +1 -2
  48. astrbot/core/provider/sources/openai_embedding_source.py +1 -2
  49. astrbot/core/provider/sources/openai_source.py +18 -15
  50. astrbot/core/provider/sources/openai_tts_api_source.py +1 -1
  51. astrbot/core/star/context.py +3 -0
  52. astrbot/core/star/star.py +6 -0
  53. astrbot/core/star/star_manager.py +13 -7
  54. astrbot/core/umop_config_router.py +81 -0
  55. astrbot/core/updator.py +1 -1
  56. astrbot/core/utils/io.py +23 -12
  57. astrbot/dashboard/routes/__init__.py +2 -0
  58. astrbot/dashboard/routes/config.py +137 -9
  59. astrbot/dashboard/routes/knowledge_base.py +1065 -0
  60. astrbot/dashboard/routes/plugin.py +24 -5
  61. astrbot/dashboard/routes/update.py +1 -1
  62. astrbot/dashboard/server.py +6 -0
  63. astrbot/dashboard/utils.py +161 -0
  64. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/METADATA +29 -13
  65. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/RECORD +68 -44
  66. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/WHEEL +0 -0
  67. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/entry_points.txt +0 -0
  68. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -40,48 +40,83 @@ class MisskeyPlatformEvent(AstrMessageEvent):
40
40
  return any(message_trimmed.startswith(prefix) for prefix in system_prefixes)
41
41
 
42
42
  async def send(self, message: MessageChain):
43
- content, has_at = serialize_message_chain(message.chain)
44
-
45
- if not content:
46
- logger.debug("[MisskeyEvent] 内容为空,跳过发送")
47
- return
48
-
43
+ """发送消息,使用适配器的完整上传和发送逻辑"""
49
44
  try:
50
- original_message_id = getattr(self.message_obj, "message_id", None)
51
- raw_message = getattr(self.message_obj, "raw_message", {})
52
-
53
- if raw_message and not has_at:
54
- user_data = raw_message.get("user", {})
55
- user_info = {
56
- "username": user_data.get("username", ""),
57
- "nickname": user_data.get("name", user_data.get("username", "")),
58
- }
59
- content = add_at_mention_if_needed(content, user_info, has_at)
60
-
61
- # 根据会话类型选择发送方式
62
- if hasattr(self.client, "send_message") and is_valid_user_session_id(
63
- self.session_id
64
- ):
65
- user_id = extract_user_id_from_session_id(self.session_id)
66
- await self.client.send_message(user_id, content)
67
- elif hasattr(self.client, "send_room_message") and is_valid_room_session_id(
68
- self.session_id
69
- ):
70
- room_id = extract_room_id_from_session_id(self.session_id)
71
- await self.client.send_room_message(room_id, content)
72
- elif original_message_id and hasattr(self.client, "create_note"):
73
- visibility, visible_user_ids = resolve_visibility_from_raw_message(
74
- raw_message
75
- )
76
- await self.client.create_note(
77
- content,
78
- reply_id=original_message_id,
79
- visibility=visibility,
80
- visible_user_ids=visible_user_ids,
81
- )
82
- elif hasattr(self.client, "create_note"):
83
- logger.debug("[MisskeyEvent] 创建新帖子")
84
- await self.client.create_note(content)
45
+ logger.debug(
46
+ f"[MisskeyEvent] send 方法被调用,消息链包含 {len(message.chain)} 个组件"
47
+ )
48
+
49
+ # 使用适配器的 send_by_session 方法,它包含文件上传逻辑
50
+ from astrbot.core.platform.message_session import MessageSession
51
+ from astrbot.core.platform.message_type import MessageType
52
+
53
+ # 根据session_id类型确定消息类型
54
+ if is_valid_user_session_id(self.session_id):
55
+ message_type = MessageType.FRIEND_MESSAGE
56
+ elif is_valid_room_session_id(self.session_id):
57
+ message_type = MessageType.GROUP_MESSAGE
58
+ else:
59
+ message_type = MessageType.FRIEND_MESSAGE # 默认
60
+
61
+ session = MessageSession(
62
+ platform_name=self.platform_meta.name,
63
+ message_type=message_type,
64
+ session_id=self.session_id,
65
+ )
66
+
67
+ logger.debug(
68
+ f"[MisskeyEvent] 检查适配器方法: hasattr(self.client, 'send_by_session') = {hasattr(self.client, 'send_by_session')}"
69
+ )
70
+
71
+ # 调用适配器的 send_by_session 方法
72
+ if hasattr(self.client, "send_by_session"):
73
+ logger.debug("[MisskeyEvent] 调用适配器的 send_by_session 方法")
74
+ await self.client.send_by_session(session, message)
75
+ else:
76
+ # 回退到原来的简化发送逻辑
77
+ content, has_at = serialize_message_chain(message.chain)
78
+
79
+ if not content:
80
+ logger.debug("[MisskeyEvent] 内容为空,跳过发送")
81
+ return
82
+
83
+ original_message_id = getattr(self.message_obj, "message_id", None)
84
+ raw_message = getattr(self.message_obj, "raw_message", {})
85
+
86
+ if raw_message and not has_at:
87
+ user_data = raw_message.get("user", {})
88
+ user_info = {
89
+ "username": user_data.get("username", ""),
90
+ "nickname": user_data.get(
91
+ "name", user_data.get("username", "")
92
+ ),
93
+ }
94
+ content = add_at_mention_if_needed(content, user_info, has_at)
95
+
96
+ # 根据会话类型选择发送方式
97
+ if hasattr(self.client, "send_message") and is_valid_user_session_id(
98
+ self.session_id
99
+ ):
100
+ user_id = extract_user_id_from_session_id(self.session_id)
101
+ await self.client.send_message(user_id, content)
102
+ elif hasattr(
103
+ self.client, "send_room_message"
104
+ ) and is_valid_room_session_id(self.session_id):
105
+ room_id = extract_room_id_from_session_id(self.session_id)
106
+ await self.client.send_room_message(room_id, content)
107
+ elif original_message_id and hasattr(self.client, "create_note"):
108
+ visibility, visible_user_ids = resolve_visibility_from_raw_message(
109
+ raw_message
110
+ )
111
+ await self.client.create_note(
112
+ content,
113
+ reply_id=original_message_id,
114
+ visibility=visibility,
115
+ visible_user_ids=visible_user_ids,
116
+ )
117
+ elif hasattr(self.client, "create_note"):
118
+ logger.debug("[MisskeyEvent] 创建新帖子")
119
+ await self.client.create_note(content)
85
120
 
86
121
  await super().send(message)
87
122
 
@@ -5,6 +5,68 @@ import astrbot.api.message_components as Comp
5
5
  from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
6
6
 
7
7
 
8
+ class FileIDExtractor:
9
+ """从 API 响应中提取文件 ID 的帮助类(无状态)。"""
10
+
11
+ @staticmethod
12
+ def extract_file_id(result: Any) -> Optional[str]:
13
+ if not isinstance(result, dict):
14
+ return None
15
+
16
+ id_paths = [
17
+ lambda r: r.get("createdFile", {}).get("id"),
18
+ lambda r: r.get("file", {}).get("id"),
19
+ lambda r: r.get("id"),
20
+ ]
21
+
22
+ for p in id_paths:
23
+ try:
24
+ if fid := p(result):
25
+ return fid
26
+ except Exception:
27
+ continue
28
+
29
+ return None
30
+
31
+
32
+ class MessagePayloadBuilder:
33
+ """构建不同类型消息负载的帮助类(无状态)。"""
34
+
35
+ @staticmethod
36
+ def build_chat_payload(
37
+ user_id: str, text: Optional[str], file_id: Optional[str] = None
38
+ ) -> Dict[str, Any]:
39
+ payload = {"toUserId": user_id}
40
+ if text:
41
+ payload["text"] = text
42
+ if file_id:
43
+ payload["fileId"] = file_id
44
+ return payload
45
+
46
+ @staticmethod
47
+ def build_room_payload(
48
+ room_id: str, text: Optional[str], file_id: Optional[str] = None
49
+ ) -> Dict[str, Any]:
50
+ payload = {"toRoomId": room_id}
51
+ if text:
52
+ payload["text"] = text
53
+ if file_id:
54
+ payload["fileId"] = file_id
55
+ return payload
56
+
57
+ @staticmethod
58
+ def build_note_payload(
59
+ text: Optional[str], file_ids: Optional[List[str]] = None, **kwargs
60
+ ) -> Dict[str, Any]:
61
+ payload: Dict[str, Any] = {}
62
+ if text:
63
+ payload["text"] = text
64
+ if file_ids:
65
+ payload["fileIds"] = file_ids
66
+ payload |= kwargs
67
+ return payload
68
+
69
+
8
70
  def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]:
9
71
  """将消息链序列化为文本字符串"""
10
72
  text_parts = []
@@ -15,11 +77,19 @@ def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]:
15
77
  if isinstance(component, Comp.Plain):
16
78
  return component.text
17
79
  elif isinstance(component, Comp.File):
18
- file_name = getattr(component, "name", "文件")
19
- return f"[文件: {file_name}]"
80
+ # 为文件组件返回占位符,但适配器仍会处理原组件
81
+ return "[文件]"
82
+ elif isinstance(component, Comp.Image):
83
+ # 为图片组件返回占位符,但适配器仍会处理原组件
84
+ return "[图片]"
20
85
  elif isinstance(component, Comp.At):
21
86
  has_at = True
22
- return f"@{component.qq}"
87
+ # 优先使用name字段(用户名),如果没有则使用qq字段
88
+ # 这样可以避免在Misskey中生成 @<user_id> 这样的无效提及
89
+ if hasattr(component, "name") and component.name:
90
+ return f"@{component.name}"
91
+ else:
92
+ return f"@{component.qq}"
23
93
  elif hasattr(component, "text"):
24
94
  text = getattr(component, "text", "")
25
95
  if "@" in text:
@@ -43,15 +113,22 @@ def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]:
43
113
 
44
114
 
45
115
  def resolve_message_visibility(
46
- user_id: Optional[str],
47
- user_cache: Dict[str, Any],
48
- self_id: Optional[str],
116
+ user_id: Optional[str] = None,
117
+ user_cache: Optional[Dict[str, Any]] = None,
118
+ self_id: Optional[str] = None,
119
+ raw_message: Optional[Dict[str, Any]] = None,
49
120
  default_visibility: str = "public",
50
121
  ) -> Tuple[str, Optional[List[str]]]:
51
- """解析 Misskey 消息的可见性设置"""
122
+ """解析 Misskey 消息的可见性设置
123
+
124
+ 可以从 user_cache 或 raw_message 中解析,支持两种调用方式:
125
+ 1. 基于 user_cache: resolve_message_visibility(user_id, user_cache, self_id)
126
+ 2. 基于 raw_message: resolve_message_visibility(raw_message=raw_message, self_id=self_id)
127
+ """
52
128
  visibility = default_visibility
53
129
  visible_user_ids = None
54
130
 
131
+ # 优先从 user_cache 解析
55
132
  if user_id and user_cache:
56
133
  user_info = user_cache.get(user_id)
57
134
  if user_info:
@@ -66,38 +143,36 @@ def resolve_message_visibility(
66
143
  visible_user_ids = [uid for uid in visible_user_ids if uid]
67
144
  else:
68
145
  visibility = original_visibility
146
+ return visibility, visible_user_ids
147
+
148
+ # 回退到从 raw_message 解析
149
+ if raw_message:
150
+ original_visibility = raw_message.get("visibility", default_visibility)
151
+ if original_visibility == "specified":
152
+ visibility = "specified"
153
+ original_visible_users = raw_message.get("visibleUserIds", [])
154
+ sender_id = raw_message.get("userId", "")
155
+
156
+ users_to_include = []
157
+ if sender_id:
158
+ users_to_include.append(sender_id)
159
+ if self_id:
160
+ users_to_include.append(self_id)
161
+
162
+ visible_user_ids = list(set(original_visible_users + users_to_include))
163
+ visible_user_ids = [uid for uid in visible_user_ids if uid]
164
+ else:
165
+ visibility = original_visibility
69
166
 
70
167
  return visibility, visible_user_ids
71
168
 
72
169
 
170
+ # 保留旧函数名作为向后兼容的别名
73
171
  def resolve_visibility_from_raw_message(
74
172
  raw_message: Dict[str, Any], self_id: Optional[str] = None
75
173
  ) -> Tuple[str, Optional[List[str]]]:
76
- """从原始消息数据中解析可见性设置"""
77
- visibility = "public"
78
- visible_user_ids = None
79
-
80
- if not raw_message:
81
- return visibility, visible_user_ids
82
-
83
- original_visibility = raw_message.get("visibility", "public")
84
- if original_visibility == "specified":
85
- visibility = "specified"
86
- original_visible_users = raw_message.get("visibleUserIds", [])
87
- sender_id = raw_message.get("userId", "")
88
-
89
- users_to_include = []
90
- if sender_id:
91
- users_to_include.append(sender_id)
92
- if self_id:
93
- users_to_include.append(self_id)
94
-
95
- visible_user_ids = list(set(original_visible_users + users_to_include))
96
- visible_user_ids = [uid for uid in visible_user_ids if uid]
97
- else:
98
- visibility = original_visibility
99
-
100
- return visibility, visible_user_ids
174
+ """从原始消息数据中解析可见性设置(已弃用,使用 resolve_message_visibility 替代)"""
175
+ return resolve_message_visibility(raw_message=raw_message, self_id=self_id)
101
176
 
102
177
 
103
178
  def is_valid_user_session_id(session_id: Union[str, Any]) -> bool:
@@ -128,6 +203,20 @@ def is_valid_room_session_id(session_id: Union[str, Any]) -> bool:
128
203
  )
129
204
 
130
205
 
206
+ def is_valid_chat_session_id(session_id: Union[str, Any]) -> bool:
207
+ """检查 session_id 是否是有效的聊天 session_id (仅限chat%前缀)"""
208
+ if not isinstance(session_id, str) or "%" not in session_id:
209
+ return False
210
+
211
+ parts = session_id.split("%")
212
+ return (
213
+ len(parts) == 2
214
+ and parts[0] == "chat"
215
+ and bool(parts[1])
216
+ and parts[1] != "unknown"
217
+ )
218
+
219
+
131
220
  def extract_user_id_from_session_id(session_id: str) -> str:
132
221
  """从 session_id 中提取用户 ID"""
133
222
  if "%" in session_id:
@@ -149,21 +238,22 @@ def extract_room_id_from_session_id(session_id: str) -> str:
149
238
  def add_at_mention_if_needed(
150
239
  text: str, user_info: Optional[Dict[str, Any]], has_at: bool = False
151
240
  ) -> str:
152
- """如果需要且没有@用户,则添加@用户"""
241
+ """如果需要且没有@用户,则添加@用户
242
+
243
+ 注意:仅在有有效的username时才添加@提及,避免使用用户ID
244
+ """
153
245
  if has_at or not user_info:
154
246
  return text
155
247
 
156
248
  username = user_info.get("username")
157
- nickname = user_info.get("nickname")
249
+ # 如果没有username,则不添加@提及,返回原文本
250
+ # 这样可以避免生成 @<user_id> 这样的无效提及
251
+ if not username:
252
+ return text
158
253
 
159
- if username:
160
- mention = f"@{username}"
161
- if not text.startswith(mention):
162
- text = f"{mention}\n{text}".strip()
163
- elif nickname:
164
- mention = f"@{nickname}"
165
- if not text.startswith(mention):
166
- text = f"{mention}\n{text}".strip()
254
+ mention = f"@{username}"
255
+ if not text.startswith(mention):
256
+ text = f"{mention}\n{text}".strip()
167
257
 
168
258
  return text
169
259
 
@@ -197,6 +287,22 @@ def process_files(
197
287
  return file_parts
198
288
 
199
289
 
290
+ def format_poll(poll: Dict[str, Any]) -> str:
291
+ """将 Misskey 的 poll 对象格式化为可读字符串。"""
292
+ if not poll or not isinstance(poll, dict):
293
+ return ""
294
+ multiple = poll.get("multiple", False)
295
+ choices = poll.get("choices", [])
296
+ text_choices = [
297
+ f"({idx}) {c.get('text', '')} [{c.get('votes', 0)}票]"
298
+ for idx, c in enumerate(choices, start=1)
299
+ ]
300
+ parts = ["[投票]", ("允许多选" if multiple else "单选")] + (
301
+ ["选项: " + ", ".join(text_choices)] if text_choices else []
302
+ )
303
+ return " ".join(parts)
304
+
305
+
200
306
  def extract_sender_info(
201
307
  raw_data: Dict[str, Any], is_chat: bool = False
202
308
  ) -> Dict[str, Any]:
@@ -248,7 +354,7 @@ def create_base_message(
248
354
  else:
249
355
  session_prefix = "note"
250
356
  session_id = f"{session_prefix}%{sender_info['sender_id']}"
251
- message.type = MessageType.FRIEND_MESSAGE
357
+ message.type = MessageType.OTHER_MESSAGE
252
358
 
253
359
  message.session_id = (
254
360
  session_id if sender_info["sender_id"] else f"{session_prefix}%unknown"
@@ -303,6 +409,8 @@ def cache_user_info(
303
409
  "nickname": sender_info["nickname"],
304
410
  "visibility": raw_data.get("visibility", "public"),
305
411
  "visible_user_ids": raw_data.get("visibleUserIds", []),
412
+ # 保存原消息ID,用于回复时作为reply_id
413
+ "reply_to_note_id": raw_data.get("id"),
306
414
  }
307
415
 
308
416
  user_cache[sender_info["sender_id"]] = user_cache_data
@@ -325,3 +433,106 @@ def cache_room_info(
325
433
  "visibility": "specified",
326
434
  "visible_user_ids": [client_self_id],
327
435
  }
436
+
437
+
438
+ async def resolve_component_url_or_path(
439
+ comp: Any,
440
+ ) -> Tuple[Optional[str], Optional[str]]:
441
+ """尝试从组件解析可上传的远程 URL 或本地路径。
442
+
443
+ 返回 (url_candidate, local_path)。两者可能都为 None。
444
+ 这个函数尽量不抛异常,调用方可按需处理 None。
445
+ """
446
+ url_candidate = None
447
+ local_path = None
448
+
449
+ async def _get_str_value(coro_or_val):
450
+ """辅助函数:统一处理协程或普通值"""
451
+ try:
452
+ if hasattr(coro_or_val, "__await__"):
453
+ result = await coro_or_val
454
+ else:
455
+ result = coro_or_val
456
+ return result if isinstance(result, str) else None
457
+ except Exception:
458
+ return None
459
+
460
+ try:
461
+ # 1. 尝试异步方法
462
+ for method in ["convert_to_file_path", "get_file", "register_to_file_service"]:
463
+ if not hasattr(comp, method):
464
+ continue
465
+ try:
466
+ value = await _get_str_value(getattr(comp, method)())
467
+ if value:
468
+ if value.startswith("http"):
469
+ url_candidate = value
470
+ break
471
+ else:
472
+ local_path = value
473
+ except Exception:
474
+ continue
475
+
476
+ # 2. 尝试 get_file(True) 获取可直接访问的 URL
477
+ if not url_candidate and hasattr(comp, "get_file"):
478
+ try:
479
+ value = await _get_str_value(comp.get_file(True))
480
+ if value and value.startswith("http"):
481
+ url_candidate = value
482
+ except Exception:
483
+ pass
484
+
485
+ # 3. 回退到同步属性
486
+ if not url_candidate and not local_path:
487
+ for attr in ("file", "url", "path", "src", "source"):
488
+ try:
489
+ value = getattr(comp, attr, None)
490
+ if value and isinstance(value, str):
491
+ if value.startswith("http"):
492
+ url_candidate = value
493
+ break
494
+ else:
495
+ local_path = value
496
+ break
497
+ except Exception:
498
+ continue
499
+
500
+ except Exception:
501
+ pass
502
+
503
+ return url_candidate, local_path
504
+
505
+
506
+ def summarize_component_for_log(comp: Any) -> Dict[str, Any]:
507
+ """生成适合日志的组件属性字典(尽量不抛异常)。"""
508
+ attrs = {}
509
+ for a in ("file", "url", "path", "src", "source", "name"):
510
+ try:
511
+ v = getattr(comp, a, None)
512
+ if v is not None:
513
+ attrs[a] = v
514
+ except Exception:
515
+ continue
516
+ return attrs
517
+
518
+
519
+ async def upload_local_with_retries(
520
+ api: Any,
521
+ local_path: str,
522
+ preferred_name: Optional[str],
523
+ folder_id: Optional[str],
524
+ ) -> Optional[str]:
525
+ """尝试本地上传,返回 file id 或 None。如果文件类型不允许则直接失败。"""
526
+ try:
527
+ res = await api.upload_file(local_path, preferred_name, folder_id)
528
+ if isinstance(res, dict):
529
+ fid = res.get("id") or (res.get("raw") or {}).get("createdFile", {}).get(
530
+ "id"
531
+ )
532
+ if fid:
533
+ return str(fid)
534
+ except Exception:
535
+ # 上传失败,直接返回 None,让上层处理错误
536
+ return None
537
+
538
+ return None
@@ -15,12 +15,13 @@ class QQOfficialWebhook:
15
15
  self.appid = config["appid"]
16
16
  self.secret = config["secret"]
17
17
  self.port = config.get("port", 6196)
18
+ self.is_sandbox = config.get("is_sandbox", False)
18
19
  self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
19
20
 
20
21
  if isinstance(self.port, str):
21
22
  self.port = int(self.port)
22
23
 
23
- self.http: BotHttp = BotHttp(timeout=300)
24
+ self.http: BotHttp = BotHttp(timeout=300, is_sandbox=self.is_sandbox)
24
25
  self.api: BotAPI = BotAPI(http=self.http)
25
26
  self.token = Token(self.appid, self.secret)
26
27
 
@@ -499,10 +499,36 @@ class SatoriPlatformAdapter(Platform):
499
499
  }
500
500
 
501
501
  return None
502
+ except ET.ParseError as e:
503
+ logger.warning(f"XML解析失败,使用正则提取: {e}")
504
+ return await self._extract_quote_with_regex(content)
502
505
  except Exception as e:
503
506
  logger.error(f"提取<quote>标签时发生错误: {e}")
504
507
  return None
505
508
 
509
+ async def _extract_quote_with_regex(self, content: str) -> Optional[dict]:
510
+ """使用正则表达式提取quote标签信息"""
511
+ import re
512
+
513
+ quote_pattern = r"<quote\s+([^>]*)>(.*?)</quote>"
514
+ match = re.search(quote_pattern, content, re.DOTALL)
515
+
516
+ if not match:
517
+ return None
518
+
519
+ attrs_str = match.group(1)
520
+ inner_content = match.group(2)
521
+
522
+ id_match = re.search(r'id\s*=\s*["\']([^"\']*)["\']', attrs_str)
523
+ quote_id = id_match.group(1) if id_match else ""
524
+ content_without_quote = content.replace(match.group(0), "")
525
+ content_without_quote = content_without_quote.strip()
526
+
527
+ return {
528
+ "quote": {"id": quote_id, "content": inner_content},
529
+ "content_without_quote": content_without_quote,
530
+ }
531
+
506
532
  async def _convert_quote_message(self, quote: dict) -> Optional[AstrBotMessage]:
507
533
  """转换引用消息"""
508
534
  try:
@@ -574,7 +600,7 @@ class SatoriPlatformAdapter(Platform):
574
600
  root = ET.fromstring(processed_content)
575
601
  await self._parse_xml_node(root, elements)
576
602
  except ET.ParseError as e:
577
- logger.error(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
603
+ logger.warning(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
578
604
  # 如果解析失败,将整个内容当作纯文本
579
605
  if content.strip():
580
606
  elements.append(Plain(text=content))