AstrBot 4.7.4__py3-none-any.whl → 4.9.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 (111) hide show
  1. astrbot/cli/__init__.py +1 -1
  2. astrbot/core/agent/runners/tool_loop_agent_runner.py +0 -1
  3. astrbot/core/agent/tool.py +7 -2
  4. astrbot/core/astr_agent_run_util.py +15 -1
  5. astrbot/core/astr_agent_tool_exec.py +5 -1
  6. astrbot/core/config/astrbot_config.py +4 -0
  7. astrbot/core/config/default.py +116 -1
  8. astrbot/core/core_lifecycle.py +1 -1
  9. astrbot/core/db/__init__.py +32 -4
  10. astrbot/core/db/migration/migra_3_to_4.py +2 -0
  11. astrbot/core/db/migration/sqlite_v3.py +6 -4
  12. astrbot/core/db/po.py +16 -15
  13. astrbot/core/db/sqlite.py +56 -1
  14. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +2 -0
  15. astrbot/core/event_bus.py +6 -1
  16. astrbot/core/knowledge_base/retrieval/manager.py +5 -1
  17. astrbot/core/log.py +2 -1
  18. astrbot/core/message/components.py +9 -3
  19. astrbot/core/persona_mgr.py +2 -2
  20. astrbot/core/pipeline/content_safety_check/stage.py +1 -1
  21. astrbot/core/pipeline/context_utils.py +2 -1
  22. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +1 -1
  23. astrbot/core/pipeline/process_stage/method/star_request.py +1 -2
  24. astrbot/core/pipeline/process_stage/stage.py +1 -1
  25. astrbot/core/pipeline/respond/stage.py +4 -2
  26. astrbot/core/pipeline/result_decorate/stage.py +68 -21
  27. astrbot/core/pipeline/scheduler.py +5 -1
  28. astrbot/core/pipeline/waking_check/stage.py +10 -0
  29. astrbot/core/platform/astr_message_event.py +5 -3
  30. astrbot/core/platform/astrbot_message.py +2 -2
  31. astrbot/core/platform/manager.py +71 -9
  32. astrbot/core/platform/platform.py +109 -4
  33. astrbot/core/platform/platform_metadata.py +1 -1
  34. astrbot/core/platform/register.py +1 -0
  35. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +8 -6
  36. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +13 -8
  37. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +28 -22
  38. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +5 -2
  39. astrbot/core/platform/sources/discord/client.py +16 -4
  40. astrbot/core/platform/sources/discord/components.py +2 -2
  41. astrbot/core/platform/sources/discord/discord_platform_adapter.py +53 -26
  42. astrbot/core/platform/sources/discord/discord_platform_event.py +29 -8
  43. astrbot/core/platform/sources/lark/lark_adapter.py +178 -22
  44. astrbot/core/platform/sources/lark/lark_event.py +39 -4
  45. astrbot/core/platform/sources/lark/server.py +206 -0
  46. astrbot/core/platform/sources/misskey/misskey_adapter.py +3 -5
  47. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +64 -18
  48. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +14 -10
  49. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +36 -11
  50. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +15 -2
  51. astrbot/core/platform/sources/satori/satori_adapter.py +1 -2
  52. astrbot/core/platform/sources/slack/client.py +58 -40
  53. astrbot/core/platform/sources/slack/slack_adapter.py +36 -16
  54. astrbot/core/platform/sources/slack/slack_event.py +11 -10
  55. astrbot/core/platform/sources/telegram/tg_adapter.py +2 -3
  56. astrbot/core/platform/sources/telegram/tg_event.py +23 -27
  57. astrbot/core/platform/sources/webchat/webchat_adapter.py +97 -31
  58. astrbot/core/platform/sources/webchat/webchat_event.py +35 -35
  59. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +27 -11
  60. astrbot/core/platform/sources/wecom/wecom_adapter.py +75 -36
  61. astrbot/core/platform/sources/wecom/wecom_event.py +3 -3
  62. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +26 -9
  63. astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +3 -3
  64. astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +27 -5
  65. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +81 -35
  66. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +11 -8
  67. astrbot/core/platform_message_history_mgr.py +3 -3
  68. astrbot/core/provider/func_tool_manager.py +3 -3
  69. astrbot/core/provider/manager.py +130 -74
  70. astrbot/core/provider/provider.py +12 -1
  71. astrbot/core/provider/sources/azure_tts_source.py +31 -9
  72. astrbot/core/provider/sources/bailian_rerank_source.py +4 -0
  73. astrbot/core/provider/sources/dashscope_tts.py +3 -2
  74. astrbot/core/provider/sources/edge_tts_source.py +1 -1
  75. astrbot/core/provider/sources/fishaudio_tts_api_source.py +5 -4
  76. astrbot/core/provider/sources/gemini_embedding_source.py +15 -5
  77. astrbot/core/provider/sources/gemini_source.py +12 -10
  78. astrbot/core/provider/sources/minimax_tts_api_source.py +4 -2
  79. astrbot/core/provider/sources/openai_embedding_source.py +2 -2
  80. astrbot/core/provider/sources/openai_source.py +4 -0
  81. astrbot/core/provider/sources/sensevoice_selfhosted_source.py +5 -2
  82. astrbot/core/provider/sources/vllm_rerank_source.py +1 -0
  83. astrbot/core/provider/sources/whisper_api_source.py +44 -12
  84. astrbot/core/provider/sources/whisper_selfhosted_source.py +6 -2
  85. astrbot/core/provider/sources/xinference_rerank_source.py +10 -2
  86. astrbot/core/star/context.py +2 -2
  87. astrbot/core/star/register/star_handler.py +22 -5
  88. astrbot/core/star/star_handler.py +85 -4
  89. astrbot/core/updator.py +3 -3
  90. astrbot/core/utils/io.py +1 -1
  91. astrbot/core/utils/session_waiter.py +17 -10
  92. astrbot/core/utils/shared_preferences.py +32 -0
  93. astrbot/core/utils/t2i/__init__.py +2 -2
  94. astrbot/core/utils/t2i/local_strategy.py +25 -31
  95. astrbot/core/utils/tencent_record_helper.py +2 -2
  96. astrbot/core/utils/version_comparator.py +6 -3
  97. astrbot/core/utils/webhook_utils.py +66 -0
  98. astrbot/dashboard/routes/__init__.py +2 -0
  99. astrbot/dashboard/routes/chat.py +311 -76
  100. astrbot/dashboard/routes/config.py +14 -5
  101. astrbot/dashboard/routes/knowledge_base.py +254 -79
  102. astrbot/dashboard/routes/log.py +13 -8
  103. astrbot/dashboard/routes/platform.py +100 -0
  104. astrbot/dashboard/routes/plugin.py +108 -51
  105. astrbot/dashboard/routes/route.py +2 -0
  106. astrbot/dashboard/server.py +9 -4
  107. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/METADATA +50 -37
  108. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/RECORD +111 -108
  109. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/WHEEL +0 -0
  110. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/entry_points.txt +0 -0
  111. {astrbot-4.7.4.dist-info → astrbot-4.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -2,11 +2,13 @@ import asyncio
2
2
  import os
3
3
  import time
4
4
  import uuid
5
- from collections.abc import Awaitable, Callable
5
+ from collections.abc import Callable, Coroutine
6
6
  from typing import Any
7
7
 
8
8
  from astrbot import logger
9
- from astrbot.core.message.components import Image, Plain, Record
9
+ from astrbot.core import db_helper
10
+ from astrbot.core.db.po import PlatformMessageHistory
11
+ from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video
10
12
  from astrbot.core.message.message_event_result import MessageChain
11
13
  from astrbot.core.platform import (
12
14
  AstrBotMessage,
@@ -74,9 +76,8 @@ class WebChatAdapter(Platform):
74
76
  platform_settings: dict,
75
77
  event_queue: asyncio.Queue,
76
78
  ) -> None:
77
- super().__init__(event_queue)
79
+ super().__init__(platform_config, event_queue)
78
80
 
79
- self.config = platform_config
80
81
  self.settings = platform_settings
81
82
  self.unique_session = platform_settings["unique_session"]
82
83
  self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
@@ -96,6 +97,92 @@ class WebChatAdapter(Platform):
96
97
  await WebChatMessageEvent._send(message_chain, session.session_id)
97
98
  await super().send_by_session(session, message_chain)
98
99
 
100
+ async def _get_message_history(
101
+ self, message_id: int
102
+ ) -> PlatformMessageHistory | None:
103
+ return await db_helper.get_platform_message_history_by_id(message_id)
104
+
105
+ async def _parse_message_parts(
106
+ self,
107
+ message_parts: list,
108
+ depth: int = 0,
109
+ max_depth: int = 1,
110
+ ) -> tuple[list, list[str]]:
111
+ """解析消息段列表,返回消息组件列表和纯文本列表
112
+
113
+ Args:
114
+ message_parts: 消息段列表
115
+ depth: 当前递归深度
116
+ max_depth: 最大递归深度(用于处理 reply)
117
+
118
+ Returns:
119
+ tuple[list, list[str]]: (消息组件列表, 纯文本列表)
120
+ """
121
+ components = []
122
+ text_parts = []
123
+
124
+ for part in message_parts:
125
+ part_type = part.get("type")
126
+ if part_type == "plain":
127
+ text = part.get("text", "")
128
+ components.append(Plain(text))
129
+ text_parts.append(text)
130
+ elif part_type == "reply":
131
+ message_id = part.get("message_id")
132
+ reply_chain = []
133
+ reply_message_str = ""
134
+ sender_id = None
135
+ sender_name = None
136
+
137
+ # recursively get the content of the referenced message
138
+ if depth < max_depth and message_id:
139
+ history = await self._get_message_history(message_id)
140
+ if history and history.content:
141
+ reply_parts = history.content.get("message", [])
142
+ if isinstance(reply_parts, list):
143
+ (
144
+ reply_chain,
145
+ reply_text_parts,
146
+ ) = await self._parse_message_parts(
147
+ reply_parts,
148
+ depth=depth + 1,
149
+ max_depth=max_depth,
150
+ )
151
+ reply_message_str = "".join(reply_text_parts)
152
+ sender_id = history.sender_id
153
+ sender_name = history.sender_name
154
+
155
+ components.append(
156
+ Reply(
157
+ id=message_id,
158
+ chain=reply_chain,
159
+ message_str=reply_message_str,
160
+ sender_id=sender_id,
161
+ sender_nickname=sender_name,
162
+ )
163
+ )
164
+ elif part_type == "image":
165
+ path = part.get("path")
166
+ if path:
167
+ components.append(Image.fromFileSystem(path))
168
+ elif part_type == "record":
169
+ path = part.get("path")
170
+ if path:
171
+ components.append(Record.fromFileSystem(path))
172
+ elif part_type == "file":
173
+ path = part.get("path")
174
+ if path:
175
+ filename = part.get("filename") or (
176
+ os.path.basename(path) if path else "file"
177
+ )
178
+ components.append(File(name=filename, file=path))
179
+ elif part_type == "video":
180
+ path = part.get("path")
181
+ if path:
182
+ components.append(Video.fromFileSystem(path))
183
+
184
+ return components, text_parts
185
+
99
186
  async def convert_message(self, data: tuple) -> AstrBotMessage:
100
187
  username, cid, payload = data
101
188
 
@@ -108,40 +195,19 @@ class WebChatAdapter(Platform):
108
195
  abm.session_id = f"webchat!{username}!{cid}"
109
196
 
110
197
  abm.message_id = str(uuid.uuid4())
111
- abm.message = []
112
-
113
- if payload["message"]:
114
- abm.message.append(Plain(payload["message"]))
115
- if payload["image_url"]:
116
- if isinstance(payload["image_url"], list):
117
- for img in payload["image_url"]:
118
- abm.message.append(
119
- Image.fromFileSystem(os.path.join(self.imgs_dir, img)),
120
- )
121
- else:
122
- abm.message.append(
123
- Image.fromFileSystem(
124
- os.path.join(self.imgs_dir, payload["image_url"]),
125
- ),
126
- )
127
- if payload["audio_url"]:
128
- if isinstance(payload["audio_url"], list):
129
- for audio in payload["audio_url"]:
130
- path = os.path.join(self.imgs_dir, audio)
131
- abm.message.append(Record(file=path, path=path))
132
- else:
133
- path = os.path.join(self.imgs_dir, payload["audio_url"])
134
- abm.message.append(Record(file=path, path=path))
198
+
199
+ # 处理消息段列表
200
+ message_parts = payload.get("message", [])
201
+ abm.message, message_str_parts = await self._parse_message_parts(message_parts)
135
202
 
136
203
  logger.debug(f"WebChatAdapter: {abm.message}")
137
204
 
138
- message_str = payload["message"]
139
205
  abm.timestamp = int(time.time())
140
- abm.message_str = message_str
206
+ abm.message_str = "".join(message_str_parts)
141
207
  abm.raw_message = data
142
208
  return abm
143
209
 
144
- def run(self) -> Awaitable[Any]:
210
+ def run(self) -> Coroutine[Any, Any, None]:
145
211
  async def callback(data: tuple):
146
212
  abm = await self.convert_message(data)
147
213
  await self.handle_msg(abm)
@@ -1,12 +1,12 @@
1
1
  import base64
2
2
  import os
3
+ import shutil
3
4
  import uuid
4
5
 
5
6
  from astrbot.api import logger
6
7
  from astrbot.api.event import AstrMessageEvent, MessageChain
7
- from astrbot.api.message_components import Image, Plain, Record
8
+ from astrbot.api.message_components import File, Image, Plain, Record
8
9
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
9
- from astrbot.core.utils.io import download_image_by_url
10
10
 
11
11
  from .webchat_queue_mgr import webchat_queue_mgr
12
12
 
@@ -19,7 +19,9 @@ class WebChatMessageEvent(AstrMessageEvent):
19
19
  os.makedirs(imgs_dir, exist_ok=True)
20
20
 
21
21
  @staticmethod
22
- async def _send(message: MessageChain, session_id: str, streaming: bool = False):
22
+ async def _send(
23
+ message: MessageChain | None, session_id: str, streaming: bool = False
24
+ ) -> str | None:
23
25
  cid = session_id.split("!")[-1]
24
26
  web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
25
27
  if not message:
@@ -30,7 +32,7 @@ class WebChatMessageEvent(AstrMessageEvent):
30
32
  "streaming": False,
31
33
  }, # end means this request is finished
