feat(qqofficial): support group message create type (#8838)

* feat(qqofficial): support group message create

* chore: remove temporary qq official capture script

* feat(qqofficial): allow ws segmented replies

* fix(qqofficial): guard missing group mentions

* feat(qqofficial): enhance group message handling with debug logging and sender username

* feat(qqofficial): add recommended group chat settings and update bot creation instructions

* feat(qqofficial): enhance error handling for QQ Official API message sending

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
letr
2026-06-17 19:11:48 +08:00
committed by GitHub
parent 2cafa217f2
commit 30ae18a8f0
12 changed files with 621 additions and 56 deletions

View File

@@ -138,7 +138,7 @@ class RespondStage(Stage):
return False
if event.get_platform_name() in [
"qq_official",
"qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:

View File

@@ -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",
]:

View File

@@ -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"])

View File

@@ -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):

View File

@@ -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,

View File

@@ -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:

View File

@@ -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`.

View File

@@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -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
添加机器人到你想用的地方后。

View File

@@ -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` 以检查是否连接成功。

View File

@@ -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)] == [
"第一段",
"第二段",
]