diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index bfeec753b..7d5b89334 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -376,16 +376,6 @@ CONFIG_METADATA_2 = {
"satori_heartbeat_interval": 10,
"satori_reconnect_delay": 5,
},
- "WeChatPadPro": {
- "id": "wechatpadpro",
- "type": "wechatpadpro",
- "enable": False,
- "admin_key": "stay33",
- "host": "这里填写你的局域网IP或者公网服务器IP",
- "port": 8059,
- "wpp_active_message_poll": False,
- "wpp_active_message_poll_interval": 3,
- },
# "WebChat": {
# "id": "webchat",
# "type": "webchat",
diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py
index 00fb41c51..50599e818 100644
--- a/astrbot/core/pipeline/waking_check/stage.py
+++ b/astrbot/core/pipeline/waking_check/stage.py
@@ -22,7 +22,6 @@ UNIQUE_SESSION_ID_BUILDERS: dict[str, Callable[[AstrMessageEvent], str | None]]
"qq_official_webhook": lambda e: e.get_sender_id(),
"lark": lambda e: f"{e.get_sender_id()}%{e.get_group_id()}",
"misskey": lambda e: f"{e.get_session_id()}_{e.get_sender_id()}",
- "wechatpadpro": lambda e: f"{e.get_group_id()}#{e.get_sender_id()}",
}
diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py
index f4313f642..6b33693e7 100644
--- a/astrbot/core/platform/manager.py
+++ b/astrbot/core/platform/manager.py
@@ -70,10 +70,6 @@ class PlatformManager:
from .sources.qqofficial_webhook.qo_webhook_adapter import (
QQOfficialWebhookPlatformAdapter, # noqa: F401
)
- case "wechatpadpro":
- from .sources.wechatpadpro.wechatpadpro_adapter import (
- WeChatPadProAdapter, # noqa: F401
- )
case "lark":
from .sources.lark.lark_adapter import (
LarkPlatformAdapter, # noqa: F401
diff --git a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py b/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
deleted file mode 100644
index 214ac782c..000000000
--- a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py
+++ /dev/null
@@ -1,940 +0,0 @@
-import asyncio
-import base64
-import json
-import os
-import time
-import traceback
-from typing import cast
-
-import aiohttp
-import anyio
-import websockets
-
-from astrbot import logger
-from astrbot.api.message_components import At, Image, Plain, Record
-from astrbot.api.platform import Platform, PlatformMetadata
-from astrbot.core.message.message_event_result import MessageChain
-from astrbot.core.platform.astr_message_event import MessageSesion
-from astrbot.core.platform.astrbot_message import (
- AstrBotMessage,
- MessageMember,
- MessageType,
-)
-from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-
-from ...register import register_platform_adapter
-from .wechatpadpro_message_event import WeChatPadProMessageEvent
-
-try:
- from .xml_data_parser import GeweDataParser
-except ImportError as e:
- logger.warning(
- f"警告: 可能未安装 defusedxml 依赖库,将导致无法解析微信的 表情包、引用 类型的消息: {e!s}",
- )
-
-
-@register_platform_adapter(
- "wechatpadpro", "WeChatPadPro 消息平台适配器", support_streaming_message=False
-)
-class WeChatPadProAdapter(Platform):
- def __init__(
- self,
- platform_config: dict,
- platform_settings: dict,
- event_queue: asyncio.Queue,
- ) -> None:
- super().__init__(platform_config, event_queue)
- self._shutdown_event = None
- self.wxnewpass = None
- self.settings = platform_settings
-
- self.metadata = PlatformMetadata(
- name="wechatpadpro",
- description="WeChatPadPro 消息平台适配器",
- id=self.config.get("id", "wechatpadpro"),
- support_streaming_message=False,
- )
-
- # 保存配置信息
- self.admin_key = self.config.get("admin_key")
- self.host = self.config.get("host")
- self.port = self.config.get("port")
- self.active_mesasge_poll: bool = self.config.get(
- "wpp_active_message_poll",
- False,
- )
- self.active_message_poll_interval: int = self.config.get(
- "wpp_active_message_poll_interval",
- 5,
- )
- self.base_url = f"http://{self.host}:{self.port}"
- self.auth_key = None # 用于保存生成的授权码
- self.wxid: str | None = None # 用于保存登录成功后的 wxid
- self.credentials_file = os.path.join(
- get_astrbot_data_path(),
- "wechatpadpro_credentials.json",
- ) # 持久化文件路径
- self.ws_handle_task = None
-
- # 添加图片消息缓存,用于引用消息处理
- self.cached_images = {}
- """缓存图片消息。key是NewMsgId (对应引用消息的svrid),value是图片的base64数据"""
- # 设置缓存大小限制,避免内存占用过大
- self.max_image_cache = 50
-
- # 添加文本消息缓存,用于引用消息处理
- self.cached_texts = {}
- """缓存文本消息。key是NewMsgId (对应引用消息的svrid),value是消息文本内容"""
- # 设置文本缓存大小限制
- self.max_text_cache = 100
-
- async def run(self) -> None:
- """启动平台适配器的运行实例。"""
- logger.info("WeChatPadPro 适配器正在启动...")
-
- if loaded_credentials := self.load_credentials():
- self.auth_key = loaded_credentials.get("auth_key")
- self.wxid = loaded_credentials.get("wxid")
-
- isLoginIn = await self.check_online_status()
-
- # 检查在线状态
- if self.auth_key and isLoginIn:
- logger.info("WeChatPadPro 设备已在线,凭据存在,跳过扫码登录。")
- # 如果在线,连接 WebSocket 接收消息
- self.ws_handle_task = asyncio.create_task(self.connect_websocket())
- else:
- # 1. 生成授权码
- if not self.auth_key:
- logger.info("WeChatPadPro 无可用凭据,将生成新的授权码。")
- await self.generate_auth_key()
-
- # 2. 获取登录二维码
- if not isLoginIn:
- logger.info("WeChatPadPro 设备已离线,开始扫码登录。")
- qr_code_url = await self.get_login_qr_code()
-
- if qr_code_url:
- logger.info(f"请扫描以下二维码登录: {qr_code_url}")
- else:
- logger.error("无法获取登录二维码。")
- return
-
- # 3. 检测扫码状态
- login_successful = await self.check_login_status()
-
- if login_successful:
- logger.info("登录成功,WeChatPadPro适配器已连接。")
- else:
- logger.warning("登录失败或超时,WeChatPadPro 适配器将关闭。")
- await self.terminate()
- return
-
- # 登录成功后,连接 WebSocket 接收消息
- self.ws_handle_task = asyncio.create_task(self.connect_websocket())
-
- self._shutdown_event = asyncio.Event()
- await self._shutdown_event.wait()
- logger.info("WeChatPadPro 适配器已停止。")
-
- def load_credentials(self):
- """从文件中加载 auth_key 和 wxid。"""
- if os.path.exists(self.credentials_file):
- try:
- with open(self.credentials_file) as f:
- credentials = json.load(f)
- logger.info("成功加载 WeChatPadPro 凭据。")
- return credentials
- except Exception as e:
- logger.error(f"加载 WeChatPadPro 凭据失败: {e}")
- return None
-
- def save_credentials(self):
- """将 auth_key 和 wxid 保存到文件。"""
- credentials = {
- "auth_key": self.auth_key,
- "wxid": self.wxid,
- }
- try:
- # 确保数据目录存在
- data_dir = os.path.dirname(self.credentials_file)
- os.makedirs(data_dir, exist_ok=True)
- with open(self.credentials_file, "w") as f:
- json.dump(credentials, f)
- except Exception as e:
- logger.error(f"保存 WeChatPadPro 凭据失败: {e}")
-
- async def check_online_status(self):
- """检查 WeChatPadPro 设备是否在线。"""
- if not self.auth_key:
- return False
- url = f"{self.base_url}/login/GetLoginStatus"
- params = {"key": self.auth_key}
-
- async with aiohttp.ClientSession() as session:
- try:
- async with session.get(url, params=params) as response:
- response_data = await response.json()
- # 根据提供的在线接口返回示例,成功状态码是 200,loginState 为 1 表示在线
- if response.status == 200 and response_data.get("Code") == 200:
- login_state = response_data.get("Data", {}).get("loginState")
- if login_state == 1:
- logger.info("WeChatPadPro 设备当前在线。")
- return True
- # login_state == 3 为离线状态
- if login_state == 3:
- logger.info("WeChatPadPro 设备不在线。")
- return False
- logger.error(f"未知的在线状态: {response_data}")
- return False
- # Code == 300 为微信退出状态。
- if response.status == 200 and response_data.get("Code") == 300:
- logger.info("WeChatPadPro 设备已退出。")
- return False
- if response.status == 200 and response_data.get("Code") == -2:
- # 该链接不存在
- self.auth_key = None
- return False
- logger.error(
- f"检查在线状态失败: {response.status}, {response_data}",
- )
- return False
-
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return False
- except Exception as e:
- logger.error(f"检查在线状态时发生错误: {e}")
- logger.error(traceback.format_exc())
- return False
-
- def _extract_auth_key(self, data):
- """Helper method to extract auth_key from response data."""
- if isinstance(data, dict):
- auth_keys = data.get("authKeys") # 新接口
- if isinstance(auth_keys, list) and auth_keys:
- return auth_keys[0]
- elif isinstance(data, list) and data: # 旧接口
- return data[0]
- return None
-
- async def generate_auth_key(self):
- """生成授权码。"""
- url = f"{self.base_url}/admin/GenAuthKey1"
- params = {"key": self.admin_key}
- payload = {"Count": 1, "Days": 365} # 生成一个有效期365天的授权码
-
- self.auth_key = None # Reset auth_key before generating a new one
-
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- if response.status != 200:
- logger.error(
- f"生成授权码失败: {response.status}, {await response.text()}",
- )
- return
-
- response_data = await response.json()
- if response_data.get("Code") == 200:
- if data := response_data.get("Data"):
- self.auth_key = self._extract_auth_key(data)
-
- if self.auth_key:
- logger.info("成功获取授权码")
- else:
- logger.error(
- f"生成授权码成功但未找到授权码: {response_data}",
- )
- else:
- logger.error(f"生成授权码失败: {response_data}")
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- except Exception as e:
- logger.error(f"生成授权码时发生错误: {e}")
-
- async def get_login_qr_code(self):
- """获取登录二维码地址。"""
- url = f"{self.base_url}/login/GetLoginQrCodeNew"
- params = {"key": self.auth_key}
- payload = {} # 根据文档,这个接口的 body 可以为空
-
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- response_data = await response.json()
- if response.status == 200 and response_data.get("Code") == 200:
- # 二维码地址在 Data.QrCodeUrl 字段中
- if response_data.get("Data") and response_data["Data"].get(
- "QrCodeUrl",
- ):
- return response_data["Data"]["QrCodeUrl"]
- logger.error(
- f"获取登录二维码成功但未找到二维码地址: {response_data}",
- )
- return None
- if "该 key 无效" in response_data.get("Text"):
- logger.error(
- "授权码无效,已经清除。请重新启动 AstrBot 或者本消息适配器。原因也可能是 WeChatPadPro 的 MySQL 服务没有启动成功,请检查 WeChatPadPro 服务的日志。",
- )
- self.auth_key = None
- self.save_credentials()
- return None
- logger.error(
- f"获取登录二维码失败: {response.status}, {response_data}",
- )
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"获取登录二维码时发生错误: {e}")
- return None
-
- async def check_login_status(self):
- """循环检测扫码状态。
- 尝试 6 次后跳出循环,添加倒计时。
- 返回 True 如果登录成功,否则返回 False。
- """
- url = f"{self.base_url}/login/CheckLoginStatus"
- params = {"key": self.auth_key}
-
- attempts = 0 # 初始化尝试次数
- max_attempts = 36 # 最大尝试次数
- countdown = 180 # 倒计时时长
- logger.info(f"请在 {countdown} 秒内扫码登录。")
- while attempts < max_attempts:
- async with aiohttp.ClientSession() as session:
- try:
- async with session.get(url, params=params) as response:
- response_data = await response.json()
- # 成功判断条件和数据提取路径
- if response.status == 200 and response_data.get("Code") == 200:
- if (
- response_data.get("Data")
- and response_data["Data"].get("state") is not None
- ):
- status = response_data["Data"]["state"]
- logger.info(
- f"第 {attempts + 1} 次尝试,当前登录状态: {status},还剩{countdown - attempts * 5}秒",
- )
- if status == 2: # 状态 2 表示登录成功
- self.wxid = response_data["Data"].get("wxid")
- self.wxnewpass = response_data["Data"].get(
- "wxnewpass",
- )
- logger.info(
- f"登录成功,wxid: {self.wxid}, wxnewpass: {self.wxnewpass}",
- )
- self.save_credentials() # 登录成功后保存凭据
- return True
- if status == -2: # 二维码过期
- logger.error("二维码已过期,请重新获取。")
- return False
- else:
- logger.error(
- f"检测登录状态成功但未找到登录状态: {response_data}",
- )
- elif response_data.get("Code") == 300:
- # "不存在状态"
- pass
- else:
- logger.info(
- f"检测登录状态失败: {response.status}, {response_data}",
- )
-
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- await asyncio.sleep(5)
- attempts += 1
- continue
- except Exception as e:
- logger.error(f"检测登录状态时发生错误: {e}")
- attempts += 1
- continue
-
- attempts += 1
- await asyncio.sleep(5) # 每隔5秒检测一次
- logger.warning("登录检测超过最大尝试次数,退出检测。")
- return False
-
- async def connect_websocket(self):
- """建立 WebSocket 连接并处理接收到的消息。"""
- os.environ["no_proxy"] = f"localhost,127.0.0.1,{self.host}"
- ws_url = f"ws://{self.host}:{self.port}/ws/GetSyncMsg?key={self.auth_key}"
- logger.info(
- f"正在连接 WebSocket: ws://{self.host}:{self.port}/ws/GetSyncMsg?key=***",
- )
- while True:
- try:
- async with websockets.connect(ws_url) as websocket:
- logger.debug("WebSocket 连接成功。")
- # 设置空闲超时重连
- wait_time = (
- self.active_message_poll_interval
- if self.active_mesasge_poll
- else 120
- )
- while True:
- try:
- message = await asyncio.wait_for(
- websocket.recv(),
- timeout=wait_time,
- )
- # logger.debug(message) # 不显示原始消息内容
- asyncio.create_task(self.handle_websocket_message(message))
- except asyncio.TimeoutError:
- logger.debug(f"WebSocket 连接空闲超过 {wait_time} s")
- break
- except websockets.exceptions.ConnectionClosedOK:
- logger.info("WebSocket 连接正常关闭。")
- break
- except Exception as e:
- logger.error(f"处理 WebSocket 消息时发生错误: {e}")
- break
- except Exception as e:
- logger.error(
- f"WebSocket 连接失败: {e}, 请检查WeChatPadPro服务状态,或尝试重启WeChatPadPro适配器。",
- )
- await asyncio.sleep(5)
-
- async def handle_websocket_message(self, message: str | bytes):
- """处理从 WebSocket 接收到的消息。"""
- logger.debug(f"收到 WebSocket 消息: {message}")
- try:
- message_data = json.loads(message)
- if (
- message_data.get("msg_id") is not None
- and message_data.get("from_user_name") is not None
- ):
- abm = await self.convert_message(message_data)
- if abm:
- # 创建 WeChatPadProMessageEvent 实例
- message_event = WeChatPadProMessageEvent(
- message_str=abm.message_str,
- message_obj=abm,
- platform_meta=self.meta(),
- session_id=abm.session_id,
- # 传递适配器实例,以便在事件中调用 send 方法
- adapter=self,
- )
- # 提交事件到事件队列
- self.commit_event(message_event)
- else:
- logger.warning(f"收到未知结构的 WebSocket 消息: {message_data}")
-
- except json.JSONDecodeError:
- logger.error(f"无法解析 WebSocket 消息为 JSON: {message}")
- except Exception as e:
- logger.error(f"处理 WebSocket 消息时发生错误: {e}")
-
- async def convert_message(self, raw_message: dict) -> AstrBotMessage | None:
- """将 WeChatPadPro 原始消息转换为 AstrBotMessage。"""
- if self.wxid is None:
- logger.error("WeChatPadPro 适配器未登录或未获取到 wxid,无法处理消息。")
- return None
- abm = AstrBotMessage()
- abm.raw_message = raw_message
- abm.message_id = str(raw_message.get("msg_id"))
- abm.timestamp = cast(int, raw_message.get("create_time"))
- abm.self_id = self.wxid
-
- if int(time.time()) - abm.timestamp > 180:
- logger.warning(
- f"忽略 3 分钟前的旧消息:消息时间戳 {abm.timestamp} 超过当前时间 {int(time.time())}。",
- )
- return None
-
- from_user_name = raw_message.get("from_user_name", {}).get("str", "")
- to_user_name = raw_message.get("to_user_name", {}).get("str", "")
- content = raw_message.get("content", {}).get("str", "")
- push_content = raw_message.get("push_content", "")
- msg_type = cast(int, raw_message.get("msg_type"))
-
- abm.message_str = ""
- abm.message = []
-
- # 如果是机器人自己发送的消息、回显消息或系统消息,忽略
- if from_user_name == self.wxid:
- logger.info("忽略来自自己的消息。")
- return None
-
- if from_user_name in ["weixin", "newsapp", "newsapp_wechat"]:
- logger.info("忽略来自微信团队的消息。")
- return None
-
- # 先判断群聊/私聊并设置基本属性
- if await self._process_chat_type(
- abm,
- raw_message,
- from_user_name,
- to_user_name,
- content,
- push_content,
- ):
- # 再根据消息类型处理消息内容
- await self._process_message_content(abm, raw_message, msg_type, content)
-
- return abm
- return None
-
- async def _process_chat_type(
- self,
- abm: AstrBotMessage,
- raw_message: dict,
- from_user_name: str,
- to_user_name: str,
- content: str,
- push_content: str,
- ):
- """判断消息是群聊还是私聊,并设置 AstrBotMessage 的基本属性。"""
- if from_user_name == "weixin":
- return False
- at_me = False
- if "@chatroom" in from_user_name:
- abm.type = MessageType.GROUP_MESSAGE
- abm.group_id = from_user_name
-
- parts = content.split(":\n", 1)
- sender_wxid = parts[0] if len(parts) == 2 else ""
- abm.sender = MessageMember(user_id=sender_wxid, nickname="")
-
- # 获取群聊发送者的nickname
- if sender_wxid:
- accurate_nickname = await self._get_group_member_nickname(
- abm.group_id,
- sender_wxid,
- )
- if accurate_nickname:
- abm.sender.nickname = accurate_nickname
-
- if abm.type == MessageType.GROUP_MESSAGE:
- abm.session_id = abm.group_id
- else:
- abm.session_id = abm.sender.user_id
-
- msg_source = raw_message.get("msg_source", "")
- if self.wxid in msg_source:
- at_me = True
- if "在群聊中@了你" in raw_message.get("push_content", ""):
- at_me = True
- if at_me:
- abm.message.insert(0, At(qq=abm.self_id, name=""))
- else:
- abm.type = MessageType.FRIEND_MESSAGE
- abm.group_id = ""
- nick_name = ""
- if push_content and " : " in push_content:
- nick_name = push_content.split(" : ")[0]
- abm.sender = MessageMember(user_id=from_user_name, nickname=nick_name)
- abm.session_id = from_user_name
- return True
-
- async def _get_group_member_nickname(
- self,
- group_id: str,
- member_wxid: str,
- ) -> str | None:
- """通过接口获取群成员的昵称。"""
- url = f"{self.base_url}/group/GetChatroomMemberDetail"
- params = {"key": self.auth_key}
- payload = {
- "ChatRoomName": group_id,
- }
-
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- response_data = await response.json()
- if response.status == 200 and response_data.get("Code") == 200:
- # 从返回数据中查找对应成员的昵称
- member_list = (
- response_data.get("Data", {})
- .get("member_data", {})
- .get("chatroom_member_list", [])
- )
- for member in member_list:
- if member.get("user_name") == member_wxid:
- return member.get("nick_name")
- logger.warning(
- f"在群 {group_id} 中未找到成员 {member_wxid} 的昵称",
- )
- else:
- logger.error(
- f"获取群成员详情失败: {response.status}, {response_data}",
- )
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"获取群成员详情时发生错误: {e}")
- return None
-
- async def _download_raw_image(
- self,
- from_user_name: str,
- to_user_name: str,
- msg_id: int,
- ) -> dict | None:
- """下载原始图片。"""
- url = f"{self.base_url}/message/GetMsgBigImg"
- params = {"key": self.auth_key}
- payload = {
- "CompressType": 0,
- "FromUserName": from_user_name,
- "MsgId": msg_id,
- "Section": {"DataLen": 61440, "StartPos": 0},
- "ToUserName": to_user_name,
- "TotalLen": 0,
- }
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- if response.status == 200:
- return await response.json()
- logger.error(f"下载图片失败: {response.status}")
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"下载图片时发生错误: {e}")
- return None
-
- async def download_voice(
- self,
- to_user_name: str,
- new_msg_id: str,
- bufid: str,
- length: int,
- ):
- """下载原始音频。"""
- url = f"{self.base_url}/message/GetMsgVoice"
- params = {"key": self.auth_key}
- payload = {
- "Bufid": bufid,
- "ToUserName": to_user_name,
- "NewMsgId": new_msg_id,
- "Length": length,
- }
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- if response.status == 200:
- return await response.json()
- logger.error(f"下载音频失败: {response.status}")
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"下载音频时发生错误: {e}")
- return None
-
- async def _process_message_content(
- self,
- abm: AstrBotMessage,
- raw_message: dict,
- msg_type: int,
- content: str,
- ):
- """根据消息类型处理消息内容,填充 AstrBotMessage 的 message 列表。"""
- if msg_type == 1: # 文本消息
- abm.message_str = content
- if abm.type == MessageType.GROUP_MESSAGE:
- parts = content.split(":\n", 1)
- if len(parts) == 2:
- message_content = parts[1]
- abm.message_str = message_content
-
- # 检查是否@了机器人,参考 gewechat 的实现方式
- # 微信大部分客户端在@用户昵称后面,紧接着是一个\u2005字符(四分之一空格)
- at_me = False
-
- # 检查 msg_source 中是否包含机器人的 wxid
- # wechatpadpro 的格式: wxid
- # gewechat 的格式:
- msg_source = raw_message.get("msg_source", "")
- if (
- f"{abm.self_id}" in msg_source
- or f"{abm.self_id}," in msg_source
- or f",{abm.self_id}" in msg_source
- ):
- at_me = True
-
- # 也检查 push_content 中是否有@提示
- push_content = raw_message.get("push_content", "")
- if "在群聊中@了你" in push_content:
- at_me = True
-
- if at_me:
- # 被@了,在消息开头插入At组件(参考gewechat的做法)
- bot_nickname = await self._get_group_member_nickname(
- abm.group_id,
- abm.self_id,
- )
- abm.message.insert(
- 0,
- At(qq=abm.self_id, name=bot_nickname or abm.self_id),
- )
-
- # 只有当消息内容不仅仅是@时才添加Plain组件
- if "\u2005" in message_content:
- # 检查@之后是否还有其他内容
- parts = message_content.split("\u2005")
- if len(parts) > 1 and any(
- part.strip() for part in parts[1:]
- ):
- abm.message.append(Plain(message_content))
- else:
- # 检查是否只包含@机器人
- is_pure_at = False
- if (
- bot_nickname
- and message_content.strip() == f"@{bot_nickname}"
- ):
- is_pure_at = True
- if not is_pure_at:
- abm.message.append(Plain(message_content))
- else:
- # 没有@机器人,作为普通文本处理
- abm.message.append(Plain(message_content))
- else:
- abm.message.append(Plain(abm.message_str))
- else: # 私聊消息
- abm.message.append(Plain(abm.message_str))
-
- # 缓存文本消息,以便引用消息可以查找
- try:
- # 获取msg_id作为缓存的key
- new_msg_id = raw_message.get("new_msg_id")
- if new_msg_id:
- # 限制缓存大小
- if (
- len(self.cached_texts) >= self.max_text_cache
- and self.cached_texts
- ):
- # 删除最早的一条缓存
- oldest_key = next(iter(self.cached_texts))
- self.cached_texts.pop(oldest_key)
-
- logger.debug(f"缓存文本消息,new_msg_id={new_msg_id}")
- self.cached_texts[str(new_msg_id)] = content
- except Exception as e:
- logger.error(f"缓存文本消息失败: {e}")
- elif msg_type == 3:
- # 图片消息
- from_user_name = raw_message.get("from_user_name", {}).get("str", "")
- to_user_name = raw_message.get("to_user_name", {}).get("str", "")
- msg_id = cast(int, raw_message.get("msg_id"))
- image_resp = await self._download_raw_image(
- from_user_name,
- to_user_name,
- msg_id,
- )
- if image_resp is None:
- logger.error(f"下载图片失败: msg_id={msg_id}")
- return
- image_bs64_data = (
- image_resp.get("Data", {}).get("Data", {}).get("Buffer", None)
- )
- if image_bs64_data:
- abm.message.append(Image.fromBase64(image_bs64_data))
- # 缓存图片,以便引用消息可以查找
- try:
- # 获取msg_id作为缓存的key
- new_msg_id = raw_message.get("new_msg_id")
- if new_msg_id:
- # 限制缓存大小
- if (
- len(self.cached_images) >= self.max_image_cache
- and self.cached_images
- ):
- # 删除最早的一条缓存
- oldest_key = next(iter(self.cached_images))
- self.cached_images.pop(oldest_key)
-
- logger.debug(f"缓存图片消息,new_msg_id={new_msg_id}")
- self.cached_images[str(new_msg_id)] = image_bs64_data
- except Exception as e:
- logger.error(f"缓存图片消息失败: {e}")
- elif msg_type == 47:
- # 视频消息 (注意:表情消息也是 47,需要区分)
- data_parser = GeweDataParser(
- content=content,
- is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
- raw_message=raw_message,
- )
- emoji_message = data_parser.parse_emoji()
- if emoji_message is not None:
- abm.message.append(emoji_message)
- elif msg_type == 50:
- logger.warning("收到语音/视频消息,待实现。")
- elif msg_type == 34:
- # 语音消息
- bufid = 0
- to_user_name = raw_message.get("to_user_name", {}).get("str", "")
- new_msg_id = raw_message.get("new_msg_id")
- if new_msg_id is None:
- logger.error("语音消息缺少 new_msg_id")
- return
- data_parser = GeweDataParser(
- content=content,
- is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
- raw_message=raw_message,
- )
-
- voicemsg = data_parser._format_to_xml().find("voicemsg")
- if voicemsg is None:
- logger.error("无法从 XML 解析 voicemsg 节点")
- return
- bufid = voicemsg.get("bufid") or "0"
- length = int(voicemsg.get("length") or 0)
- voice_resp = await self.download_voice(
- to_user_name=to_user_name,
- new_msg_id=new_msg_id,
- bufid=bufid,
- length=length,
- )
- if voice_resp is None:
- logger.error(f"下载语音失败: new_msg_id={new_msg_id}")
- return
- voice_bs64_data = voice_resp.get("Data", {}).get("Base64", None)
- if voice_bs64_data:
- voice_bs64_data = base64.b64decode(voice_bs64_data)
- temp_dir = os.path.join(get_astrbot_data_path(), "temp")
- file_path = os.path.join(
- temp_dir,
- f"wechatpadpro_voice_{abm.message_id}.silk",
- )
-
- async with await anyio.open_file(file_path, "wb") as f:
- await f.write(voice_bs64_data)
- abm.message.append(Record(file=file_path, url=file_path))
- elif msg_type == 49:
- try:
- parser = GeweDataParser(
- content=content,
- is_private_chat=(abm.type != MessageType.GROUP_MESSAGE),
- cached_texts=self.cached_texts,
- cached_images=self.cached_images,
- raw_message=raw_message,
- downloader=self._download_raw_image,
- )
- components = await parser.parse_mutil_49()
- if components:
- abm.message.extend(components)
- abm.message_str = "\n".join(
- c.text for c in components if isinstance(c, Plain)
- )
- except Exception as e:
- logger.warning(f"msg_type 49 处理失败: {e}")
- abm.message.append(Plain("[XML 消息处理失败]"))
- abm.message_str = "[XML 消息处理失败]"
- else:
- logger.warning(f"收到未处理的消息类型: {msg_type}。")
-
- async def terminate(self):
- """终止一个平台的运行实例。"""
- logger.info("终止 WeChatPadPro 适配器。")
- try:
- if self.ws_handle_task:
- self.ws_handle_task.cancel()
- if self._shutdown_event is not None:
- self._shutdown_event.set()
- except Exception:
- pass
-
- def meta(self) -> PlatformMetadata:
- """得到一个平台的元数据。"""
- return self.metadata
-
- async def send_by_session(
- self,
- session: MessageSesion,
- message_chain: MessageChain,
- ):
- dummy_message_obj = AstrBotMessage()
- dummy_message_obj.session_id = session.session_id
- # 根据 session_id 判断消息类型
- if "@chatroom" in session.session_id:
- dummy_message_obj.type = MessageType.GROUP_MESSAGE
- if "#" in session.session_id:
- dummy_message_obj.group_id = session.session_id.split("#")[0]
- else:
- dummy_message_obj.group_id = session.session_id
- dummy_message_obj.sender = MessageMember(user_id="", nickname="")
- else:
- dummy_message_obj.type = MessageType.FRIEND_MESSAGE
- dummy_message_obj.group_id = ""
- dummy_message_obj.sender = MessageMember(user_id="", nickname="")
- sending_event = WeChatPadProMessageEvent(
- message_str="",
- message_obj=dummy_message_obj,
- platform_meta=self.meta(),
- session_id=session.session_id,
- adapter=self,
- )
- # 调用实例方法 send
- await sending_event.send(message_chain)
-
- async def get_contact_list(self):
- """获取联系人列表。"""
- url = f"{self.base_url}/friend/GetContactList"
- params = {"key": self.auth_key}
- payload = {"CurrentChatRoomContactSeq": 0, "CurrentWxcontactSeq": 0}
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- if response.status != 200:
- logger.error(f"获取联系人列表失败: {response.status}")
- return None
- result = await response.json()
- if result.get("Code") == 200 and result.get("Data"):
- contact_list = (
- result.get("Data", {})
- .get("ContactList", {})
- .get("contactUsernameList", [])
- )
- return contact_list
- logger.error(f"获取联系人列表失败: {result}")
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"获取联系人列表时发生错误: {e}")
- return None
-
- async def get_contact_details_list(
- self,
- room_wx_id_list: list[str] | None = None,
- user_names: list[str] | None = None,
- ) -> dict | None:
- """获取联系人详情列表。"""
- if room_wx_id_list is None:
- room_wx_id_list = []
- if user_names is None:
- user_names = []
- url = f"{self.base_url}/friend/GetContactDetailsList"
- params = {"key": self.auth_key}
- payload = {"RoomWxIDList": room_wx_id_list, "UserNames": user_names}
- async with aiohttp.ClientSession() as session:
- try:
- async with session.post(url, params=params, json=payload) as response:
- if response.status != 200:
- logger.error(f"获取联系人详情列表失败: {response.status}")
- return None
- result = await response.json()
- if result.get("Code") == 200 and result.get("Data"):
- contact_list = result.get("Data", {}).get("contactList", {})
- return contact_list
- logger.error(f"获取联系人详情列表失败: {result}")
- return None
- except aiohttp.ClientConnectorError as e:
- logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
- return None
- except Exception as e:
- logger.error(f"获取联系人详情列表时发生错误: {e}")
- return None
diff --git a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py b/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
deleted file mode 100644
index 08ab27013..000000000
--- a/astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py
+++ /dev/null
@@ -1,178 +0,0 @@
-import asyncio
-import base64
-import io
-from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING
-
-import aiohttp
-from PIL import Image as PILImage # 使用别名避免冲突
-
-from astrbot import logger
-from astrbot.core.message.components import (
- Image,
- Plain,
- Record,
- WechatEmoji,
-) # Import Image
-from astrbot.core.message.message_event_result import MessageChain
-from astrbot.core.platform.astr_message_event import AstrMessageEvent
-from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageType
-from astrbot.core.platform.platform_metadata import PlatformMetadata
-from astrbot.core.utils.tencent_record_helper import audio_to_tencent_silk_base64
-
-if TYPE_CHECKING:
- from .wechatpadpro_adapter import WeChatPadProAdapter
-
-
-class WeChatPadProMessageEvent(AstrMessageEvent):
- def __init__(
- self,
- message_str: str,
- message_obj: AstrBotMessage,
- platform_meta: PlatformMetadata,
- session_id: str,
- adapter: "WeChatPadProAdapter", # 传递适配器实例
- ):
- super().__init__(message_str, message_obj, platform_meta, session_id)
- self.message_obj = message_obj # Save the full message object
- self.adapter = adapter # Save the adapter instance
-
- async def send(self, message: MessageChain):
- async with aiohttp.ClientSession() as session:
- for comp in message.chain:
- await asyncio.sleep(1)
- if isinstance(comp, Plain):
- await self._send_text(session, comp.text)
- elif isinstance(comp, Image):
- await self._send_image(session, comp)
- elif isinstance(comp, WechatEmoji):
- await self._send_emoji(session, comp)
- elif isinstance(comp, Record):
- await self._send_voice(session, comp)
- await super().send(message)
-
- async def send_streaming(
- self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
- ):
- buffer = None
- async for chain in generator:
- if not buffer:
- buffer = chain
- else:
- buffer.chain.extend(chain.chain)
- if not buffer:
- return None
- buffer.squash_plain()
- await self.send(buffer)
- return await super().send_streaming(generator, use_fallback)
-
- async def _send_image(self, session: aiohttp.ClientSession, comp: Image):
- b64 = await comp.convert_to_base64()
- raw = self._validate_base64(b64)
- b64c = self._compress_image(raw)
- payload = {
- "MsgItem": [
- {"ImageContent": b64c, "MsgType": 3, "ToUserName": self.session_id},
- ],
- }
- url = f"{self.adapter.base_url}/message/SendImageNewMessage"
- await self._post(session, url, payload)
-
- async def _send_text(self, session: aiohttp.ClientSession, text: str):
- if (
- self.message_obj.type == MessageType.GROUP_MESSAGE # 确保是群聊消息
- and self.adapter.settings.get(
- "reply_with_mention",
- False,
- ) # 检查适配器设置是否启用 reply_with_mention
- and self.message_obj.sender # 确保有发送者信息
- and (
- self.message_obj.sender.user_id or self.message_obj.sender.nickname
- ) # 确保发送者有 ID 或昵称
- ):
- # 优先使用 nickname,如果没有则使用 user_id
- mention_text = (
- self.message_obj.sender.nickname or self.message_obj.sender.user_id
- )
- message_text = f"@{mention_text} {text}"
- # logger.info(f"已添加 @ 信息: {message_text}")
- else:
- message_text = text
- if self.get_group_id() and "#" in self.session_id:
- session_id = self.session_id.split("#")[0]
- else:
- session_id = self.session_id
- payload = {
- "MsgItem": [
- {
- "MsgType": 1,
- "TextContent": message_text,
- "ToUserName": session_id,
- },
- ],
- }
- url = f"{self.adapter.base_url}/message/SendTextMessage"
- await self._post(session, url, payload)
-
- async def _send_emoji(self, session: aiohttp.ClientSession, comp: WechatEmoji):
- payload = {
- "EmojiList": [
- {
- "EmojiMd5": comp.md5,
- "EmojiSize": comp.md5_len,
- "ToUserName": self.session_id,
- },
- ],
- }
- url = f"{self.adapter.base_url}/message/SendEmojiMessage"
- await self._post(session, url, payload)
-
- async def _send_voice(self, session: aiohttp.ClientSession, comp: Record):
- record_path = await comp.convert_to_file_path()
- # 默认已经存在 data/temp 中
- b64, duration = await audio_to_tencent_silk_base64(record_path)
- payload = {
- "ToUserName": self.session_id,
- "VoiceData": b64,
- "VoiceFormat": 4,
- "VoiceSecond": duration,
- }
- url = f"{self.adapter.base_url}/message/SendVoice"
- await self._post(session, url, payload)
-
- @staticmethod
- def _validate_base64(b64: str) -> bytes:
- return base64.b64decode(b64, validate=True)
-
- @staticmethod
- def _compress_image(data: bytes) -> str:
- img = PILImage.open(io.BytesIO(data))
- buf = io.BytesIO()
- if img.format == "JPEG":
- img.save(buf, "JPEG", quality=80)
- else:
- if img.mode in ("RGBA", "P"):
- img = img.convert("RGB")
- img.save(buf, "JPEG", quality=80)
- # logger.info("图片处理完成!!!")
- return base64.b64encode(buf.getvalue()).decode()
-
- async def _post(self, session, url, payload):
- params = {"key": self.adapter.auth_key}
- try:
- async with session.post(url, params=params, json=payload) as resp:
- data = await resp.json()
- if resp.status != 200 or data.get("Code") != 200:
- logger.error(f"{url} failed: {resp.status} {data}")
- except Exception as e:
- logger.error(f"{url} error: {e}")
-
-
-# TODO: 添加对其他消息组件类型的处理 (Record, Video, At等)
-# elif isinstance(component, Record):
-# pass
-# elif isinstance(component, Video):
-# pass
-# elif isinstance(component, At):
-# pass
-# ...
diff --git a/astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py b/astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py
deleted file mode 100644
index 09924edb6..000000000
--- a/astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py
+++ /dev/null
@@ -1,159 +0,0 @@
-from defusedxml import ElementTree as eT
-
-from astrbot.api import logger
-from astrbot.api.message_components import (
- BaseMessageComponent,
- Image,
- Plain,
-)
-from astrbot.api.message_components import (
- WechatEmoji as Emoji,
-)
-
-
-class GeweDataParser:
- def __init__(
- self,
- content: str,
- is_private_chat: bool = False,
- cached_texts=None,
- cached_images=None,
- raw_message: dict | None = None,
- downloader=None,
- ):
- self._xml = None
- self.content = content
- self.is_private_chat = is_private_chat
- self.cached_texts = cached_texts or {}
- self.cached_images = cached_images or {}
- self.downloader = downloader
-
- raw_message = raw_message or {}
- self.from_user_name = raw_message.get("from_user_name", {}).get("str", "")
- self.to_user_name = raw_message.get("to_user_name", {}).get("str", "")
- self.msg_id = raw_message.get("msg_id", "")
-
- def _format_to_xml(self):
- if self._xml:
- return self._xml
-
- try:
- msg_str = self.content
- if not self.is_private_chat:
- parts = self.content.split(":\n", 1)
- msg_str = parts[1] if len(parts) == 2 else self.content
-
- self._xml = eT.fromstring(msg_str)
- return self._xml
- except Exception as e:
- logger.error(f"[XML解析失败] {e}")
- raise
-
- async def parse_mutil_49(self) -> list[BaseMessageComponent] | None:
- """处理 msg_type == 49 的多种 appmsg 类型(目前支持 type==57)"""
- try:
- appmsg_type = self._format_to_xml().findtext(".//appmsg/type")
- if appmsg_type == "57":
- return await self.parse_reply()
- except Exception as e:
- logger.warning(f"[parse_mutil_49] 解析失败: {e}")
- return None
-
- async def parse_reply(self) -> list[BaseMessageComponent]:
- """处理 type == 57 的引用消息:支持文本(1)、图片(3)、嵌套49(49)"""
- components = []
-
- try:
- appmsg = self._format_to_xml().find("appmsg")
- if appmsg is None:
- return [Plain("[引用消息解析失败]")]
-
- refermsg = appmsg.find("refermsg")
- if refermsg is None:
- return [Plain("[引用消息解析失败]")]
-
- quote_type = int(refermsg.findtext("type", "0"))
- nickname = refermsg.findtext("displayname", "未知发送者")
- quote_content = refermsg.findtext("content", "")
- svrid = refermsg.findtext("svrid")
-
- match quote_type:
- case 1: # 文本引用
- quoted_text = self.cached_texts.get(str(svrid), quote_content)
- components.append(Plain(f"[引用] {nickname}: {quoted_text}"))
-
- case 3: # 图片引用
- quoted_image_b64 = self.cached_images.get(str(svrid))
- if not quoted_image_b64:
- try:
- quote_xml = eT.fromstring(quote_content)
- img = quote_xml.find("img")
- cdn_url = (
- img.get("cdnbigimgurl") or img.get("cdnmidimgurl")
- if img is not None
- else None
- )
- if cdn_url and self.downloader:
- image_resp = await self.downloader(
- self.from_user_name,
- self.to_user_name,
- self.msg_id,
- )
- quoted_image_b64 = (
- image_resp.get("Data", {})
- .get("Data", {})
- .get("Buffer")
- )
- except Exception as e:
- logger.warning(f"[引用图片解析失败] svrid={svrid} err={e}")
-
- if quoted_image_b64:
- components.extend(
- [
- Image.fromBase64(quoted_image_b64),
- Plain(f"[引用] {nickname}: [引用的图片]"),
- ],
- )
- else:
- components.append(
- Plain(f"[引用] {nickname}: [引用的图片 - 未能获取]"),
- )
-
- case 49: # 嵌套引用
- try:
- nested_root = eT.fromstring(quote_content)
- nested_title = nested_root.findtext(".//appmsg/title", "")
- components.append(Plain(f"[引用] {nickname}: {nested_title}"))
- except Exception as e:
- logger.warning(f"[嵌套引用解析失败] err={e}")
- components.append(Plain(f"[引用] {nickname}: [嵌套引用消息]"))
-
- case _: # 其他未识别类型
- logger.info(f"[未知引用类型] quote_type={quote_type}")
- components.append(Plain(f"[引用] {nickname}: [不支持的引用类型]"))
-
- # 主消息标题
- title = appmsg.findtext("title", "")
- if title:
- components.append(Plain(title))
-
- except Exception as e:
- logger.error(f"[parse_reply] 总体解析失败: {e}")
- return [Plain("[引用消息解析失败]")]
-
- return components
-
- def parse_emoji(self) -> Emoji | None:
- """处理 msg_type == 47 的表情消息(emoji)"""
- try:
- emoji_element = self._format_to_xml().find(".//emoji")
- if emoji_element is not None:
- return Emoji(
- md5=emoji_element.get("md5"),
- md5_len=emoji_element.get("len"),
- cdnurl=emoji_element.get("cdnurl"),
- )
- except Exception as e:
- logger.error(f"[parse_emoji] 解析失败: {e}")
-
- return None
diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py
index 1182ff9b0..241662bca 100644
--- a/astrbot/core/star/filter/platform_adapter_type.py
+++ b/astrbot/core/star/filter/platform_adapter_type.py
@@ -12,7 +12,6 @@ class PlatformAdapterType(enum.Flag):
TELEGRAM = enum.auto()
WECOM = enum.auto()
LARK = enum.auto()
- WECHATPADPRO = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
SLACK = enum.auto()
@@ -27,7 +26,6 @@ class PlatformAdapterType(enum.Flag):
| TELEGRAM
| WECOM
| LARK
- | WECHATPADPRO
| DINGTALK
| DISCORD
| SLACK
@@ -49,7 +47,6 @@ ADAPTER_NAME_2_TYPE = {
"discord": PlatformAdapterType.DISCORD,
"slack": PlatformAdapterType.SLACK,
"kook": PlatformAdapterType.KOOK,
- "wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
"satori": PlatformAdapterType.SATORI,
diff --git a/dashboard/src/utils/platformUtils.js b/dashboard/src/utils/platformUtils.js
index f622204fc..cac9a7fd7 100644
--- a/dashboard/src/utils/platformUtils.js
+++ b/dashboard/src/utils/platformUtils.js
@@ -14,8 +14,6 @@ export function getPlatformIcon(name) {
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
} else if (name === 'wecom' || name === 'wecom_ai_bot') {
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
- } else if (name === 'wechatpadpro' || name === 'weixin_official_account' || name === 'wechat') {
- return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
} else if (name === 'lark') {
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
} else if (name === 'dingtalk') {
@@ -52,7 +50,6 @@ export function getTutorialLink(platformType) {
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
- "wechatpadpro": "https://docs.astrbot.app/deploy/platform/wechat/wechatpadpro.html",
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue
index 0e2b59555..2f7bb124b 100644
--- a/dashboard/src/views/SessionManagementPage.vue
+++ b/dashboard/src/views/SessionManagementPage.vue
@@ -986,7 +986,6 @@ export default {
getPlatformColor(platform) {
const colors = {
'aiocqhttp': 'blue',
- 'wechatpadpro': 'green',
'qq_official': 'purple',
'telegram': 'light-blue',
'discord': 'indigo',