32
34
  )
33
- return ""
35
+ return
34
36
 
35
37
  data = ""
36
38
  for comp in message.chain:
@@ -47,24 +49,11 @@ class WebChatMessageEvent(AstrMessageEvent):
47
49
  )
48
50
  elif isinstance(comp, Image):
49
51
  # save image to local
50
- filename = str(uuid.uuid4()) + ".jpg"
52
+ filename = f"{str(uuid.uuid4())}.jpg"
51
53
  path = os.path.join(imgs_dir, filename)
52
- if comp.file and comp.file.startswith("file:///"):
53
- ph = comp.file[8:]
54
- with open(path, "wb") as f:
55
- with open(ph, "rb") as f2:
56
- f.write(f2.read())
57
- elif comp.file.startswith("base64://"):
58
- base64_str = comp.file[9:]
59
- image_data = base64.b64decode(base64_str)
60
- with open(path, "wb") as f:
61
- f.write(image_data)
62
- elif comp.file and comp.file.startswith("http"):
63
- await download_image_by_url(comp.file, path=path)
64
- else:
65
- with open(path, "wb") as f:
66
- with open(comp.file, "rb") as f2:
67
- f.write(f2.read())
54
+ image_base64 = await comp.convert_to_base64()
55
+ with open(path, "wb") as f:
56
+ f.write(base64.b64decode(image_base64))
68
57
  data = f"[IMAGE]{filename}"
