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
@@ -21,6 +21,7 @@ from astrbot.api.platform import (
21
21
  PlatformMetadata,
22
22
  )
23
23
  from astrbot.core.platform.astr_message_event import MessageSesion
24
+ from astrbot.core.utils.webhook_utils import log_webhook_info
24
25
 
25
26
  from ...register import register_platform_adapter
26
27
  from .client import SlackSocketClient, SlackWebhookClient
@@ -39,9 +40,7 @@ class SlackAdapter(Platform):
39
40
  platform_settings: dict,
40
41
  event_queue: asyncio.Queue,
41
42
  ) -> None:
42
- super().__init__(event_queue)
43
-
44
- self.config = platform_config
43
+ super().__init__(platform_config, event_queue)
45
44
  self.settings = platform_settings
46
45
  self.unique_session = platform_settings.get("unique_session", False)
47
46
 
@@ -49,6 +48,7 @@ class SlackAdapter(Platform):
49
48
  self.app_token = platform_config.get("app_token")
50
49
  self.signing_secret = platform_config.get("signing_secret")
51
50
  self.connection_mode = platform_config.get("slack_connection_mode", "socket")
51
+ self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
52
52
  self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0")
53
53
  self.webhook_port = platform_config.get("slack_webhook_port", 3000)
54
54
  self.webhook_path = platform_config.get(
@@ -361,10 +361,17 @@ class SlackAdapter(Platform):
361
361
  self._handle_webhook_event,
362
362
  )
363
363
 
364
- logger.info(
365
- f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}...",
366
- )
367
- await self.webhook_client.start()
364
+ # 如果启用统一 webhook 模式,则不启动独立服务器
365
+ webhook_uuid = self.config.get("webhook_uuid")
366
+ if self.unified_webhook_mode and webhook_uuid:
367
+ log_webhook_info(f"{self.meta().id}(Slack)", webhook_uuid)
368
+ # 保持运行状态,等待 shutdown
369
+ await self.webhook_client.shutdown_event.wait()
370
+ else:
371
+ logger.info(
372
+ f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}...",
373
+ )
374
+ await self.webhook_client.start()
368
375
 
369
376
  else:
370
377
  raise ValueError(
@@ -391,6 +398,13 @@ class SlackAdapter(Platform):
391
398
  if abm:
392
399
  await self.handle_msg(abm)
393
400
 
401
+ async def webhook_callback(self, request: Any) -> Any:
402
+ """统一 Webhook 回调入口"""
403
+ if self.connection_mode != "webhook" or not self.webhook_client:
404
+ return {"error": "Slack adapter is not in webhook mode"}, 400
405
+
406
+ return await self.webhook_client.handle_callback(request)
407
+
394
408
  async def terminate(self):
395
409
  if self.socket_client:
396
410
  await self.socket_client.stop()
@@ -31,7 +31,7 @@ class SlackMessageEvent(AstrMessageEvent):
31
31
  async def _from_segment_to_slack_block(
32
32
  segment: BaseMessageComponent,
33
33
  web_client: AsyncWebClient,
34
- ) -> dict:
34
+ ) -> dict | None:
35
35
  """将消息段转换为 Slack 块格式"""
36
36
  if isinstance(segment, Plain):
37
37
  return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}}
@@ -85,7 +85,6 @@ class SlackMessageEvent(AstrMessageEvent):
85
85
  "text": f"文件: <{file_url}|{segment.name or '文件'}>",
86
86
  },
87
87
  }
88
- return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
89
88
 
90
89
  @staticmethod
91
90
  async def _parse_slack_blocks(
@@ -115,7 +114,8 @@ class SlackMessageEvent(AstrMessageEvent):
115
114
  segment,
116
115
  web_client,
117
116
  )
118
- blocks.append(block)
117
+ if block:
118
+ blocks.append(block)
119
119
 
120
120
  # 如果最后还有文本内容
121
121
  if text_content.strip():
@@ -42,8 +42,7 @@ class TelegramPlatformAdapter(Platform):
42
42
  platform_settings: dict,
43
43
  event_queue: asyncio.Queue,
44
44
  ) -> None:
45
- super().__init__(event_queue)
46
- self.config = platform_config
45
+ super().__init__(platform_config, event_queue)
47
46
  self.settings = platform_settings
48
47
  self.client_self_id = uuid.uuid4().hex[:8]
49
48
 
@@ -381,7 +380,9 @@ class TelegramPlatformAdapter(Platform):
381
380
  f"Telegram document file_path is None, cannot save the file {file_name}.",
382
381
  )
383
382
  else:
384
- message.message.append(Comp.File(file=file_path, name=file_name))
383
+ message.message.append(
384
+ Comp.File(file=file_path, name=file_name, url=file_path)
385
+ )
385
386
 
386
387
  elif update.message.video:
387
388
  file = await update.message.video.get_file()
@@ -6,7 +6,9 @@ from collections.abc import Awaitable, Callable
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,36 +195,15 @@ 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
 
@@ -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,6 +79,23 @@ 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
 
@@ -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:
@@ -42,10 +42,9 @@ class WeChatPadProAdapter(Platform):
42
42
  platform_settings: dict,
43
43
  event_queue: asyncio.Queue,
44
44
  ) -> None:
45
- super().__init__(event_queue)
45
+ super().__init__(platform_config, event_queue)
46
46
  self._shutdown_event = None
47
47
  self.wxnewpass = None
48
- self.config = platform_config
49
48
  self.settings = platform_settings
50
49
  self.unique_session = platform_settings.get("unique_session", False)
51
50
 
@@ -2,6 +2,7 @@ import asyncio
2
2
  import os
3
3
  import sys
4
4
  import uuid
5
+ from typing import Any
5
6
 
6
7
  import quart
7
8
  from requests import Response
@@ -24,6 +25,7 @@ from astrbot.api.platform import (
24
25
  from astrbot.core import logger
25
26
  from astrbot.core.platform.astr_message_event import MessageSesion
26
27
  from astrbot.core.utils.astrbot_path import get_astrbot_data_path
28
+ from astrbot.core.utils.webhook_utils import log_webhook_info
27
29
 
28
30
  from .wecom_event import WecomPlatformEvent
29
31
  from .wecom_kf import WeChatKF
@@ -62,8 +64,20 @@ class WecomServer:
62
64
  self.shutdown_event = asyncio.Event()
63
65
 
64
66
  async def verify(self):
65
- logger.info(f"验证请求有效性: {quart.request.args}")
66
- args = quart.request.args
67
+ """内部服务器的 GET 验证入口"""
68
+ return await self.handle_verify(quart.request)
69
+
70
+ async def handle_verify(self, request) -> str:
71
+ """处理验证请求,可被统一 webhook 入口复用
72
+
73
+ Args:
74
+ request: Quart 请求对象
75
+
76
+ Returns:
77
+ 验证响应
78
+ """
79
+ logger.info(f"验证请求有效性: {request.args}")
80
+ args = request.args
67
81
  try:
68
82
  echo_str = self.crypto.check_signature(
69
83
  args.get("msg_signature"),
@@ -78,10 +92,22 @@ class WecomServer:
78
92
  raise
79
93
 
80
94
  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")
95
+ """内部服务器的 POST 回调入口"""
96
+ return await self.handle_callback(quart.request)
97
+
98
+ async def handle_callback(self, request) -> str:
99
+ """处理回调请求,可被统一 webhook 入口复用
100
+
101
+ Args:
102
+ request: Quart 请求对象
103
+
104
+ Returns:
105
+ 响应内容
106
+ """
107
+ data = await request.get_data()
108
+ msg_signature = request.args.get("msg_signature")
109
+ timestamp = request.args.get("timestamp")
110
+ nonce = request.args.get("nonce")
85
111
  try:
86
112
  xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce)
87
113
  except InvalidSignatureException:
@@ -118,14 +144,14 @@ class WecomPlatformAdapter(Platform):
118
144
  platform_settings: dict,
119
145
  event_queue: asyncio.Queue,
120
146
  ) -> None:
121
- super().__init__(event_queue)
122
- self.config = platform_config
147
+ super().__init__(platform_config, event_queue)
123
148
  self.settingss = platform_settings
124
149
  self.client_self_id = uuid.uuid4().hex[:8]
125
150
  self.api_base_url = platform_config.get(
126
151
  "api_base_url",
127
152
  "https://qyapi.weixin.qq.com/cgi-bin/",
128
153
  )
154
+ self.unified_webhook_mode = platform_config.get("unified_webhook_mode", False)
129
155
 
130
156
  if not self.api_base_url:
131
157
  self.api_base_url = "https://qyapi.weixin.qq.com/cgi-bin/"
@@ -232,7 +258,23 @@ class WecomPlatformAdapter(Platform):
232
258
  )
233
259
  except Exception as e:
234
260
  logger.error(e)
235
- await self.server.start_polling()
261
+
262
+ # 如果启用统一 webhook 模式,则不启动独立服务器
263
+ webhook_uuid = self.config.get("webhook_uuid")
264
+ if self.unified_webhook_mode and webhook_uuid:
265
+ log_webhook_info(f"{self.meta().id}(企业微信)", webhook_uuid)
266
+ # 保持运行状态,等待 shutdown
267
+ await self.server.shutdown_event.wait()
268
+ else:
269
+ await self.server.start_polling()
270
+
271
+ async def webhook_callback(self, request: Any) -> Any:
272
+ """统一 Webhook 回调入口"""
273
+ # 根据请求方法分发到不同的处理函数
274
+ if request.method == "GET":
275
+ return await self.server.handle_verify(request)
276
+ else:
277
+ return await self.server.handle_callback(request)
236
278
 
237
279
  async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
238
280
  abm = AstrBotMessage()
@@ -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
 
@@ -22,6 +22,7 @@ from astrbot.api.platform import (
22
22
  PlatformMetadata,
23
23
  )
24
24
  from astrbot.core.platform.astr_message_event import MessageSesion
25
+ from astrbot.core.utils.webhook_utils import log_webhook_info
25
26
 
26
27
  from ...register import register_platform_adapter
27
28
  from .wecomai_api import (
@@ -103,9 +104,7 @@ class WecomAIBotAdapter(Platform):
103
104
  platform_settings: dict,
104
105
  event_queue: asyncio.Queue,
105
106
  ) -> None:
106
- super().__init__(event_queue)
107
-
108
- self.config = platform_config
107
+ super().__init__(platform_config, event_queue)
109
108
  self.settings = platform_settings
110
109
 
111
110
  # 初始化配置参数
@@ -122,6 +121,7 @@ class WecomAIBotAdapter(Platform):
122
121
  "wecomaibot_friend_message_welcome_text",
123
122
  "",
124
123
  )
124
+ self.unified_webhook_mode = self.config.get("unified_webhook_mode", False)
125
125
 
126
126
  # 平台元数据
127
127
  self.metadata = PlatformMetadata(
@@ -425,17 +425,34 @@ class WecomAIBotAdapter(Platform):
425
425
 
426
426
  def run(self) -> Awaitable[Any]:
427
427
  """运行适配器,同时启动HTTP服务器和队列监听器"""
428
- logger.info("启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port)
429
428
 
430
429
  async def run_both():
431
- # 同时运行HTTP服务器和队列监听器
432
- await asyncio.gather(
433
- self.server.start_server(),
434
- self.queue_listener.run(),
435
- )
430
+ # 如果启用统一 webhook 模式,则不启动独立服务器
431
+ webhook_uuid = self.config.get("webhook_uuid")
432
+ if self.unified_webhook_mode and webhook_uuid:
433
+ log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
434
+ # 只运行队列监听器
435
+ await self.queue_listener.run()
436
+ else:
437
+ logger.info(
438
+ "启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
439
+ )
440
+ # 同时运行HTTP服务器和队列监听器
441
+ await asyncio.gather(
442
+ self.server.start_server(),
443
+ self.queue_listener.run(),
444
+ )
436
445
 
437
446
  return run_both()
438
447
 
448
+ async def webhook_callback(self, request: Any) -> Any:
449
+ """统一 Webhook 回调入口"""
450
+ # 根据请求方法分发到不同的处理函数
451
+ if request.method == "GET":
452
+ return await self.server.handle_verify(request)
453
+ else:
454
+ return await self.server.handle_callback(request)
455
+
439
456
  async def terminate(self):
440
457
  """终止适配器"""
441
458
  logger.info("企业微信智能机器人适配器正在关闭...")
@@ -59,8 +59,19 @@ class WecomAIBotServer:
59
59
  )
60
60
 
61
61
  async def verify_url(self):
62
- """验证回调 URL"""
63
- args = quart.request.args
62
+ """内部服务器的 GET 验证入口"""
63
+ return await self.handle_verify(quart.request)
64
+
65
+ async def handle_verify(self, request):
66
+ """处理 URL 验证请求,可被统一 webhook 入口复用
67
+
68
+ Args:
69
+ request: Quart 请求对象
70
+
71
+ Returns:
72
+ 验证响应元组 (content, status_code, headers)
73
+ """
74
+ args = request.args
64
75
  msg_signature = args.get("msg_signature")
65
76
  timestamp = args.get("timestamp")
66
77
  nonce = args.get("nonce")
@@ -81,8 +92,19 @@ class WecomAIBotServer:
81
92
  return result, 200, {"Content-Type": "text/plain"}
82
93
 
83
94
  async def handle_message(self):
84
- """处理消息回调"""
85
- args = quart.request.args
95
+ """内部服务器的 POST 消息回调入口"""
96
+ return await self.handle_callback(quart.request)
97
+
98
+ async def handle_callback(self, request):
99
+ """处理消息回调,可被统一 webhook 入口复用
100
+
101
+ Args:
102
+ request: Quart 请求对象
103
+
104
+ Returns:
105
+ 响应元组 (content, status_code, headers)
106
+ """
107
+ args = request.args
86
108
  msg_signature = args.get("msg_signature")
87
109
  timestamp = args.get("timestamp")
88
110
  nonce = args.get("nonce")
@@ -102,7 +124,7 @@ class WecomAIBotServer:
102
124
 
103
125
  try:
104
126
  # 获取请求体
105
- post_data = await quart.request.get_data()
127
+ post_data = await request.get_data()
106
128
 
107
129
  # 确保 post_data 是 bytes 类型
108
130
  if isinstance(post_data, str):