From 5c3643c54c0a0a997d2bc10307cd58aca0d78007 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:26:08 +0800 Subject: [PATCH] feat: added support for file, voice, and video messages for QQ Official Bot (including WebSocket mode). (#6063) --- .../qqofficial/qqofficial_message_event.py | 121 ++++++++-- .../qqofficial/qqofficial_platform_adapter.py | 221 +++++++++++++++++- .../qqofficial_webhook/qo_webhook_adapter.py | 97 +------- docs/en/platform/qqofficial/webhook.md | 10 +- docs/en/platform/qqofficial/websockets.md | 10 +- docs/zh/platform/qqofficial/webhook.md | 10 +- docs/zh/platform/qqofficial/websockets.md | 10 +- 7 files changed, 344 insertions(+), 135 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 2b417f45f..d1fd0e187 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import Image, Plain, Record +from astrbot.api.message_components import File, Image, Plain, Record, Video from astrbot.api.platform import AstrBotMessage, PlatformMetadata from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_image_by_url, file_to_base64 @@ -47,6 +47,10 @@ _patch_qq_botpy_formdata() class QQOfficialMessageEvent(AstrMessageEvent): MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" + IMAGE_FILE_TYPE = 1 + VIDEO_FILE_TYPE = 2 + VOICE_FILE_TYPE = 3 + FILE_FILE_TYPE = 4 def __init__( self, @@ -126,6 +130,9 @@ class QQOfficialMessageEvent(AstrMessageEvent): image_base64, image_path, record_file_path, + video_file_source, + file_source, + file_name, ) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) if ( @@ -133,6 +140,8 @@ class QQOfficialMessageEvent(AstrMessageEvent): and not image_base64 and not image_path and not record_file_path + and not video_file_source + and not file_source ): return None @@ -157,7 +166,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): if image_base64: media = await self.upload_group_and_c2c_image( image_base64, - 1, + self.IMAGE_FILE_TYPE, group_openid=source.group_openid, ) payload["media"] = media @@ -165,15 +174,39 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload.pop("markdown", None) payload["content"] = plain_text or None if record_file_path: # group record msg - media = await self.upload_group_and_c2c_record( + media = await self.upload_group_and_c2c_media( record_file_path, - 3, + self.VOICE_FILE_TYPE, group_openid=source.group_openid, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None + if video_file_source: + media = await self.upload_group_and_c2c_media( + video_file_source, + self.VIDEO_FILE_TYPE, + group_openid=source.group_openid, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None + if file_source: + media = await self.upload_group_and_c2c_media( + file_source, + self.FILE_FILE_TYPE, + file_name=file_name, + group_openid=source.group_openid, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.bot.api.post_group_message( group_openid=source.group_openid, # type: ignore @@ -187,7 +220,7 @@ class QQOfficialMessageEvent(AstrMessageEvent): if image_base64: media = await self.upload_group_and_c2c_image( image_base64, - 1, + self.IMAGE_FILE_TYPE, openid=source.author.user_openid, ) payload["media"] = media @@ -195,15 +228,39 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload.pop("markdown", None) payload["content"] = plain_text or None if record_file_path: # c2c record - media = await self.upload_group_and_c2c_record( + media = await self.upload_group_and_c2c_media( record_file_path, - 3, + self.VOICE_FILE_TYPE, openid=source.author.user_openid, ) - payload["media"] = media - payload["msg_type"] = 7 - payload.pop("markdown", None) - payload["content"] = plain_text or None + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None + if video_file_source: + media = await self.upload_group_and_c2c_media( + video_file_source, + self.VIDEO_FILE_TYPE, + openid=source.author.user_openid, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None + if file_source: + media = await self.upload_group_and_c2c_media( + file_source, + self.FILE_FILE_TYPE, + file_name=file_name, + openid=source.author.user_openid, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + payload.pop("markdown", None) + payload["content"] = plain_text or None if stream: ret = await self._send_with_markdown_fallback( send_func=lambda retry_payload: self.post_c2c_message( @@ -327,16 +384,19 @@ class QQOfficialMessageEvent(AstrMessageEvent): ttl=result.get("ttl", 0), ) - async def upload_group_and_c2c_record( + async def upload_group_and_c2c_media( self, file_source: str, file_type: int, srv_send_msg: bool = False, + file_name: str | None = None, **kwargs, ) -> Media | None: """上传媒体文件""" # 构建基础payload payload = {"file_type": file_type, "srv_send_msg": srv_send_msg} + if file_name: + payload["file_name"] = file_name # 处理文件数据 if os.path.exists(file_source): @@ -416,6 +476,9 @@ class QQOfficialMessageEvent(AstrMessageEvent): image_base64 = None # only one img supported image_file_path = None record_file_path = None + video_file_source = None + file_source = None + file_name = None for i in message.chain: if isinstance(i, Plain): plain_text += i.text @@ -454,6 +517,30 @@ class QQOfficialMessageEvent(AstrMessageEvent): except Exception as e: logger.error(f"处理语音时出错: {e}") record_file_path = None + elif isinstance(i, Video) and not video_file_source: + if i.file.startswith("file:///"): + video_file_source = i.file[8:] + else: + video_file_source = i.file + elif isinstance(i, File) and not file_source: + file_name = i.name + if i.file_: + file_path = i.file_ + if file_path.startswith("file:///"): + file_path = file_path[8:] + elif file_path.startswith("file://"): + file_path = file_path[7:] + file_source = file_path + elif i.url: + file_source = i.url else: logger.debug(f"qq_official 忽略 {i.type}") - return plain_text, image_base64, image_file_path, record_file_path + return ( + plain_text, + image_base64, + image_file_path, + record_file_path, + video_file_source, + file_source, + file_name, + ) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 603bc8f58..88d4a2128 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio import logging import os +import random import time -from typing import cast +from types import SimpleNamespace +from typing import Any, cast import botpy import botpy.message @@ -12,7 +14,7 @@ from botpy import Client from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import At, File, Image, Plain +from astrbot.api.message_components import At, File, Image, Plain, Record, Video from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -46,6 +48,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) # 收到频道消息 @@ -56,6 +59,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) # 收到私聊消息 @@ -67,6 +71,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 消息 @@ -76,9 +81,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( QQOfficialMessageEvent( abm.message_str, @@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform): self.client.set_platform(self) + self._session_last_message_id: dict[str, str] = {} + self._session_scene: dict[str, str] = {} + self.test_mode = os.environ.get("TEST_MODE", "off") == "on" async def send_by_session( @@ -131,14 +141,185 @@ class QQOfficialPlatformAdapter(Platform): session: MessageSesion, message_chain: MessageChain, ) -> None: - raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + await self._send_by_session_common(session, message_chain) + + async def _send_by_session_common( + self, + session: MessageSesion, + message_chain: MessageChain, + ) -> None: + ( + plain_text, + image_base64, + image_path, + record_file_path, + video_file_source, + file_source, + file_name, + ) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain) + if ( + not plain_text + and not image_path + and not image_base64 + and not record_file_path + and not video_file_source + and not file_source + ): + return + + msg_id = self._session_last_message_id.get(session.session_id) + if not msg_id: + 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} + 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, + QQOfficialMessageEvent.IMAGE_FILE_TYPE, + group_openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + if record_file_path: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + record_file_path, + QQOfficialMessageEvent.VOICE_FILE_TYPE, + group_openid=session.session_id, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + if video_file_source: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + video_file_source, + QQOfficialMessageEvent.VIDEO_FILE_TYPE, + group_openid=session.session_id, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + if file_source: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + file_source, + QQOfficialMessageEvent.FILE_FILE_TYPE, + file_name=file_name, + group_openid=session.session_id, + ) + if media: + 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, + QQOfficialMessageEvent.IMAGE_FILE_TYPE, + openid=session.session_id, + ) + payload["media"] = media + payload["msg_type"] = 7 + if record_file_path: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + record_file_path, + QQOfficialMessageEvent.VOICE_FILE_TYPE, + openid=session.session_id, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + if video_file_source: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + video_file_source, + QQOfficialMessageEvent.VIDEO_FILE_TYPE, + openid=session.session_id, + ) + if media: + payload["media"] = media + payload["msg_type"] = 7 + if file_source: + media = await QQOfficialMessageEvent.upload_group_and_c2c_media( + send_helper, # type: ignore + file_source, + QQOfficialMessageEvent.FILE_FILE_TYPE, + file_name=file_name, + openid=session.session_id, + ) + if media: + 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( + "[QQOfficial] 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", description="QQ 机器人官方 API 适配器", id=cast(str, self.config.get("id")), - support_proactive_message=False, + support_proactive_message=True, ) @staticmethod @@ -158,7 +339,10 @@ class QQOfficialPlatformAdapter(Platform): return for attachment in attachments: - content_type = cast(str, getattr(attachment, "content_type", "") or "") + content_type = cast( + str, + getattr(attachment, "content_type", "") or "", + ).lower() url = QQOfficialPlatformAdapter._normalize_attachment_url( cast(str | None, getattr(attachment, "url", None)) ) @@ -174,7 +358,32 @@ class QQOfficialPlatformAdapter(Platform): or getattr(attachment, "name", None) or "attachment", ) - msg.append(File(name=filename, file=url, url=url)) + ext = os.path.splitext(filename)[1].lower() + image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"} + audio_exts = { + ".mp3", + ".wav", + ".ogg", + ".m4a", + ".amr", + ".silk", + } + video_exts = { + ".mp4", + ".mov", + ".avi", + ".mkv", + ".webm", + } + + if content_type.startswith("audio") or ext in audio_exts: + msg.append(Record.fromURL(url)) + elif content_type.startswith("video") or ext in video_exts: + msg.append(Video.fromURL(url)) + elif content_type.startswith("image") or ext in image_exts: + msg.append(Image.fromURL(url)) + else: + msg.append(File(name=filename, file=url, url=url)) @staticmethod def _parse_from_qqofficial( 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 6aae6b9ce..4c73fdf38 100644 --- a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -1,7 +1,5 @@ import asyncio import logging -import random -from types import SimpleNamespace from typing import Any, cast import botpy @@ -15,7 +13,6 @@ 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 @@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform): session: MessageSesion, message_chain: MessageChain, ) -> None: - ( - 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) + await QQOfficialPlatformAdapter._send_by_session_common( + cast(Any, 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/docs/en/platform/qqofficial/webhook.md b/docs/en/platform/qqofficial/webhook.md index 8ed67fdc1..ebd136ee4 100644 --- a/docs/en/platform/qqofficial/webhook.md +++ b/docs/en/platform/qqofficial/webhook.md @@ -7,17 +7,17 @@ ## Supported Basic Message Types -> Version v4.15.0. +> Version v4.19.6. | Message Type | Receive | Send | Notes | | --- | --- | --- | --- | | Text | Yes | Yes | | | Image | Yes | Yes | | -| Voice | No | No | | -| Video | No | No | | -| File | No | No | | +| Voice | Yes | Yes | | +| Video | Yes | Yes | | +| File | Yes | Yes | | -Proactive message push: Not supported. +Proactive message push: Supported. ## Apply for a Bot diff --git a/docs/en/platform/qqofficial/websockets.md b/docs/en/platform/qqofficial/websockets.md index 0188dcef4..9b64576c3 100644 --- a/docs/en/platform/qqofficial/websockets.md +++ b/docs/en/platform/qqofficial/websockets.md @@ -2,17 +2,17 @@ ## Supported Basic Message Types -> Version v4.15.0. +> Version v4.19.6. | Message Type | Receive | Send | Notes | | --- | --- | --- | --- | | Text | Yes | Yes | | | Image | Yes | Yes | | -| Voice | No | No | | -| Video | No | No | | -| File | No | No | | +| Voice | Yes | Yes | | +| Video | Yes | Yes | | +| File | Yes | Yes | | -Proactive message push: Not supported. +Proactive message push: Supported. ## Quick Deployment Steps diff --git a/docs/zh/platform/qqofficial/webhook.md b/docs/zh/platform/qqofficial/webhook.md index c7b241e0d..b7c5f4f44 100644 --- a/docs/zh/platform/qqofficial/webhook.md +++ b/docs/zh/platform/qqofficial/webhook.md @@ -10,17 +10,17 @@ ## 支持的基本消息类型 -> 版本 v4.15.0。 +> 版本 v4.19.6。 | 消息类型 | 是否支持接收 | 是否支持发送 | 备注 | | --- | --- | --- | --- | | 文本 | 是 | 是 | | | 图片 | 是 | 是 | | -| 语音 | 否 | 否 | | -| 视频 | 否 | 否 | | -| 文件 | 否 | 否 | | +| 语音 | 是 | 是 | | +| 视频 | 是 | 是 | | +| 文件 | 是 | 是 | | -主动消息推送:不支持。 +主动消息推送:支持。 ## 申请一个机器人 diff --git a/docs/zh/platform/qqofficial/websockets.md b/docs/zh/platform/qqofficial/websockets.md index 1deb6a363..2e3de73a9 100644 --- a/docs/zh/platform/qqofficial/websockets.md +++ b/docs/zh/platform/qqofficial/websockets.md @@ -3,17 +3,17 @@ ## 支持的基本消息类型 -> 版本 v4.15.0。 +> 版本 v4.19.6。 | 消息类型 | 是否支持接收 | 是否支持发送 | 备注 | | --- | --- | --- | --- | | 文本 | 是 | 是 | | | 图片 | 是 | 是 | | -| 语音 | 否 | 否 | | -| 视频 | 否 | 否 | | -| 文件 | 否 | 否 | | +| 语音 | 是 | 是 | | +| 视频 | 是 | 是 | | +| 文件 | 是 | 是 | | -主动消息推送:不支持。 +主动消息推送:支持。 ## 快速部署通道