69
58
  await web_chat_back_queue.put(
70
59
  {
@@ -76,19 +65,11 @@ class WebChatMessageEvent(AstrMessageEvent):
76
65
  )
77
66
  elif isinstance(comp, Record):
78
67
  # save record to local
79
- filename = str(uuid.uuid4()) + ".wav"
68
+ filename = f"{str(uuid.uuid4())}.wav"
80
69
  path = os.path.join(imgs_dir, filename)
81
- if comp.file and comp.file.startswith("file:///"):
82
- ph = comp.file[8:]
83
- with open(path, "wb") as f:
84
- with open(ph, "rb") as f2:
85
- f.write(f2.read())
86
- elif comp.file and comp.file.startswith("http"):
87
- await download_image_by_url(comp.file, path=path)
88
- else:
89
- with open(path, "wb") as f:
90
- with open(comp.file, "rb") as f2:
91
- f.write(f2.read())
70
+ record_base64 = await comp.convert_to_base64()
71
+ with open(path, "wb") as f:
72
+ f.write(base64.b64decode(record_base64))
92
73
  data = f"[RECORD]{filename}"
93
74
  await web_chat_back_queue.put(
94
75
  {
@@ -98,14 +79,31 @@ class WebChatMessageEvent(AstrMessageEvent):
98
79
  "streaming": streaming,
99
80
  },
100
81
  )
82
+ elif isinstance(comp, File):
83
+ # save file to local
84
+ file_path = await comp.get_file()
85
+ original_name = comp.name or os.path.basename(file_path)
86
+ ext = os.path.splitext(original_name)[1] or ""
87
+ filename = f"{uuid.uuid4()!s}{ext}"
88
+ dest_path = os.path.join(imgs_dir, filename)
89
+ shutil.copy2(file_path, dest_path)
90
+ data = f"[FILE]{filename}|{original_name}"
91
+ await web_chat_back_queue.put(
92
+ {
93
+ "type": "file",
94
+ "cid": cid,
95
+ "data": data,
96
+ "streaming": streaming,
97
+ },
98
+ )
101
99
  else:
102
100
  logger.debug(f"webchat 忽略: {comp.type}")
103
101
 
104
102
  return data
105
103
 
106
- async def send(self, message: MessageChain):
104
+ async def send(self, message: MessageChain | None):
107
105
  await WebChatMessageEvent._send(message, session_id=self.session_id)
108
- await super().send(message)
106
+ await super().send(MessageChain([]))
109
107
 
110
108
  async def send_streaming(self, generator, use_fallback: bool = False):
111
109
  final_data = ""
@@ -131,6 +129,8 @@ class WebChatMessageEvent(AstrMessageEvent):
131
129
  session_id=self.session_id,
132
130
  streaming=True,
133
131
  )
