diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 604f1ded0..014545690 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -138,7 +138,7 @@ class RespondStage(Stage): return False if event.get_platform_name() in [ - "qq_official", + "qq_official_webhook", "weixin_official_account", "dingtalk", ]: diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index afb8a6184..5956c8b5e 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -203,7 +203,7 @@ class ResultDecorateStage(Stage): # 分段回复 if self.enable_segmented_reply and event.get_platform_name() not in [ - "qq_official", + "qq_official_webhook", "weixin_official_account", "dingtalk", ]: diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 15d7b4159..68a93b09b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -65,6 +65,14 @@ _qqofficial_retry = retry( reraise=True, ) +_QQOFFICIAL_SEND_API_ERRORS = ( + botpy.errors.ForbiddenError, + botpy.errors.MethodNotAllowedError, + botpy.errors.NotFoundError, + botpy.errors.SequenceNumberError, + botpy.errors.ServerError, +) + class QQOfficialMessageEvent(AstrMessageEvent): MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" @@ -482,7 +490,21 @@ class QQOfficialMessageEvent(AstrMessageEvent): ): try: return await send_func(payload) - except botpy.errors.ServerError as err: + except _QQOFFICIAL_SEND_API_ERRORS as err: + logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) + if payload.get("msg_id"): + fallback_payload = payload.copy() + try: + ret = await send_func(fallback_payload) + logger.info("[QQOfficial] 使用主动发送接口发送成功。") + return ret + except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: + err = fallback_err + payload = fallback_payload + + if not isinstance(err, botpy.errors.ServerError): + raise + # QQ 流式 markdown 分片校验:内容必须以换行结尾。 # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err): @@ -649,6 +671,8 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) -> message.Message | None: payload = locals() payload.pop("self", None) + if payload.get("msg_id") is None: + payload.pop("msg_id", None) # QQ API does not accept stream.id=None; remove it when not yet assigned if "stream" in payload and payload["stream"] is not None: stream_data = dict(payload["stream"]) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 5d71ce5af..38e88a4ba 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -12,6 +12,7 @@ from typing import Any, cast import botpy import botpy.message from botpy import Client +from botpy.connection import ConnectionState from botpy.gateway import BotWebSocket from astrbot import logger @@ -36,6 +37,39 @@ for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) +class PatchedGroupMessage(botpy.message.GroupMessage): + class _User: + def __init__(self, data: dict[str, Any]) -> None: + self.id = data.get("id", None) + self.username = data.get("username", None) + self.bot = data.get("bot", None) + self.avatar = data.get("avatar", None) + self.member_openid = data.get("member_openid", None) + self.user_openid = data.get("user_openid", None) + self.is_you = data.get("is_you", None) + + def __repr__(self) -> str: + return str(self.__dict__) + + +def _ensure_group_message_create_parser() -> None: + """Register the missing qq-botpy parser for GROUP_MESSAGE_CREATE.""" + + if hasattr(ConnectionState, "parse_group_message_create"): + return + + def parse_group_message_create(self, payload: dict[str, Any]) -> None: + group_message = PatchedGroupMessage( + self.api, + payload.get("id", None), + payload.get("d", {}), + ) + logger.debug("[QQOfficial] Received group message: %s", group_message) + self._dispatch("group_message_create", group_message) + + setattr(ConnectionState, "parse_group_message_create", parse_group_message_create) + + class ManagedBotWebSocket(BotWebSocket): def __init__(self, session, connection: Any, client: botClient): super().__init__(session, connection) @@ -70,6 +104,19 @@ class botClient(Client): # 收到群消息 async def on_group_at_message_create( self, message: botpy.message.GroupMessage + ) -> None: + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + force_group_mention=True, + ) + abm.group_id = cast(str, message.group_openid) + abm.session_id = abm.group_id + self.platform.remember_session_scene(abm.session_id, "group") + self._commit(abm) + + async def on_group_message_create( + self, message: botpy.message.GroupMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, @@ -176,8 +223,11 @@ class QQOfficialPlatformAdapter(Platform): self.client.set_platform(self) + _ensure_group_message_create_parser() + self._session_last_message_id: dict[str, str] = {} self._session_scene: dict[str, str] = {} + self._allow_group_proactive_send = True self.test_mode = os.environ.get("TEST_MODE", "off") == "on" @@ -220,21 +270,32 @@ class QQOfficialPlatformAdapter(Platform): ): return - # 私聊主动推送不需要 msg_id,见 https://github.com/AstrBotDevs/AstrBot/issues/7904 + # 主动推送不需要 msg_id,见 https://github.com/AstrBotDevs/AstrBot/issues/7904 msg_id = self._session_last_message_id.get(session.session_id) - if not msg_id and session.message_type != MessageType.FRIEND_MESSAGE: + scene = self._session_scene.get(session.session_id) + allow_group_proactive_send = ( + session.message_type == MessageType.GROUP_MESSAGE + and scene == "group" + and getattr(self, "_allow_group_proactive_send", False) + ) + if ( + not msg_id + and session.message_type != MessageType.FRIEND_MESSAGE + and not allow_group_proactive_send + ): logger.warning( "[QQOfficial] No cached msg_id for session: %s, skip send_by_session", session.session_id, ) return - payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id} + payload: dict[str, Any] = {"content": plain_text} + if msg_id and not allow_group_proactive_send: + payload["msg_id"] = msg_id ret: Any = None send_helper = SimpleNamespace(bot=self.client) if session.message_type == MessageType.GROUP_MESSAGE: - scene = self._session_scene.get(session.session_id) if scene == "group": payload["msg_seq"] = random.randint(1, 10000) if image_base64: @@ -537,6 +598,7 @@ class QQOfficialPlatformAdapter(Platform): | botpy.message.DirectMessage | botpy.message.C2CMessage, message_type: MessageType, + force_group_mention: bool = False, ) -> AstrBotMessage: abm = AstrBotMessage() abm.type = message_type @@ -551,16 +613,47 @@ class QQOfficialPlatformAdapter(Platform): botpy.message.C2CMessage, ): if isinstance(message, botpy.message.GroupMessage): - abm.sender = MessageMember(message.author.member_openid, "") + abm.sender = MessageMember( + message.author.member_openid, + getattr(message.author, "username", "") or "", + ) abm.group_id = message.group_openid + bot_mentions = [ + mention + for mention in (getattr(message, "mentions", None) or []) + if getattr(mention, "is_you", False) is True + and getattr(mention, "id", None) is not None + ] + bot_mention_ids = [str(mention.id) for mention in bot_mentions] + group_mentioned = bool(bot_mention_ids) or force_group_mention + plain_content_raw = message.content or "" + for mention_id in bot_mention_ids: + plain_content_raw = plain_content_raw.replace( + f"<@{mention_id}>", + "", + ).replace( + f"<@!{mention_id}>", + "", + ) + abm.message_str = QQOfficialPlatformAdapter._parse_face_message( + plain_content_raw.strip() + ) + abm.self_id = bot_mention_ids[0] if bot_mention_ids else "qq_official" + if group_mentioned: + mention_name = ( + getattr(bot_mentions[0], "username", "") if bot_mentions else "" + ) + msg.append(At(qq=abm.self_id, name=mention_name)) else: - abm.sender = MessageMember(message.author.user_openid, "") - # Parse face messages to readable text - abm.message_str = QQOfficialPlatformAdapter._parse_face_message( - message.content.strip() - ) - abm.self_id = "unknown_selfid" - msg.append(At(qq="qq_official")) + abm.sender = MessageMember( + message.author.user_openid, + getattr(message.author, "username", "") or "", + ) + abm.message_str = QQOfficialPlatformAdapter._parse_face_message( + (message.content or "").strip() + ) + abm.self_id = "unknown_selfid" + msg.append(At(qq="qq_official")) msg.append(Plain(abm.message_str)) await QQOfficialPlatformAdapter._append_attachments( msg, message.attachments @@ -599,7 +692,8 @@ class QQOfficialPlatformAdapter(Platform): abm.group_id = message.channel_id else: raise ValueError(f"Unknown message type: {message_type}") - abm.self_id = "qq_official" + if not abm.self_id: + abm.self_id = "qq_official" return abm def run(self): diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py index a0f518c44..1a90f4ec9 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -13,7 +13,10 @@ from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.utils.webhook_utils import log_webhook_info from ...register import register_platform_adapter -from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter +from ..qqofficial.qqofficial_platform_adapter import ( + QQOfficialPlatformAdapter, + _ensure_group_message_create_parser, +) from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_server import QQOfficialWebhook @@ -30,6 +33,19 @@ class botClient(Client): # 收到群消息 async def on_group_at_message_create( self, message: botpy.message.GroupMessage + ) -> None: + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + force_group_mention=True, + ) + abm.group_id = cast(str, message.group_openid) + abm.session_id = abm.group_id + self.platform.remember_session_scene(abm.session_id, "group") + self._commit(abm) + + async def on_group_message_create( + self, message: botpy.message.GroupMessage ) -> None: abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( message, @@ -103,9 +119,11 @@ class QQOfficialWebhookPlatformAdapter(Platform): timeout=20, ) self.client.set_platform(self) + _ensure_group_message_create_parser() self.webhook_helper = None self._session_last_message_id: dict[str, str] = {} self._session_scene: dict[str, str] = {} + self._allow_group_proactive_send = False async def send_by_session( self, diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py index 2c9f6caba..38ad45b56 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -12,6 +12,8 @@ from cryptography.hazmat.primitives.asymmetric import ed25519 from astrbot.api import logger from astrbot.core.platform.webhook_server import FastAPIWebhookServer +from ..qqofficial.qqofficial_platform_adapter import _ensure_group_message_create_parser + # remove logger handler for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) @@ -120,6 +122,7 @@ class QQOfficialWebhook: def _setup_connection(self) -> None: if self._connection is not None: return + _ensure_group_message_create_parser() self.client.api = self.api self.client.http = self.http @@ -163,7 +166,7 @@ class QQOfficialWebhook: """内部服务器的回调入口""" return await self.handle_callback(request) - async def handle_callback(self, request) -> dict: + async def handle_callback(self, request) -> dict | tuple[dict[str, str], int]: """处理 webhook 回调,可被统一 webhook 入口复用 Args: @@ -226,6 +229,7 @@ class QQOfficialWebhook: "creating parser connection lazily.", ) self._setup_connection() + connection = cast(ConnectionSession, self._connection) # Extract extra fields from raw payload before botpy parses and discards them if data: @@ -240,7 +244,7 @@ class QQOfficialWebhook: if extra: self._extra_data_cache[msg_id] = extra try: - func = self._connection.parser[event] + func = connection.parser[event] except KeyError: logger.error("_parser unknown event %s.", event) if data: diff --git a/docs/en/platform/qqofficial/webhook.md b/docs/en/platform/qqofficial/webhook.md index ebd136ee4..23bff4679 100644 --- a/docs/en/platform/qqofficial/webhook.md +++ b/docs/en/platform/qqofficial/webhook.md @@ -37,6 +37,14 @@ Then configure QQ groups, private chat QQ accounts, and QQ channels as needed. ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png) +## Recommended Group Chat Settings + +In mobile QQ group settings, open the bot settings page. We recommend setting `Messages the bot can access` to `All group messages`, and enabling `Allow the bot to proactively speak in the group`. + +With this configuration, the bot can receive full group messages and proactively push messages to the group, such as scheduled task notifications and plugin notifications. + +![QQ Official Bot recommended group chat settings](/qqofficial-group-recommended-config.png) + ## Get `appid` and `secret` After adding the bot where you need it, open `Development -> Development Settings`, then copy `appid` and `secret`. diff --git a/docs/en/platform/qqofficial/websockets.md b/docs/en/platform/qqofficial/websockets.md index 9b64576c3..7aed3d3b3 100644 --- a/docs/en/platform/qqofficial/websockets.md +++ b/docs/en/platform/qqofficial/websockets.md @@ -14,26 +14,37 @@ Proactive message push: Supported. -## Quick Deployment Steps +## Create a QQ Bot in AstrBot with One-click QR Setup (Recommended) -> Updated: `2026/03/06`. This method only supports `private chat`. +### Setup Flow -1. Open [QQ Open Platform](https://q.qq.com/qqbot/openclaw/). Register an account if you don't have one. -2. Click the `Create Bot` button on the right. -3. Obtain your `AppID` and `AppSecret`. -4. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`, select `QQ Official Bot (WebSocket)`, paste the `AppID` and `AppSecret` into the form, click `Enable`, then click `Save`. +1. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`. +2. Select `QQ Official Bot (WebSocket)`. +3. Under `Choose setup method`, select `One-click QR setup`, click start, then scan the QR code with mobile QQ. +4. After you confirm the QR binding, AstrBot automatically fills in `AppID` and `AppSecret`. Make sure `Enable` is checked, then click `Save`. 5. Back on the QQ Open Platform page, click `Scan QR Code to Chat` next to your bot, then scan with your mobile QQ to start chatting. -To use the bot in group chats, refer to the `Allow Bot in Channel / Group / Private Chat` section below. +### Use in Group Chats ---- +#### Add to a Group Chat -## Apply for a Bot +Open the created QQ bot profile page (mobile QQ -> Contacts -> Bots tab). You can find `Add to group chat` near the bottom. Currently, the bot can only be added to groups where you are the group owner. + +#### Set Message Access Scope and Proactive Speaking + +In mobile QQ group settings, open the bot settings page. We recommend setting `Messages the bot can access` to `All group messages`, and enabling `Allow the bot to proactively speak in the group`. + +With this configuration, the bot can receive full group messages and proactively push messages to the group, such as scheduled task notifications and plugin notifications. + +![QQ Official Bot recommended group chat settings](/qqofficial-group-recommended-config.png) + +## Manually Apply for a QQ Bot (Not Recommended) + +### Apply for a Bot > [!WARNING] > 1. QQ Official Bot currently requires an IP whitelist. > 2. It supports group chat, private chat, channel chat, and channel private chat. -> 3. Tencent is phasing out Websockets access, so this method is no longer recommended. Please use [Webhook](/en/platform/qqofficial/webhook) instead. Open [QQ Official Bot](https://q.qq.com) and sign in. @@ -43,7 +54,7 @@ Open the created bot to enter its management page: ![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png) -## Allow Bot in Channel / Group / Private Chat +### Allow Bot in Channel / Group / Private Chat Open `Sandbox Configuration` to set a sandbox channel / QQ group / QQ private chat (up to 20 members). @@ -51,11 +62,13 @@ Then configure QQ groups, private chat QQ accounts, and QQ channels as needed. ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png) -## Get `appid` and `secret` +### Get `appid` and `secret` After adding the bot where you need it, open `Development -> Development Settings`, then copy `appid` and `secret`. -## Add IP Whitelist +If you use AstrBot WebUI's `One-click QR setup`, you can skip this step. AstrBot fills in `appid` and `secret` automatically after QR binding succeeds. + +### Add IP Whitelist Open `Development -> Development Settings`, find IP whitelist, and add your server IP. @@ -66,22 +79,27 @@ Open `Development -> Development Settings`, find IP whitelist, and add your serv > > In NAT environments without a public IP, the observed IP may change depending on your carrier. Use proxy/tunnel if needed. -## Configure in AstrBot +### Configure in AstrBot 1. Open AstrBot Dashboard. 2. Click `Bots` in the left sidebar. 3. Click `+ Create Bot`. 4. Select `qq_official`. -Fill in: +Recommended: use `One-click QR setup`. + +1. Under `Choose setup method`, select `One-click QR setup`. +2. Click start, then scan and confirm the QR code with mobile QQ. +3. Wait until the page shows binding success. AstrBot fills in `appid` and `secret` automatically. +4. Adjust `ID`, `Enable group/C2C message list`, `Enable guild direct message`, and other options as needed, then click `Save`. + +If QR setup is unavailable, choose `Manual setup` and fill in: - ID (`id`): any unique identifier. - Enable (`enable`): checked. - `appid`: from QQ Official Bot platform. - `secret`: from QQ Official Bot platform. +- Enable group/C2C message list (`enable_group_c2c`): keep enabled if you need QQ message-list private chat. +- Enable guild direct message (`enable_guild_direct_message`): keep enabled if you need guild direct messages. Click `Save`. - -## Done - -AstrBot should now be connected. Send `/help` to the bot in QQ private chat to verify. diff --git a/docs/public/qqofficial-group-recommended-config.png b/docs/public/qqofficial-group-recommended-config.png new file mode 100644 index 000000000..1347933a2 Binary files /dev/null and b/docs/public/qqofficial-group-recommended-config.png differ diff --git a/docs/zh/platform/qqofficial/webhook.md b/docs/zh/platform/qqofficial/webhook.md index b7c5f4f44..949440d73 100644 --- a/docs/zh/platform/qqofficial/webhook.md +++ b/docs/zh/platform/qqofficial/webhook.md @@ -40,6 +40,14 @@ ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png) +## 推荐群聊配置 + +在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。 + +这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。 + +![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png) + ## 获取 appid、secret 添加机器人到你想用的地方后。 diff --git a/docs/zh/platform/qqofficial/websockets.md b/docs/zh/platform/qqofficial/websockets.md index 15d193a19..da1705df1 100644 --- a/docs/zh/platform/qqofficial/websockets.md +++ b/docs/zh/platform/qqofficial/websockets.md @@ -15,21 +15,33 @@ 主动消息推送:支持。 -## 快速部署通道 +## 在 AstrBot 中扫码一键创建 QQ 机器人(推荐) -> 更新自: `2026/03/06`。该方法仅支持 `私聊`。 +### 配置流程 -1. 打开 [QQ 开放平台](https://q.qq.com/qqbot/openclaw/)。如果没注册,需要先注册。 -2. 点击右侧 `创建机器人` 按钮。 -3. 获取 `AppID` 和 `AppSecret`。 -4. 进入 AstrBot 的 WebUI,点击左边栏 `机器人`,然后在右边的界面中,点击 `+ 创建机器人`,选择 `QQ 官方机器人(WebSocket)`,将之前得到的 `AppID` 和 `AppSecret` 复制到这里的表单中,然后 `启用`,然后点击保存。 +1. 进入 AstrBot 的 WebUI,点击左边栏 `机器人`,然后点击 `+ 创建机器人`。 +2. 选择 `QQ 官方机器人(WebSocket)`。 +3. 在 `选择创建方式` 中选择 `扫码一键创建`,点击开始创建后,用手机 QQ 扫描页面中的二维码。 +4. 扫码确认后,AstrBot 会自动写入 `AppID` 和 `AppSecret`。确认 `启用` 已勾选,然后点击 `保存`。 5. 回到 QQ 开放平台页面,点击机器人右边的 `扫码聊天`。用手机 QQ 扫码即可聊天。 -如果要在群聊中使用,参考下面文档的 `允许机器人加入频道/群/私聊` 一节。 +### 在群聊中使用 ---- +#### 添加到群聊 -## 申请一个机器人 +进入创建的 QQ 机器人的资料页(手机QQ -> 联系人 -> 机器人页签),在下方可以找到 “添加到群聊”。目前只能添加到自己为群主的群聊。 + +#### 设置机器人可获取的群聊消息范围和主动发言 + +在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。 + +这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。 + +![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png) + +## 手动申请 QQ 机器人(不推荐) + +### 申请一个机器人 > [!WARNING] > @@ -44,7 +56,7 @@ ![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png) -## 允许机器人加入频道/群/私聊 +### 允许机器人加入频道/群/私聊 点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊,用于拉入机器人(需要小于等于20个人)。 @@ -52,13 +64,15 @@ ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png) -## 获取 appid、secret +### 获取 appid、secret 添加机器人到你想用的地方后。 点击 `开发->开发设置`,找到 appid、secret。复制并保存它们。 -## 添加 IP 白名单(可选) +如果你使用 AstrBot WebUI 的 `扫码一键创建`,这一步可以跳过。扫码绑定成功后,AstrBot 会自动填入 `appid` 和 `secret`。 + +### 添加 IP 白名单(可选) 点击 `开发->开发设置`,找到 IP 白名单。添加你的服务器 IP 地址。 @@ -69,22 +83,27 @@ > > 如果你在没有公网 IP 的环境下,你看到的 IP 是运营商 NAT 的 IP,这个 IP 根据你的运营商的情况可能会随时变化。如有必要,可以配置代理。 -## 在 AstrBot 配置 +### 在 AstrBot 配置 1. 进入 AstrBot 的管理面板 2. 点击左边栏 `机器人` 3. 然后在右边的界面中,点击 `+ 创建机器人` 4. 选择 `QQ 官方机器人(WebSocket)` -弹出的配置项填写: +推荐使用 `扫码一键创建`: + +1. 在 `选择创建方式` 中选择 `扫码一键创建`。 +2. 点击开始创建,用手机 QQ 扫描二维码并确认。 +3. 等待页面显示绑定成功。AstrBot 会自动填入 `appid` 和 `secret`。 +4. 根据需要调整 `ID`、`启用消息列表单聊`、`启用频道私聊` 等配置,然后点击 `保存`。 + +如果扫码不可用,也可以选择 `手动创建`。弹出的配置项填写: - ID(id):随意填写,用于区分不同的消息平台实例。 - 启用(enable): 勾选。 - appid: QQ 官方机器人中获取的 appid。 - secret: QQ 官方机器人中获取的 secret。 +- 启用消息列表单聊(enable_group_c2c): 如果需要通过 QQ 消息列表私聊机器人,保持开启。 +- 启用频道私聊(enable_guild_direct_message): 如果需要频道私聊,保持开启。 点击 `保存`。 - -## 🎉 大功告成 - -此时,你的 AstrBot 应该已经成功连接 QQ 官方接口。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。 diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py new file mode 100644 index 000000000..67017369a --- /dev/null +++ b/tests/test_qqofficial_group_message_create.py @@ -0,0 +1,372 @@ +import asyncio +import re +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import AsyncMock + +import botpy +import botpy.message +import pytest +from botpy import ConnectionSession + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import At, Plain +from astrbot.core.message.message_event_result import ( + MessageEventResult, + ResultContentType, +) +from astrbot.core.pipeline.respond.stage import RespondStage +from astrbot.core.pipeline.result_decorate.stage import ResultDecorateStage +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + QQOfficialPlatformAdapter, + _ensure_group_message_create_parser, +) +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + botClient as QQOfficialBotClient, +) +from astrbot.core.platform.sources.qqofficial_webhook.qo_webhook_adapter import ( + QQOfficialWebhookPlatformAdapter, +) + + +def _make_group_payload( + *, + message_id: str = "msg-1", + content: str = "hello world", + mentions: list[dict] | None = None, + member_openid: str = "member-1", + group_openid: str = "group-1", +) -> dict: + return { + "id": f"event-{message_id}", + "d": { + "id": message_id, + "content": content, + "author": {"member_openid": member_openid}, + "group_openid": group_openid, + "mentions": mentions or [], + "attachments": [], + }, + } + + +def _dispatch_group_message(payload: dict) -> tuple[str, botpy.message.GroupMessage]: + dispatched: list[tuple[str, botpy.message.GroupMessage]] = [] + _ensure_group_message_create_parser() + connection = ConnectionSession( + max_async=1, + connect=lambda: None, + dispatch=lambda event, message: dispatched.append((event, message)), + loop=asyncio.get_event_loop(), + api=None, + ) + connection.parser["group_message_create"](payload) + return dispatched[0] + + +@pytest.mark.asyncio +async def test_group_message_create_parser_is_registered_and_dispatches_group_message(): + QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + + event_name, message = _dispatch_group_message(_make_group_payload()) + + assert event_name == "group_message_create" + assert isinstance(message, botpy.message.GroupMessage) + assert message.group_openid == "group-1" + + +@pytest.mark.asyncio +async def test_parse_group_message_create_plain_message_has_no_at_component(): + _, message = _dispatch_group_message( + _make_group_payload(content="plain group message") + ) + + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + + assert abm.type == MessageType.GROUP_MESSAGE + assert abm.sender.user_id == "member-1" + assert abm.group_id == "group-1" + assert abm.message_str == "plain group message" + assert not any(isinstance(component, At) for component in abm.message) + assert [ + component.text for component in abm.message if isinstance(component, Plain) + ] == ["plain group message"] + + +@pytest.mark.asyncio +async def test_parse_group_message_create_bot_mention_cleans_plain_text(): + _, message = _dispatch_group_message( + _make_group_payload( + content="<@!bot-123> hello there", + mentions=[{"id": "bot-123", "is_you": True}], + ) + ) + + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + ) + + assert isinstance(abm.message[0], At) + assert abm.message[0].qq == "bot-123" + assert abm.self_id == "bot-123" + assert isinstance(abm.message[1], Plain) + assert abm.message[1].text == "hello there" + assert abm.message_str == "hello there" + assert abm.sender.user_id == "member-1" + assert abm.group_id == "group-1" + + +@pytest.mark.asyncio +async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing(): + message = botpy.message.GroupMessage( + None, + "event-legacy", + _make_group_payload(content="legacy text", mentions=[])["d"], + ) + + abm = await QQOfficialPlatformAdapter._parse_from_qqofficial( + message, + MessageType.GROUP_MESSAGE, + force_group_mention=True, + ) + + assert isinstance(abm.message[0], At) + assert abm.message[0].qq == "qq_official" + assert abm.self_id == "qq_official" + assert isinstance(abm.message[1], Plain) + assert abm.message[1].text == "legacy text" + + +@pytest.mark.asyncio +async def test_group_message_create_handler_maps_group_session_and_scene(): + _, message = _dispatch_group_message(_make_group_payload()) + committed: list = [] + remembered_scenes: list[tuple[str, str]] = [] + remembered_ids: list[tuple[str, str]] = [] + + class PlatformStub: + def remember_session_scene(self, session_id: str, scene: str) -> None: + remembered_scenes.append((session_id, scene)) + + def remember_session_message_id(self, session_id: str, message_id: str) -> None: + remembered_ids.append((session_id, message_id)) + + def create_event(self, message_obj): + return message_obj + + def commit_event(self, event) -> None: + committed.append(event) + + client = QQOfficialBotClient( + intents=botpy.Intents(public_messages=True), + bot_log=False, + ) + client.set_platform(cast(Any, PlatformStub())) + + await client.on_group_message_create(message) + + assert remembered_scenes == [("group-1", "group")] + assert remembered_ids == [("group-1", "msg-1")] + assert committed[0].type == MessageType.GROUP_MESSAGE + assert committed[0].group_id == "group-1" + assert committed[0].session_id == "group-1" + + +@pytest.mark.asyncio +async def test_ws_group_send_by_session_without_cached_msg_id_omits_msg_id(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-1"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain("proactive hello")]), + ) + + adapter.client.api.post_group_message.assert_awaited_once() + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["group_openid"] == "group-1" + assert kwargs["content"] == "proactive hello" + assert "msg_id" not in kwargs + assert "msg_seq" in kwargs + assert adapter._session_last_message_id["group-1"] == "sent-1" + + +@pytest.mark.asyncio +async def test_ws_group_send_by_session_with_cached_msg_id_still_omits_msg_id(): + adapter = QQOfficialPlatformAdapter( + { + "id": "qq-official-test", + "appid": "123", + "secret": "secret", + "enable_group_c2c": True, + "enable_guild_direct_message": False, + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-2"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + adapter._session_last_message_id["group-1"] = "stale-msg-id" + + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain("proactive with cache")]), + ) + + adapter.client.api.post_group_message.assert_awaited_once() + kwargs = adapter.client.api.post_group_message.await_args.kwargs + assert kwargs["group_openid"] == "group-1" + assert kwargs["content"] == "proactive with cache" + assert "msg_id" not in kwargs + assert "msg_seq" in kwargs + + +@pytest.mark.asyncio +async def test_webhook_group_send_by_session_without_cached_msg_id_skips_send(): + adapter = QQOfficialWebhookPlatformAdapter( + { + "id": "qq-official-webhook-test", + "appid": "123", + "secret": "secret", + }, + {}, + asyncio.Queue(), + ) + adapter.client.api = SimpleNamespace( + post_group_message=AsyncMock(return_value={"id": "sent-1"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + await adapter.send_by_session( + MessageSession("qq_official_webhook", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain("webhook proactive hello")]), + ) + + adapter.client.api.post_group_message.assert_not_awaited() + + +def test_qqofficial_ws_is_not_excluded_from_segmented_reply(): + stage = RespondStage() + stage.enable_seg = True + stage.only_llm_result = False + result = MessageEventResult(chain=[Plain("hello")]) + + event = SimpleNamespace( + get_result=lambda: result, + get_platform_name=lambda: "qq_official", + ) + + assert stage.is_seg_reply_required(cast(Any, event)) is True + + +def test_qqofficial_webhook_remains_excluded_from_segmented_reply(): + stage = RespondStage() + stage.enable_seg = True + stage.only_llm_result = False + result = MessageEventResult(chain=[Plain("hello")]) + + event = SimpleNamespace( + get_result=lambda: result, + get_platform_name=lambda: "qq_official_webhook", + ) + + assert stage.is_seg_reply_required(cast(Any, event)) is False + + +@pytest.mark.asyncio +async def test_result_decorate_segments_qqofficial_ws_plain_result(): + stage = ResultDecorateStage() + stage.reply_prefix = "" + stage.content_safe_check_reply = False + stage.enable_segmented_reply = True + stage.only_llm_result = False + stage.words_count_threshold = 100 + stage.split_mode = "words" + stage.split_words = ["。"] + stage.split_words_pattern = re.compile(r"(.*?(。)|.+$)", re.DOTALL) + stage.content_cleanup_rule = "" + stage.show_reasoning = False + stage.tts_trigger_probability = 0 + stage.reply_with_mention = False + stage.reply_with_quote = False + stage.forward_threshold = 1000 + setattr( + stage, + "ctx", + SimpleNamespace( + plugin_manager=SimpleNamespace( + context=SimpleNamespace(get_using_tts_provider=lambda _umo: None) + ), + astrbot_config={ + "provider_tts_settings": { + "enable": False, + "use_file_service": False, + "dual_output": False, + }, + "callback_api_base": "", + "t2i": False, + }, + ), + ) + result = MessageEventResult( + chain=[Plain("第一段。第二段。")], + result_content_type=ResultContentType.LLM_RESULT, + ) + + event = SimpleNamespace( + plugins_name=None, + unified_msg_origin="qq_official:GroupMessage:group-1", + get_result=lambda: result, + get_platform_name=lambda: "qq_official", + is_stopped=lambda: False, + get_extra=lambda *_args, **_kwargs: None, + ) + + processed = stage.process(cast(Any, event)) + if hasattr(processed, "__aiter__"): + async for _ in cast(Any, processed): + pass + else: + yielded = await cast(Any, processed) + if yielded is not None: + async for _ in cast(Any, yielded): + pass + + assert [comp.text for comp in result.chain if isinstance(comp, Plain)] == [ + "第一段", + "第二段", + ]