From 55af880369fcd2136fb30202960dfa2758710978 Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:13:05 +0800 Subject: [PATCH] feat(qqofficial): allow QQ Official Webhook adapters to proactively send group messages without requiring a cached msg_id (#8841) * feat(qqofficial): support webhook QR setup * docs(qqofficial): simplify webhook QR setup step --- .../qqofficial/qqofficial_platform_adapter.py | 2 +- .../qqofficial_webhook/qo_webhook_adapter.py | 2 +- .../dashboard/services/platform_service.py | 2 +- .../components/platform/AddNewPlatform.vue | 4 +- .../platform/PlatformRegistrationAction.vue | 7 ++ docs/en/platform/qqofficial/webhook.md | 97 +++++++++++++----- docs/zh/platform/qqofficial/webhook.md | 99 ++++++++++++------- tests/test_qqofficial_group_message_create.py | 10 +- tests/test_qqofficial_login_registration.py | 40 ++++++++ 9 files changed, 198 insertions(+), 65 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 38e88a4ba..fc96d4404 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -413,7 +413,7 @@ class QQOfficialPlatformAdapter(Platform): sent_message_id = self._extract_message_id(ret) if sent_message_id: self.remember_session_message_id(session.session_id, sent_message_id) - await super().send_by_session(session, message_chain) + await Platform.send_by_session(self, session, message_chain) def remember_session_message_id(self, session_id: str, message_id: str) -> None: if not session_id or not message_id: 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 1a90f4ec9..c877d7540 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -123,7 +123,7 @@ class QQOfficialWebhookPlatformAdapter(Platform): self.webhook_helper = None self._session_last_message_id: dict[str, str] = {} self._session_scene: dict[str, str] = {} - self._allow_group_proactive_send = False + self._allow_group_proactive_send = True async def send_by_session( self, diff --git a/astrbot/dashboard/services/platform_service.py b/astrbot/dashboard/services/platform_service.py index 07152ce07..f30bd544a 100644 --- a/astrbot/dashboard/services/platform_service.py +++ b/astrbot/dashboard/services/platform_service.py @@ -99,7 +99,7 @@ class PlatformService: ) if platform_type == "dingtalk": return await self._handle_dingtalk_registration(action, payload) - if platform_type == "qq_official": + if platform_type in {"qq_official", "qq_official_webhook"}: return await self._handle_qqofficial_registration( action, payload, diff --git a/dashboard/src/components/platform/AddNewPlatform.vue b/dashboard/src/components/platform/AddNewPlatform.vue index 0263c5847..451298b81 100644 --- a/dashboard/src/components/platform/AddNewPlatform.vue +++ b/dashboard/src/components/platform/AddNewPlatform.vue @@ -1045,7 +1045,9 @@ export default { return this.selectedPlatformConfig?.type === "dingtalk"; }, isQqOfficialPlatform() { - return this.selectedPlatformConfig?.type === "qq_official"; + return ["qq_official", "qq_official_webhook"].includes( + this.selectedPlatformConfig?.type, + ); }, }, watch: { diff --git a/dashboard/src/components/platform/PlatformRegistrationAction.vue b/dashboard/src/components/platform/PlatformRegistrationAction.vue index 2c7c96b78..87e164f6e 100644 --- a/dashboard/src/components/platform/PlatformRegistrationAction.vue +++ b/dashboard/src/components/platform/PlatformRegistrationAction.vue @@ -77,6 +77,13 @@ const REGISTRATION_ACTIONS = { successKey: 'registrationAction.qqOfficial.created', statusKeyPrefix: 'registrationAction.qqOfficial.status', }, + qq_official_webhook: { + icon: 'mdi-qrcode', + titleKey: 'registrationAction.qqOfficial.title', + scanTitleKey: 'registrationAction.qqOfficial.scanTitle', + successKey: 'registrationAction.qqOfficial.created', + statusKeyPrefix: 'registrationAction.qqOfficial.status', + }, }; export default { diff --git a/docs/en/platform/qqofficial/webhook.md b/docs/en/platform/qqofficial/webhook.md index 23bff4679..48e4d6c87 100644 --- a/docs/en/platform/qqofficial/webhook.md +++ b/docs/en/platform/qqofficial/webhook.md @@ -2,8 +2,8 @@ > [!WARNING] > 1. QQ Official Bot currently requires an IP whitelist. -> 2. It supports group chat, private chat, channel chat, and channel private chat. -> 3. You need a server with a public IP and a domain. +> 2. Webhook mode requires a server with a public IP, a domain, and HTTPS access. +> 3. It supports group chat, private chat, channel chat, and channel private chat. ## Supported Basic Message Types @@ -19,7 +19,43 @@ Proactive message push: Supported. -## Apply for a Bot +## Create a QQ Bot in AstrBot with One-click QR Setup (Recommended) + +### Setup Flow + +1. In AstrBot WebUI, click `Bots` in the left sidebar, then click `+ Create Bot`. +2. Select `QQ Official Bot (Webhook)`. +3. Under `Choose setup method`, select `One-click QR setup`, click start, then scan the QR code with mobile QQ. +4. After confirming the QR binding, click `Save`. +5. Configure DNS and a reverse proxy for your server so HTTPS requests are forwarded to AstrBot's `6185` port. +6. Go back to the QQ Open Platform bot management page, open `Development -> Callback Configuration`, and enter the Webhook callback URL generated by AstrBot. +7. Select the callback events you need. To receive full group messages, make sure the group event `GROUP_MESSAGE_CREATE` is selected. +8. Save the callback configuration, then restart AstrBot. + +> [!TIP] +> With `Unified Webhook Mode`, AstrBot generates a unique Webhook callback URL automatically. You can find it in the logs or on the bot card in WebUI. + +![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png) + +### Use in Group Chats + +#### Add to a Group Chat + +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. + +Webhook mode also requires selecting the group event `GROUP_MESSAGE_CREATE` in QQ Open Platform callback configuration. Otherwise, AstrBot cannot receive full group message events. + +![QQ Official Bot recommended group chat settings](/qqofficial-group-recommended-config.png) + +## Manually Apply for a QQ Bot (Not Recommended) + +### Apply for a Bot Open [QQ Official Bot](https://q.qq.com) and sign in. @@ -29,7 +65,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). @@ -37,49 +73,62 @@ 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` +### 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. ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-3.png) -## Configure in AstrBot +> [!TIP] +> If you do not know your server IP, run `curl ifconfig.me` or check [ip138.com](https://ip138.com/). +> +> In NAT environments without a public IP, the observed IP may change depending on your carrier. Use proxy/tunnel if needed. + +### Configure in AstrBot 1. Open AstrBot Dashboard. 2. Click `Bots` in the left sidebar. 3. Click `+ Create Bot`. -4. Select `qq_official_webhook`. +4. Select `QQ Official Bot (Webhook)`. -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. Keep `Unified Webhook Mode` enabled, adjust `ID` 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. +- Unified Webhook Mode (`unified_webhook_mode`): keep enabled. Click `Save`. -## Configure Callback URL +### Configure Reverse Proxy -In `Development -> Callback Configuration`, configure callback URL. +After saving, configure DNS and reverse proxy for your server. Forward requests to AstrBot's `6185` port. If `Unified Webhook Mode` is disabled, forward requests to the port configured in the previous step instead. -Set request URL to `/astrbot-qo-webhook/callback`. +The Webhook callback URL must be reachable from QQ Open Platform over the public internet and must use HTTPS. -Your domain should reverse-proxy traffic to AstrBot port `6196` using `Caddy`, `Nginx`, or `Apache`. +### Configure Callback URL and Events -Then add callback events and select all four event categories (private, group, channel, etc.). +Open `Development -> Callback Configuration`. + +After you save the bot in AstrBot, AstrBot generates a unique Webhook callback URL. You can find it in the logs or on the bot card in WebUI. + +Use that URL as the request URL. + +Then add callback events. To receive full group messages, select the group event `GROUP_MESSAGE_CREATE`; also select private chat events, channel events, and other events as needed. ![image](https://files.astrbot.app/docs/source/images/webhook/image.png) @@ -87,10 +136,6 @@ After entering values, move focus out of the input box to trigger validation. If Then restart AstrBot. -## Done - -AstrBot should now be connected. If messages do not respond immediately, wait 1-2 minutes, restart AstrBot, and test again. - ## Appendix: Reverse Proxy Setup If you are new to reverse proxy, Caddy is recommended: diff --git a/docs/zh/platform/qqofficial/webhook.md b/docs/zh/platform/qqofficial/webhook.md index 949440d73..b07facc30 100644 --- a/docs/zh/platform/qqofficial/webhook.md +++ b/docs/zh/platform/qqofficial/webhook.md @@ -1,12 +1,10 @@ - # 通过 QQ官方机器人 接入 QQ (Webhook) > [!WARNING] > > 1. 截至目前,QQ 官方机器人需要设置 IP 白名单。 -> 2. 支持群聊、私聊、频道聊天、频道私聊。 -> -> **需要**一台带有公网 IP 的服务器和域名(如果没备案,需要服务器在海外或者中国港澳台地区) +> 2. Webhook 模式需要一台带公网 IP 的服务器、域名和 HTTPS 访问能力。 +> 3. 支持群聊、私聊、频道聊天、频道私聊。 ## 支持的基本消息类型 @@ -22,7 +20,43 @@ 主动消息推送:支持。 -## 申请一个机器人 +## 在 AstrBot 中扫码一键创建 QQ 机器人(推荐) + +### 配置流程 + +1. 进入 AstrBot 的 WebUI,点击左边栏 `机器人`,然后点击 `+ 创建机器人`。 +2. 选择 `QQ 官方机器人(Webhook)`。 +3. 在 `选择创建方式` 中选择 `扫码一键创建`,点击开始创建后,用手机 QQ 扫描页面中的二维码。 +4. 扫码确认后,点击 `保存`。 +5. 根据服务器环境配置域名 DNS 解析和反向代理,将 HTTPS 请求转发到 AstrBot 所在服务器的 `6185` 端口。 +6. 回到 QQ 开放平台的机器人管理页,在 `开发 -> 回调配置` 中填写 AstrBot 生成的 Webhook 回调地址。 +7. 在回调事件中勾选需要接收的事件。如果需要接收群聊全量消息,请确保勾选群事件 `GROUP_MESSAGE_CREATE`。 +8. 保存回调配置后,重启 AstrBot。 + +> [!TIP] +> 使用 `统一 Webhook 模式` 时,AstrBot 会自动生成唯一的 Webhook 回调链接。你可以在日志中,或者 WebUI 的机器人卡片上找到该链接。 + +![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png) + +### 在群聊中使用 + +#### 添加到群聊 + +进入创建的 QQ 机器人的资料页(手机QQ -> 联系人 -> 机器人页签),在下方可以找到 “添加到群聊”。目前只能添加到自己为群主的群聊。 + +#### 设置机器人可获取的群聊消息范围和主动发言 + +在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。 + +这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。 + +Webhook 模式还需要在 QQ 开放平台的回调配置中勾选群事件 `GROUP_MESSAGE_CREATE`,否则 AstrBot 无法收到群聊全量消息事件。 + +![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png) + +## 手动申请 QQ 机器人(不推荐) + +### 申请一个机器人 首先,打开 [QQ官方机器人](https://q.qq.com) 并登录。 @@ -32,7 +66,7 @@ ![image](https://files.astrbot.app/docs/source/images/qqofficial/image.png) -## 允许机器人加入频道/群/私聊 +### 允许机器人加入频道/群/私聊 点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊,用于拉入机器人(需要小于等于20个人)。 @@ -40,34 +74,40 @@ ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-1.png) -## 推荐群聊配置 - -在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。 - -这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。 - -![QQ 官方机器人推荐群聊配置](/qqofficial-group-recommended-config.png) - -## 获取 appid、secret +### 获取 appid、secret 添加机器人到你想用的地方后。 点击 `开发->开发设置`,找到 appid、secret。复制并保存它们。 -## 添加 IP 白名单 +如果你使用 AstrBot WebUI 的 `扫码一键创建`,这一步可以跳过。扫码绑定成功后,AstrBot 会自动填入 `appid` 和 `secret`。 + +### 添加 IP 白名单 点击 `开发->开发设置`,找到 IP 白名单。添加你的服务器 IP 地址。 ![image](https://files.astrbot.app/docs/source/images/qqofficial/image-3.png) -## 在 AstrBot 配置 +> [!TIP] +> 如果你不知道你的服务器 IP 地址,可以在终端中输入 `curl ifconfig.me` 来获取。或者登录 [ip138.com](https://ip138.com/) 查看。 +> +> 如果你在没有公网 IP 的环境下,你看到的 IP 是运营商 NAT 的 IP,这个 IP 根据你的运营商的情况可能会随时变化。如有必要,可以配置代理。 + +### 在 AstrBot 配置 1. 进入 AstrBot 的管理面板 2. 点击左边栏 `机器人` 3. 然后在右边的界面中,点击 `+ 创建机器人` -4. 选择 `qq_official_webhook` +4. 选择 `QQ 官方机器人(Webhook)` -弹出的配置项填写: +推荐使用 `扫码一键创建`: + +1. 在 `选择创建方式` 中选择 `扫码一键创建`。 +2. 点击开始创建,用手机 QQ 扫描二维码并确认。 +3. 等待页面显示绑定成功。AstrBot 会自动填入 `appid` 和 `secret`。 +4. 保持 `统一 Webhook 模式` 开启,根据需要调整 `ID` 等配置,然后点击 `保存`。 + +如果扫码不可用,也可以选择 `手动创建`。弹出的配置项填写: - ID(id):随意填写,用于区分不同的消息平台实例。 - 启用(enable): 勾选。 @@ -77,24 +117,21 @@ 点击 `保存`。 -## 反向代理 +### 配置反向代理 -保存之后,请根据你的服务器环境,配置域名 DNS 解析和反向代理,将请求转发到 AstrBot 所在服务器的 `6185` 端口 (如果没有开启统一 Webhook 模式,将请求转发到上一步配置指定的端口)。 +保存之后,请根据你的服务器环境,配置域名 DNS 解析和反向代理,将请求转发到 AstrBot 所在服务器的 `6185` 端口(如果没有开启统一 Webhook 模式,将请求转发到上一步配置指定的端口)。 -## 设置回调地址 +Webhook 回调地址必须可以被 QQ 开放平台公网访问,并且需要使用 HTTPS。 -在 `开发->回调配置` 处,配置回调地址。 +### 设置回调地址和事件 + +在 `开发 -> 回调配置` 处,配置回调地址。 上一步点击保存之后,AstrBot 将会自动为你生成唯一的 Webhook 回调链接,你可以在日志中或者 WebUI 的机器人页的卡片上找到。 -![unified_webhook](https://files.astrbot.app/docs/source/images/use/unified-webhook.png) - 将请求地址填写为该地址。 -> [!TIP] -> v4.8.0 之前没有 `统一 Webhook 模式`,则请求地址填写 `<你的域名>/astrbot-qo-webhook/callback`。 - -填写好之后,添加事件,四个事件类型都全选:单聊事件、群事件、频道事件等,如下图。 +填写好之后,添加事件。需要接收群聊全量消息时,请勾选群事件 `GROUP_MESSAGE_CREATE`;同时按需勾选单聊事件、频道事件等。 ![image](https://files.astrbot.app/docs/source/images/webhook/image.png) @@ -102,10 +139,6 @@ 接着重启 AstrBot。 -## 🎉 大功告成 - -此时,你的 AstrBot 应该已经连接成功。如果发送消息没有反应,请等待一两分钟后重启 AstrBot 再进行确认(测试时发现回调地址不会立即生效)。 - ## 附录:如何配置反向代理 如果你还没有相关经验,这里推荐使用 Caddy 作为反向代理的工具,请参考: diff --git a/tests/test_qqofficial_group_message_create.py b/tests/test_qqofficial_group_message_create.py index 67017369a..46d688d15 100644 --- a/tests/test_qqofficial_group_message_create.py +++ b/tests/test_qqofficial_group_message_create.py @@ -255,7 +255,7 @@ async def test_ws_group_send_by_session_with_cached_msg_id_still_omits_msg_id(): @pytest.mark.asyncio -async def test_webhook_group_send_by_session_without_cached_msg_id_skips_send(): +async def test_webhook_group_send_by_session_without_cached_msg_id_omits_msg_id(): adapter = QQOfficialWebhookPlatformAdapter( { "id": "qq-official-webhook-test", @@ -276,7 +276,13 @@ async def test_webhook_group_send_by_session_without_cached_msg_id_skips_send(): MessageChain(chain=[Plain("webhook proactive hello")]), ) - adapter.client.api.post_group_message.assert_not_awaited() + 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"] == "webhook proactive hello" + assert "msg_id" not in kwargs + assert "msg_seq" in kwargs + assert adapter._session_last_message_id["group-1"] == "sent-1" def test_qqofficial_ws_is_not_excluded_from_segmented_reply(): diff --git a/tests/test_qqofficial_login_registration.py b/tests/test_qqofficial_login_registration.py index 01b7c497c..41736f130 100644 --- a/tests/test_qqofficial_login_registration.py +++ b/tests/test_qqofficial_login_registration.py @@ -1,4 +1,5 @@ import base64 +from types import SimpleNamespace import pytest from Crypto.Cipher import AES @@ -11,6 +12,8 @@ from astrbot.core.platform.sources.qqofficial.login_registration import ( generate_qqofficial_bind_key, qqofficial_login_result, ) +from astrbot.dashboard.services import platform_service +from astrbot.dashboard.services.platform_service import PlatformService def test_generate_qqofficial_bind_key_returns_base64_aes_key(): @@ -69,3 +72,40 @@ def test_decrypt_qqofficial_secret_rejects_invalid_payload(): with pytest.raises(ValueError): decrypt_qqofficial_secret("invalid", bind_key) + + +@pytest.mark.asyncio +async def test_qqofficial_webhook_registration_reuses_qr_binding(monkeypatch): + async def fake_request_qqofficial_login_qr(platform_config: dict): + assert platform_config["type"] == "qq_official_webhook" + return SimpleNamespace( + task_id="task-1", + bind_key="bind-key", + qrcode="qr-content", + interval=3, + ) + + monkeypatch.setattr( + platform_service, + "request_qqofficial_login_qr", + fake_request_qqofficial_login_qr, + ) + service = PlatformService.__new__(PlatformService) + + result = await service.handle_platform_registration( + "qq_official_webhook", + { + "action": "start", + "platform_config": {"type": "qq_official_webhook"}, + }, + ) + + assert result == { + "status": "pending", + "registration_code": "task-1", + "task_id": "task-1", + "bind_key": "bind-key", + "qrcode": "qr-content", + "qrcode_img_content": "qr-content", + "interval": 3, + }