132
+ if not r:
133
+ continue
134
134
  if chain.type == "reasoning":
135
135
  reasoning_content += chain.get_plain_text()
136
136
  else:
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import time
6
6
  import traceback
7
+ from typing import cast
7
8
 
8
9
  import aiohttp
9
10
  import anyio
@@ -42,10 +43,9 @@ class WeChatPadProAdapter(Platform):
42
43
  platform_settings: dict,
43
44
  event_queue: asyncio.Queue,
44
45
  ) -> None:
45
- super().__init__(event_queue)
46
+ super().__init__(platform_config, event_queue)
46
47
  self._shutdown_event = None
47
48
  self.wxnewpass = None
48
- self.config = platform_config
49
49
  self.settings = platform_settings
50
50
  self.unique_session = platform_settings.get("unique_session", False)
51
51
 
@@ -70,7 +70,7 @@ class WeChatPadProAdapter(Platform):
70
70
  )
71
71
  self.base_url = f"http://{self.host}:{self.port}"
72
72
  self.auth_key = None # 用于保存生成的授权码
73
- self.wxid = None # 用于保存登录成功后的 wxid
73
+ self.wxid: str | None = None # 用于保存登录成功后的 wxid
74
74
  self.credentials_file = os.path.join(
75
75
  get_astrbot_data_path(),
76
76
  "wechatpadpro_credentials.json",
@@ -399,7 +399,7 @@ class WeChatPadProAdapter(Platform):
399
399
  )
