feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 (#5066)

* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件

* feat: enhance QQOfficialWebhook to remember session scenes for group, channel, and friend messages
This commit is contained in:
Soulter
2026-02-12 19:07:53 +08:00
committed by GitHub
parent 4af064fd17
commit d2de0ea5ad
3 changed files with 165 additions and 23 deletions
@@ -183,6 +183,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
else:
yield await self.provider.text_chat(**payload)
def _simple_print_message_role(self, tag: str = ""):
roles = []
for message in self.run_context.messages:
roles.append(message.role)
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
@override
async def step(self):
"""Process a single step of the agent.
@@ -203,9 +209,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# do truncate and compress
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
self._simple_print_message_role("[BefCompact]")
self.run_context.messages = await self.context_manager.process(
self.run_context.messages, trusted_token_usage=token_usage
)
self._simple_print_message_role("[AftCompact]")
async for llm_response in self._iter_llm_responses():
if llm_response.is_chunk:
@@ -8,13 +8,11 @@ from typing import cast
import botpy
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, Image, Plain
from astrbot.api.message_components import At, File, Image, Plain
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -143,6 +141,41 @@ class QQOfficialPlatformAdapter(Platform):
support_proactive_message=False,
)
@staticmethod
def _normalize_attachment_url(url: str | None) -> str:
if not url:
return ""
if url.startswith("http://") or url.startswith("https://"):
return url
return f"https://{url}"
@staticmethod
def _append_attachments(
msg: list[BaseMessageComponent],
attachments: list | None,
) -> None:
if not attachments:
return
for attachment in attachments:
content_type = cast(str, getattr(attachment, "content_type", "") or "")
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
if not url:
continue
if content_type.startswith("image"):
msg.append(Image.fromURL(url))
else:
filename = cast(
str,
getattr(attachment, "filename", None)
or getattr(attachment, "name", None)
or "attachment",
)
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_from_qqofficial(
message: botpy.message.Message
@@ -172,14 +205,7 @@ class QQOfficialPlatformAdapter(Platform):
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
if message.attachments:
for i in message.attachments:
if i.content_type.startswith("image"):
url = i.url
if not url.startswith("http"):
url = "https://" + url
img = Image.fromURL(url)
msg.append(img)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
elif isinstance(message, botpy.message.Message) or isinstance(
@@ -196,14 +222,7 @@ class QQOfficialPlatformAdapter(Platform):
"",
).strip()
if message.attachments:
for i in message.attachments:
if i.content_type.startswith("image"):
url = i.url
if not url.startswith("http"):
url = "https://" + url
img = Image.fromURL(url)
msg.append(img)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
abm.message_str = plain_content
abm.sender = MessageMember(
@@ -1,11 +1,11 @@
import asyncio
import logging
import random
from types import SimpleNamespace
from typing import Any, cast
import botpy
import botpy.message
import botpy.types
import botpy.types.message
from botpy import Client
from astrbot import logger
@@ -15,6 +15,7 @@ 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_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook
@@ -39,6 +40,7 @@ class botClient(Client):
)
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)
# 收到频道消息
@@ -49,6 +51,7 @@ class botClient(Client):
)
abm.group_id = message.channel_id
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm)
# 收到私聊消息
@@ -60,6 +63,7 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
# 收到 C2C 消息
@@ -69,9 +73,11 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event(
QQOfficialWebhookMessageEvent(
abm.message_str,
@@ -109,20 +115,129 @@ class QQOfficialWebhookPlatformAdapter(Platform):
)
self.client.set_platform(self)
self.webhook_helper = None
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
async def send_by_session(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
(
plain_text,
image_base64,
image_path,
record_file_path,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if not plain_text and not image_path:
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficialWebhook] 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}
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:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
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)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
return
self._session_last_message_id[session_id] = message_id
def remember_session_scene(self, session_id: str, scene: str) -> None:
if not session_id or not scene:
return
self._session_scene[session_id] = scene
def _extract_message_id(self, ret: Any) -> str | None:
if isinstance(ret, dict):
message_id = ret.get("id")
return str(message_id) if message_id else None
message_id = getattr(ret, "id", None)
if message_id:
return str(message_id)
return None
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="qq_official_webhook",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_proactive_message=False,
support_proactive_message=True,
)
async def run(self) -> None: