AstrBot 4.3.5__py3-none-any.whl → 4.5.1__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/core/agent/runners/tool_loop_agent_runner.py +31 -2
- astrbot/core/astrbot_config_mgr.py +23 -51
- astrbot/core/config/default.py +132 -12
- astrbot/core/conversation_mgr.py +36 -1
- astrbot/core/core_lifecycle.py +24 -5
- astrbot/core/db/migration/helper.py +6 -3
- astrbot/core/db/migration/migra_45_to_46.py +44 -0
- astrbot/core/db/vec_db/base.py +33 -2
- astrbot/core/db/vec_db/faiss_impl/document_storage.py +310 -52
- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +31 -3
- astrbot/core/db/vec_db/faiss_impl/vec_db.py +81 -23
- astrbot/core/file_token_service.py +6 -1
- astrbot/core/initial_loader.py +6 -3
- astrbot/core/knowledge_base/chunking/__init__.py +11 -0
- astrbot/core/knowledge_base/chunking/base.py +24 -0
- astrbot/core/knowledge_base/chunking/fixed_size.py +57 -0
- astrbot/core/knowledge_base/chunking/recursive.py +155 -0
- astrbot/core/knowledge_base/kb_db_sqlite.py +299 -0
- astrbot/core/knowledge_base/kb_helper.py +348 -0
- astrbot/core/knowledge_base/kb_mgr.py +287 -0
- astrbot/core/knowledge_base/models.py +114 -0
- astrbot/core/knowledge_base/parsers/__init__.py +15 -0
- astrbot/core/knowledge_base/parsers/base.py +50 -0
- astrbot/core/knowledge_base/parsers/markitdown_parser.py +25 -0
- astrbot/core/knowledge_base/parsers/pdf_parser.py +100 -0
- astrbot/core/knowledge_base/parsers/text_parser.py +41 -0
- astrbot/core/knowledge_base/parsers/util.py +13 -0
- astrbot/core/knowledge_base/retrieval/__init__.py +16 -0
- astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
- astrbot/core/knowledge_base/retrieval/manager.py +273 -0
- astrbot/core/knowledge_base/retrieval/rank_fusion.py +138 -0
- astrbot/core/knowledge_base/retrieval/sparse_retriever.py +130 -0
- astrbot/core/pipeline/process_stage/method/llm_request.py +29 -7
- astrbot/core/pipeline/process_stage/utils.py +80 -0
- astrbot/core/platform/astr_message_event.py +8 -7
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +5 -2
- astrbot/core/platform/sources/misskey/misskey_adapter.py +380 -44
- astrbot/core/platform/sources/misskey/misskey_api.py +581 -45
- astrbot/core/platform/sources/misskey/misskey_event.py +76 -41
- astrbot/core/platform/sources/misskey/misskey_utils.py +254 -43
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
- astrbot/core/platform/sources/satori/satori_adapter.py +27 -1
- astrbot/core/platform/sources/satori/satori_event.py +270 -99
- astrbot/core/provider/manager.py +22 -9
- astrbot/core/provider/provider.py +67 -0
- astrbot/core/provider/sources/anthropic_source.py +4 -4
- astrbot/core/provider/sources/dashscope_source.py +10 -9
- astrbot/core/provider/sources/dify_source.py +6 -8
- astrbot/core/provider/sources/gemini_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_source.py +43 -15
- astrbot/core/provider/sources/openai_tts_api_source.py +1 -1
- astrbot/core/provider/sources/xinference_rerank_source.py +108 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +187 -0
- astrbot/core/star/context.py +19 -13
- astrbot/core/star/star.py +6 -0
- astrbot/core/star/star_manager.py +13 -7
- astrbot/core/umop_config_router.py +81 -0
- astrbot/core/updator.py +1 -1
- astrbot/core/utils/io.py +23 -12
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +137 -9
- astrbot/dashboard/routes/knowledge_base.py +1065 -0
- astrbot/dashboard/routes/plugin.py +24 -5
- astrbot/dashboard/routes/update.py +1 -1
- astrbot/dashboard/server.py +6 -0
- astrbot/dashboard/utils.py +161 -0
- {astrbot-4.3.5.dist-info → astrbot-4.5.1.dist-info}/METADATA +30 -13
- {astrbot-4.3.5.dist-info → astrbot-4.5.1.dist-info}/RECORD +72 -46
- {astrbot-4.3.5.dist-info → astrbot-4.5.1.dist-info}/WHEEL +0 -0
- {astrbot-4.3.5.dist-info → astrbot-4.5.1.dist-info}/entry_points.txt +0 -0
- {astrbot-4.3.5.dist-info → astrbot-4.5.1.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
|
-
|
|
44
|
-
|
|
45
|
-
if not content:
|
|
46
|
-
logger.debug("[MisskeyEvent] 内容为空,跳过发送")
|
|
47
|
-
return
|
|
48
|
-
|
|
43
|
+
"""发送消息,使用适配器的完整上传和发送逻辑"""
|
|
49
44
|
try:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
19
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
# 如果没有username,则不添加@提及,返回原文本
|
|
250
|
+
# 这样可以避免生成 @<user_id> 这样的无效提及
|
|
251
|
+
if not username:
|
|
252
|
+
return text
|
|
158
253
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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.
|
|
603
|
+
logger.warning(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
|
|
578
604
|
# 如果解析失败,将整个内容当作纯文本
|
|
579
605
|
if content.strip():
|
|
580
606
|
elements.append(Plain(text=content))
|