400
400
  await asyncio.sleep(5)
401
401
 
402
- async def handle_websocket_message(self, message: str):
402
+ async def handle_websocket_message(self, message: str | bytes):
403
403
  """处理从 WebSocket 接收到的消息。"""
404
404
  logger.debug(f"收到 WebSocket 消息: {message}")
405
405
  try:
@@ -431,10 +431,13 @@ class WeChatPadProAdapter(Platform):
431
431
 
432
432
  async def convert_message(self, raw_message: dict) -> AstrBotMessage | None:
433
433
  """将 WeChatPadPro 原始消息转换为 AstrBotMessage。"""
434
+ if self.wxid is None:
435
+ logger.error("WeChatPadPro 适配器未登录或未获取到 wxid,无法处理消息。")
436
+ return None
434
437
  abm = AstrBotMessage()
435
438
  abm.raw_message = raw_message
436
439
  abm.message_id = str(raw_message.get("msg_id"))
437
- abm.timestamp = raw_message.get("create_time")
440
+ abm.timestamp = cast(int, raw_message.get("create_time"))
438
441
  abm.self_id = self.wxid
439
442
 
440
443
  if int(time.time()) - abm.timestamp > 180:
@@ -447,7 +450,7 @@ class WeChatPadProAdapter(Platform):
447
450
  to_user_name = raw_message.get("to_user_name", {}).get("str", "")
448
451
  content = raw_message.get("content", {}).get("str", "")
449
452
  push_content = raw_message.get("push_content", "")
450
- msg_type = raw_message.get("msg_type")
453
+ msg_type = cast(int, raw_message.get("msg_type"))
451
454
 
452
455
  abm.message_str = ""
453
456
  abm.message = []
@@ -575,7 +578,7 @@ class WeChatPadProAdapter(Platform):
575
578
  from_user_name: str,
576
579
  to_user_name: str,
577
580
  msg_id: int,
578
- ):
581
+ ) -> dict | None:
579
582
  """下载原始图片。"""
580
583
  url = f"{self.base_url}/message/GetMsgBigImg"
581
584
  params = {"key": self.auth_key}
