mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-18 10:00:40 +08:00
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:
@@ -138,7 +138,7 @@ class RespondStage(Stage):
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
"qq_official",
|
||||
"qq_official_webhook",
|
||||
"weixin_official_account",
|
||||
"dingtalk",
|
||||
]:
|
||||
|
||||
@@ -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",
|
||||
]:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -37,6 +37,14 @@ Then configure QQ groups, private chat QQ accounts, and QQ channels as needed.
|
||||
|
||||

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

|
||||
|
||||
## Get `appid` and `secret`
|
||||
|
||||
After adding the bot where you need it, open `Development -> Development Settings`, then copy `appid` and `secret`.
|
||||
|
||||
@@ -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.
|
||||
|
||||

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

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

|
||||
|
||||
## 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.
|
||||
|
||||
BIN
docs/public/qqofficial-group-recommended-config.png
Normal file
BIN
docs/public/qqofficial-group-recommended-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -40,6 +40,14 @@
|
||||
|
||||

|
||||
|
||||
## 推荐群聊配置
|
||||
|
||||
在手机 QQ 的群聊设置中打开机器人设置,推荐将 `机器人可获取的群聊消息范围` 设置为 `获取群内全部消息`,并开启 `机器人主动在群聊内发言`。
|
||||
|
||||
这样机器人可以接收群聊全量消息,也可以在群聊中主动推送消息,例如定时任务推送、插件主动通知等。
|
||||
|
||||

|
||||
|
||||
## 获取 appid、secret
|
||||
|
||||
添加机器人到你想用的地方后。
|
||||
|
||||
@@ -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 机器人(不推荐)
|
||||
|
||||
### 申请一个机器人
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
@@ -44,7 +56,7 @@
|
||||
|
||||

|
||||
|
||||
## 允许机器人加入频道/群/私聊
|
||||
### 允许机器人加入频道/群/私聊
|
||||
|
||||
点击`沙箱配置`,这允许你立即设置一个沙箱频道/QQ群/QQ私聊,用于拉入机器人(需要小于等于20个人)。
|
||||
|
||||
@@ -52,13 +64,15 @@
|
||||
|
||||

|
||||
|
||||
## 获取 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` 以检查是否连接成功。
|
||||
|
||||
372
tests/test_qqofficial_group_message_create.py
Normal file
372
tests/test_qqofficial_group_message_create.py
Normal 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)] == [
|
||||
"第一段",
|
||||
"第二段",
|
||||
]
|
||||
Reference in New Issue
Block a user