feat: added support for file, voice, and video messages for QQ Official Bot (including WebSocket mode). (#6063)

This commit is contained in:
Soulter
2026-03-12 00:26:08 +08:00
committed by GitHub
parent 589cce18af
commit 5c3643c54c
7 changed files with 344 additions and 135 deletions
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain 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.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.io import download_image_by_url, file_to_base64
@@ -47,6 +47,10 @@ _patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent): class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
IMAGE_FILE_TYPE = 1
VIDEO_FILE_TYPE = 2
VOICE_FILE_TYPE = 3
FILE_FILE_TYPE = 4
def __init__( def __init__(
self, self,
@@ -126,6 +130,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64, image_base64,
image_path, image_path,
record_file_path, record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) ) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
if ( if (
@@ -133,6 +140,8 @@ class QQOfficialMessageEvent(AstrMessageEvent):
and not image_base64 and not image_base64
and not image_path and not image_path
and not record_file_path and not record_file_path
and not video_file_source
and not file_source
): ):
return None return None
@@ -157,7 +166,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64: if image_base64:
media = await self.upload_group_and_c2c_image( media = await self.upload_group_and_c2c_image(
image_base64, image_base64,
1, self.IMAGE_FILE_TYPE,
group_openid=source.group_openid, group_openid=source.group_openid,
) )
payload["media"] = media payload["media"] = media
@@ -165,15 +174,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None) payload.pop("markdown", None)
payload["content"] = plain_text or None payload["content"] = plain_text or None
if record_file_path: # group record msg 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, record_file_path,
3, self.VOICE_FILE_TYPE,
group_openid=source.group_openid, group_openid=source.group_openid,
) )
payload["media"] = media if media:
payload["msg_type"] = 7 payload["media"] = media
payload.pop("markdown", None) payload["msg_type"] = 7
payload["content"] = plain_text or None 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( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message( send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore group_openid=source.group_openid, # type: ignore
@@ -187,7 +220,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64: if image_base64:
media = await self.upload_group_and_c2c_image( media = await self.upload_group_and_c2c_image(
image_base64, image_base64,
1, self.IMAGE_FILE_TYPE,
openid=source.author.user_openid, openid=source.author.user_openid,
) )
payload["media"] = media payload["media"] = media
@@ -195,15 +228,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None) payload.pop("markdown", None)
payload["content"] = plain_text or None payload["content"] = plain_text or None
if record_file_path: # c2c record 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, record_file_path,
3, self.VOICE_FILE_TYPE,
openid=source.author.user_openid, openid=source.author.user_openid,
) )
payload["media"] = media if media:
payload["msg_type"] = 7 payload["media"] = media
payload.pop("markdown", None) payload["msg_type"] = 7
payload["content"] = plain_text or None 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: if stream:
ret = await self._send_with_markdown_fallback( ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message( send_func=lambda retry_payload: self.post_c2c_message(
@@ -327,16 +384,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ttl=result.get("ttl", 0), ttl=result.get("ttl", 0),
) )
async def upload_group_and_c2c_record( async def upload_group_and_c2c_media(
self, self,
file_source: str, file_source: str,
file_type: int, file_type: int,
srv_send_msg: bool = False, srv_send_msg: bool = False,
file_name: str | None = None,
**kwargs, **kwargs,
) -> Media | None: ) -> Media | None:
"""上传媒体文件""" """上传媒体文件"""
# 构建基础payload # 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg} 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): if os.path.exists(file_source):
@@ -416,6 +476,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = None # only one img supported image_base64 = None # only one img supported
image_file_path = None image_file_path = None
record_file_path = None record_file_path = None
video_file_source = None
file_source = None
file_name = None
for i in message.chain: for i in message.chain:
if isinstance(i, Plain): if isinstance(i, Plain):
plain_text += i.text plain_text += i.text
@@ -454,6 +517,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e: except Exception as e:
logger.error(f"处理语音时出错: {e}") logger.error(f"处理语音时出错: {e}")
record_file_path = None 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: else:
logger.debug(f"qq_official 忽略 {i.type}") 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,
)
@@ -3,8 +3,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os import os
import random
import time import time
from typing import cast from types import SimpleNamespace
from typing import Any, cast
import botpy import botpy
import botpy.message import botpy.message
@@ -12,7 +14,7 @@ from botpy import Client
from astrbot import logger from astrbot import logger
from astrbot.api.event import MessageChain 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 ( from astrbot.api.platform import (
AstrBotMessage, AstrBotMessage,
MessageMember, MessageMember,
@@ -46,6 +48,7 @@ class botClient(Client):
) )
abm.group_id = cast(str, message.group_openid) abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm) self._commit(abm)
# 收到频道消息 # 收到频道消息
@@ -56,6 +59,7 @@ class botClient(Client):
) )
abm.group_id = message.channel_id abm.group_id = message.channel_id
abm.session_id = abm.group_id abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm) self._commit(abm)
# 收到私聊消息 # 收到私聊消息
@@ -67,6 +71,7 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE, MessageType.FRIEND_MESSAGE,
) )
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm) self._commit(abm)
# 收到 C2C 消息 # 收到 C2C 消息
@@ -76,9 +81,11 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE, MessageType.FRIEND_MESSAGE,
) )
abm.session_id = abm.sender.user_id abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm) self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None: def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event( self.platform.commit_event(
QQOfficialMessageEvent( QQOfficialMessageEvent(
abm.message_str, abm.message_str,
@@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self) 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" self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def send_by_session( async def send_by_session(
@@ -131,14 +141,185 @@ class QQOfficialPlatformAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
) -> None: ) -> 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: def meta(self) -> PlatformMetadata:
return PlatformMetadata( return PlatformMetadata(
name="qq_official", name="qq_official",
description="QQ 机器人官方 API 适配器", description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")), id=cast(str, self.config.get("id")),
support_proactive_message=False, support_proactive_message=True,
) )
@staticmethod @staticmethod
@@ -158,7 +339,10 @@ class QQOfficialPlatformAdapter(Platform):
return return
for attachment in attachments: 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( url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None)) cast(str | None, getattr(attachment, "url", None))
) )
@@ -174,7 +358,32 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None) or getattr(attachment, "name", None)
or "attachment", 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 @staticmethod
def _parse_from_qqofficial( def _parse_from_qqofficial(
@@ -1,7 +1,5 @@
import asyncio import asyncio
import logging import logging
import random
from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
import botpy 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 astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter from ...register import register_platform_adapter
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook from .qo_webhook_server import QQOfficialWebhook
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
session: MessageSesion, session: MessageSesion,
message_chain: MessageChain, message_chain: MessageChain,
) -> None: ) -> None:
( await QQOfficialPlatformAdapter._send_by_session_common(
plain_text, cast(Any, self),
image_base64, session,
image_path, message_chain,
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: def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id: if not session_id or not message_id:
+5 -5
View File
@@ -7,17 +7,17 @@
## Supported Basic Message Types ## Supported Basic Message Types
> Version v4.15.0. > Version v4.19.6.
| Message Type | Receive | Send | Notes | | Message Type | Receive | Send | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Text | Yes | Yes | | | Text | Yes | Yes | |
| Image | Yes | Yes | | | Image | Yes | Yes | |
| Voice | No | No | | | Voice | Yes | Yes | |
| Video | No | No | | | Video | Yes | Yes | |
| File | No | No | | | File | Yes | Yes | |
Proactive message push: Not supported. Proactive message push: Supported.
## Apply for a Bot ## Apply for a Bot
+5 -5
View File
@@ -2,17 +2,17 @@
## Supported Basic Message Types ## Supported Basic Message Types
> Version v4.15.0. > Version v4.19.6.
| Message Type | Receive | Send | Notes | | Message Type | Receive | Send | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Text | Yes | Yes | | | Text | Yes | Yes | |
| Image | Yes | Yes | | | Image | Yes | Yes | |
| Voice | No | No | | | Voice | Yes | Yes | |
| Video | No | No | | | Video | Yes | Yes | |
| File | No | No | | | File | Yes | Yes | |
Proactive message push: Not supported. Proactive message push: Supported.
## Quick Deployment Steps ## Quick Deployment Steps
+5 -5
View File
@@ -10,17 +10,17 @@
## 支持的基本消息类型 ## 支持的基本消息类型
> 版本 v4.15.0 > 版本 v4.19.6
| 消息类型 | 是否支持接收 | 是否支持发送 | 备注 | | 消息类型 | 是否支持接收 | 是否支持发送 | 备注 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 文本 | 是 | 是 | | | 文本 | 是 | 是 | |
| 图片 | 是 | 是 | | | 图片 | 是 | 是 | |
| 语音 | | | | | 语音 | | | |
| 视频 | | | | | 视频 | | | |
| 文件 | | | | | 文件 | | | |
主动消息推送:支持。 主动消息推送:支持。
## 申请一个机器人 ## 申请一个机器人
+5 -5
View File
@@ -3,17 +3,17 @@
## 支持的基本消息类型 ## 支持的基本消息类型
> 版本 v4.15.0 > 版本 v4.19.6
| 消息类型 | 是否支持接收 | 是否支持发送 | 备注 | | 消息类型 | 是否支持接收 | 是否支持发送 | 备注 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 文本 | 是 | 是 | | | 文本 | 是 | 是 | |
| 图片 | 是 | 是 | | | 图片 | 是 | 是 | |
| 语音 | | | | | 语音 | | | |
| 视频 | | | | | 视频 | | | |
| 文件 | | | | | 文件 | | | |
主动消息推送:支持。 主动消息推送:支持。
## 快速部署通道 ## 快速部署通道