@@ -726,12 +729,15 @@ class WeChatPadProAdapter(Platform):
726
729
  # 图片消息
727
730
  from_user_name = raw_message.get("from_user_name", {}).get("str", "")
728
731
  to_user_name = raw_message.get("to_user_name", {}).get("str", "")
729
- msg_id = raw_message.get("msg_id")
732
+ msg_id = cast(int, raw_message.get("msg_id"))
730
733
  image_resp = await self._download_raw_image(
731
734
  from_user_name,
732
735
  to_user_name,
733
736
  msg_id,
734
737
  )
738
+ if image_resp is None:
739
+ logger.error(f"下载图片失败: msg_id={msg_id}")
740
+ return
735
741
  image_bs64_data = (
736
742
  image_resp.get("Data", {}).get("Data", {}).get("Buffer", None)
737
743
  )
@@ -772,6 +778,9 @@ class WeChatPadProAdapter(Platform):
772
778
  bufid = 0
773
779
  to_user_name = raw_message.get("to_user_name", {}).get("str", "")
774
780
  new_msg_id = raw_message.get("new_msg_id")
781
+ if new_msg_id is None:
782
+ logger.error("语音消息缺少 new_msg_id")
783
+ return
775
784
  data_parser = GeweDataParser(
776
785
  content=content,
777
786
  is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
@@ -779,6 +788,9 @@ class WeChatPadProAdapter(Platform):
779
788
  )
780
789
 
781
790
  voicemsg = data_parser._format_to_xml().find("voicemsg")
791
+ if voicemsg is None:
792
+ logger.error("无法从 XML 解析 voicemsg 节点")
793
+ return
782
794
  bufid = voicemsg.get("bufid") or "0"
783
795
  length = int(voicemsg.get("length") or 0)
784
796
  voice_resp = await self.download_voice(
@@ -787,6 +799,9 @@ class WeChatPadProAdapter(Platform):
787
799
  bufid=bufid,
788
800
  length=length,
789
801
  )
802
+ if voice_resp is None:
803
+ logger.error(f"下载语音失败: new_msg_id={new_msg_id}")
804
+ return
790
805
  voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
791
806
  if voice_bs64_data:
792
807
  voice_bs64_data = base64.b64decode(voice_bs64_data)
@@ -828,7 +843,8 @@ class WeChatPadProAdapter(Platform):
828
843
  try:
829
844
  if self.ws_handle_task:
830
845
  self.ws_handle_task.cancel()
831
- self._shutdown_event.set()
846
+ if self._shutdown_event is not None:
847
+ self._shutdown_event.set()
832
848
  except Exception:
833
849
  pass
834
850
 
@@ -895,8 +911,8 @@ class WeChatPadProAdapter(Platform):
895
911
 
896
912
  async def get_contact_details_list(
897
913
  self,
898
- room_wx_id_list: list[str] = None,
899
- user_names: list[str] = None,
914
+ room_wx_id_list: list[str] | None = None,
915
+ user_names: list[str] | None = None,
900
916
  ) -> dict | None:
901
917
  """获取联系人详情列表。"""
902
918
  if room_wx_id_list is None:
@@ -2,6 +2,8 @@ import asyncio
2
2
  import os
3
3
  import sys
4
4
  import uuid
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any, cast
5
7
 
6
8
  import quart
7
9
  from requests import Response
@@ -24,6 +26,7 @@ from astrbot.api.platform import (
24
26
  from astrbot.core import logger
25
27
  from astrbot.core.platform.astr_message_event import MessageSesion
26
28
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
29
+ from astrbot.core.utils.webhook_utils import log_webhook_info
27
30
 
28
31
  from .wecom_event import WecomPlatformEvent
29
32
  from .wecom_kf import WeChatKF
@@ -38,7 +41,7 @@ else:
38
41
  class WecomServer:
39
42
  def __init__(self, event_queue: asyncio.Queue, config: dict):
40
43
  self.server = quart.Quart(__name__)
41
- self.port = int(config.get("port"))
44
+ self.port = int(cast(str, config.get("port")))
42
45
  self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
43
46
  self.server.add_url_rule(
44
47
  "/callback/command",
@@ -58,12 +61,24 @@ class WecomServer:
58
61
  config["corpid"].strip(),
59
62
  )
60
63
 
61
- self.callback = None
64
+ self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
62
65
  self.shutdown_event = asyncio.Event()
63
66
 
64
67
  async def verify(self):
65
- logger.info(f"验证请求有效性: {quart.request.args}")
66
- args = quart.request.args
68
+ """内部服务器的 GET 验证入口"""
69
+ return await self.handle_verify(quart.request)
70
+
71
+ async def handle_verify(self, request) -> str:
72
+ """处理验证请求,可被统一 webhook 入口复用
73
+
74
+ Args:
75
+ request: Quart 请求对象
76
+
77
+ Returns:
78
+ 验证响应
79
+ """
80
+ logger.info(f"验证请求有效性: {request.args}")
81
+ args = request.args
67
82
  try:
68
83
  echo_str = self.crypto.check_signature(
69
84
  args.get("msg_signature"),
@@ -78,17 +93,29 @@ class WecomServer:
78
93
  raise
79
94
 
80
95
  async def callback_command(self):
81
- data = await quart.request.get_data()
82
- msg_signature = quart.request.args.get("msg_signature")
83
- timestamp = quart.request.args.get("timestamp")
84
- nonce = quart.request.args.get("nonce")
96
+ """内部服务器的 POST 回调入口"""
97
+ return await self.handle_callback(quart.request)
98
+
99
+ async def handle_callback(self, request) -> str:
100
+ """处理回调请求,可被统一 webhook 入口复用
101
+
102
+ Args:
103
+ request: Quart 请求对象
104
+
105
+ Returns:
106
+ 响应内容
107
+ """
108
+ data = await request.get_data()
109
+ msg_signature = request.args.get("msg_signature")
110
+ timestamp = request.args.get("timestamp")
111
+ nonce = request.args.get("nonce")
85
112
  try:
86
113
  xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce)
87
114
  except InvalidSignatureException:
88
115
  logger.error("解密失败,签名异常,请检查配置。")
89
116
  raise
90
117
  else:
91
- msg = parse_message(xml)
118
+ msg = cast(BaseMessage, parse_message(xml))
92
119
  logger.info(f"解析成功: {msg}")
93
120
 
94
121
  if self.callback:
@@ -118,14 +145,14 @@ class WecomPlatformAdapter(Platform):
118
145
  platform_settings: dict,
119
146
  event_queue: asyncio.Queue,
120
147
  ) -> None:
121
- super().__init__(event_queue)
122
- self.config = platform_config
148
+ super().__init__(platform_config, event_queue)
123
149
  self.settingss = platform_settings
124
150
  self.client_self_id = uuid.uuid4().hex[:8]
125
151
  self.api_base_url = platform_config.get(
126
152
  "api_base_url",
127
153
  "https://qyapi.weixin.qq.com/cgi-bin/",
128
154
  )
155
+ self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
129
156
 
130
157
  if not self.api_base_url:
131
158
  self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/"
@@ -150,10 +177,10 @@ class WecomPlatformAdapter(Platform):
150
177
  # inject
151
178
  self.wechat_kf_api = WeChatKF(client=self.client)
152
179
  self.wechat_kf_message_api = WeChatKFMessage(self.client)
153
- self.client.kf = self.wechat_kf_api
154
- self.client.kf_message = self.wechat_kf_message_api
180
+ self.client.__setattr__("kf", self.wechat_kf_api)
181
+ self.client.__setattr__("kf_message", self.wechat_kf_message_api)
155
182
 
156
- self.client.API_BASE_URL = self.api_base_url
183
+ self.client.__setattr__("API_BASE_URL", self.api_base_url)
157
184
 
158
185
  async def callback(msg: BaseMessage):
159
186
  if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event":
@@ -232,41 +259,53 @@ class WecomPlatformAdapter(Platform):
232
259
  )
233
260
  except Exception as e:
234
261
  logger.error(e)
235
- await self.server.start_polling()
262
+
263
+ # 如果启用统一 webhook 模式,则不启动独立服务器
264
+ webhook_uuid = self.config.get("webhook_uuid")
265
+ if self.unified_webhook_mode and webhook_uuid:
266
+ log_webhook_info(f"{self.meta().id}(企业微信)", webhook_uuid)
267
+ # 保持运行状态,等待 shutdown
268
+ await self.server.shutdown_event.wait()
269
+ else:
270
+ await self.server.start_polling()
271
+
272
+ async def webhook_callback(self, request: Any) -> Any:
273
+ """统一 Webhook 回调入口"""
274
+ # 根据请求方法分发到不同的处理函数
275
+ if request.method == "GET":
276
+ return await self.server.handle_verify(request)
277
+ else:
278
+ return await self.server.handle_callback(request)
236
279
 
237
280
  async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
238
281
  abm = AstrBotMessage()
239
- if msg.type == "text":
240
- assert isinstance(msg, TextMessage)
282
+ if isinstance(msg, TextMessage):
241
283
  abm.message_str = msg.content
242
284
  abm.self_id = str(msg.agent)
243
285
  abm.message = [Plain(msg.content)]
244
286
  abm.type = MessageType.FRIEND_MESSAGE
245
287
  abm.sender = MessageMember(
246
- msg.source,
247
- msg.source,
288
+ cast(str, msg.source),
289
+ cast(str, msg.source),
248
290
  )
249
- abm.message_id = msg.id
250
- abm.timestamp = msg.time
291
+ abm.message_id = str(msg.id)
292
+ abm.timestamp = int(cast(int | str, msg.time))
251
293
  abm.session_id = abm.sender.user_id
252
294
  abm.raw_message = msg
253
- elif msg.type == "image":
254
- assert isinstance(msg, ImageMessage)
295
+ elif isinstance(msg, ImageMessage):
255
296
  abm.message_str = "[图片]"
256
297
  abm.self_id = str(msg.agent)
257
298
  abm.message = [Image(file=msg.image, url=msg.image)]
258
299
  abm.type = MessageType.FRIEND_MESSAGE
259
300
  abm.sender = MessageMember(
260
- msg.source,
261
- msg.source,
301
+ cast(str, msg.source),
302
+ cast(str, msg.source),
262
303
  )
263
- abm.message_id = msg.id
264
- abm.timestamp = msg.time
304
+ abm.message_id = str(msg.id)
305
+ abm.timestamp = int(cast(int | str, msg.time))
265
306
  abm.session_id = abm.sender.user_id
266
307
  abm.raw_message = msg
267
- elif msg.type == "voice":
268
- assert isinstance(msg, VoiceMessage)
269
-
308
+ elif isinstance(msg, VoiceMessage):
270
309
  resp: Response = await asyncio.get_event_loop().run_in_executor(
271
310
  None,
272
311
  self.client.media.download,
@@ -293,11 +332,11 @@ class WecomPlatformAdapter(Platform):
293
332
  abm.message = [Record(file=path_wav, url=path_wav)]
294
333
  abm.type = MessageType.FRIEND_MESSAGE
295
334
  abm.sender = MessageMember(
296
- msg.source,
297
- msg.source,
335
+ cast(str, msg.source),
336
+ cast(str, msg.source),
298
337
  )
299
- abm.message_id = msg.id
300
- abm.timestamp = msg.time
338
+ abm.message_id = str(msg.id)
339
+ abm.timestamp = int(cast(int | str, msg.time))
301
340
  abm.session_id = abm.sender.user_id
302
341
  abm.raw_message = msg
303
342
  else:
@@ -309,7 +348,7 @@ class WecomPlatformAdapter(Platform):
309
348
 
310
349
  async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
311
350
  msgtype = msg.get("msgtype")
312
- external_userid = msg.get("external_userid")
351
+ external_userid = cast(str, msg.get("external_userid"))
313
352
  abm = AstrBotMessage()
314
353
  abm.raw_message = msg
315
354
  abm.raw_message["_wechat_kf_flag"] = None # 方便处理
@@ -383,4 +422,4 @@ class WecomPlatformAdapter(Platform):
383
422
  await self.server.server.shutdown()
384
423
  except Exception as _:
385
424
  pass
386
- logger.info("企业微信 适配器已被优雅地关闭")
425
+ logger.info("企业微信 适配器已被关闭")
@@ -16,7 +16,7 @@ try:
16
16
  import pydub
17
17
  except Exception:
18
18
  logger.warning(
19
- "检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。",
19
+ "检测到 pydub 库未安装,企业微信将无法语音收发。如需使用语音,请前往管理面板 -> 平台日志 -> 安装 Pip 库安装 pydub。",
20
20
  )
21
21
 
22
22
 
@@ -93,10 +93,10 @@ class WecomPlatformEvent(AstrMessageEvent):
93
93
  if is_wechat_kf:
94
94
  # 微信客服
95
95
  kf_message_api = getattr(self.client, "kf_message", None)
96
- if not kf_message_api:
96
+ if not isinstance(kf_message_api, WeChatKFMessage):
97
97
  logger.warning("未找到微信客服发送消息方法。")
98
98
  return
99
- assert isinstance(kf_message_api, WeChatKFMessage)
99
+
100
100
  user_id = self.get_sender_id()
101
101
  for comp in message.chain:
102
102
  if isinstance(comp, Plain):