diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index dfca4378cf..d9106c897c 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -422,6 +422,69 @@ async def send_private_text_msg( await self.logger.error(f'Failed to send private message: {response_data}') raise ValueError(response) + async def _send_markdown_msg( + self, + target_type: str, + target_id: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ) -> None: + """Send a Markdown message to a C2C user or QQ group.""" + if not await self.check_access_token(): + await self.get_access_token() + + if target_type == 'c2c': + url = f'{self.base_url}/v2/users/{target_id}/messages' + elif target_type == 'group': + url = f'{self.base_url}/v2/groups/{target_id}/messages' + else: + raise ValueError(f'Unsupported Markdown target type: {target_type}') + + data: dict[str, Any] = { + 'msg_type': 2, + 'markdown': {'content': content}, + 'msg_seq': msg_seq, + } + if msg_id: + data['msg_id'] = msg_id + if event_id: + data['event_id'] = event_id + + async with self._http_client_context() as client: + headers = { + 'Authorization': f'QQBot {self.access_token}', + 'Content-Type': 'application/json', + } + response = await client.post(url, headers=headers, json=data) + if response.status_code != 200: + response_data = await httpclient.parse_json_response(response) + await self.logger.error(f'Failed to send Markdown message: {response_data}') + raise ValueError(response) + + async def send_private_markdown_msg( + self, + user_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ) -> None: + """Send a Markdown C2C message.""" + await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq) + + async def send_group_markdown_msg( + self, + group_openid: str, + content: str, + msg_id: Optional[str] = None, + event_id: Optional[str] = None, + msg_seq: int = 1, + ) -> None: + """Send a Markdown QQ group message.""" + await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq) + async def send_group_text_msg( self, group_openid: str, diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index 598061f6ca..f78be3a790 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -329,17 +329,12 @@ async def reply_message( content_type = content.get('type', 'text') if content_type == 'text': - if target_type == 'c2c': - await self.bot.send_private_text_msg( - target_id, - content['content'], - qq_official_event.d_id, - ) - elif target_type == 'group': - await self.bot.send_group_text_msg( + if target_type in {'c2c', 'group'}: + await self._send_c2c_or_group_text_reply( + target_type, target_id, content['content'], - qq_official_event.d_id, + msg_id=qq_official_event.d_id, ) elif content_type == 'image': @@ -383,6 +378,39 @@ async def reply_message( async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): pass + async def _send_c2c_or_group_text_reply( + self, + target_type: str, + target_id: str, + content: str, + *, + msg_id: typing.Optional[str] = None, + event_id: typing.Optional[str] = None, + msg_seq: int = 1, + ) -> None: + """Send a text reply using the configured C2C/group render mode.""" + use_markdown = self.config.get('enable-markdown-rendering', False) + if target_type == 'c2c': + send = self.bot.send_private_markdown_msg if use_markdown else self.bot.send_private_text_msg + await send( + user_openid=target_id, + content=content, + msg_id=msg_id, + event_id=event_id, + msg_seq=msg_seq, + ) + elif target_type == 'group': + send = self.bot.send_group_markdown_msg if use_markdown else self.bot.send_group_text_msg + await send( + group_openid=target_id, + content=content, + msg_id=msg_id, + event_id=event_id, + msg_seq=msg_seq, + ) + else: + raise ValueError(f'Unsupported QQ Official text reply target: {target_type}') + def register_listener( self, event_type: typing.Type[platform_events.Event], @@ -778,20 +806,13 @@ async def _reply_synthetic( return try: - if target_type == 'c2c': - await self.bot.send_private_text_msg( - user_openid=target_id, - content=text, - event_id=event_id, - msg_seq=msg_seq, - ) - elif target_type == 'group': - await self.bot.send_group_text_msg( - group_openid=target_id, - content=text, - event_id=event_id, - msg_seq=msg_seq, - ) + await self._send_c2c_or_group_text_reply( + target_type, + target_id, + text, + event_id=event_id, + msg_seq=msg_seq, + ) except Exception: await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}') diff --git a/src/langbot/pkg/platform/sources/qqofficial.yaml b/src/langbot/pkg/platform/sources/qqofficial.yaml index c3f193be90..41d326b83c 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.yaml +++ b/src/langbot/pkg/platform/sources/qqofficial.yaml @@ -95,6 +95,18 @@ spec: type: boolean required: true default: false + - name: enable-markdown-rendering + label: + en_US: Enable Markdown Rendering + zh_Hans: 启用 Markdown 渲染 + zh_Hant: 啟用 Markdown 渲染 + description: + en_US: Render non-stream C2C and QQ group text replies as Markdown. Channel messages always use plain text and are not affected by this setting. + zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。频道消息始终以纯文本发送,不受此设置影响。 + zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。頻道訊息一律以純文字傳送,不受此設定影響。 + type: boolean + required: true + default: false - name: webhook_url label: en_US: Webhook Callback URL diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py index 704dd142c8..352caae3a9 100644 --- a/tests/unit_tests/platform/test_qqofficial_api.py +++ b/tests/unit_tests/platform/test_qqofficial_api.py @@ -1,9 +1,11 @@ -"""Tests for QQ Official keyboard payload helpers.""" +"""Tests for QQ Official message and keyboard payload helpers.""" import asyncio +import json import time from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest import langbot_plugin.api.entities.builtin.platform.message as platform_message @@ -99,6 +101,12 @@ def _stream_test_adapter(): adapter.bot = MagicMock() adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'}) adapter.bot.send_markdown_keyboard = AsyncMock(return_value={'id': 'message-1'}) + adapter.bot.send_private_text_msg = AsyncMock() + adapter.bot.send_group_text_msg = AsyncMock() + adapter.bot.send_private_markdown_msg = AsyncMock() + adapter.bot.send_group_markdown_msg = AsyncMock() + adapter.bot.send_channle_group_text_msg = AsyncMock() + adapter.bot.send_channle_private_text_msg = AsyncMock() adapter.ap = None adapter._stream_ctx = {} adapter._stream_ctx_ts = {} @@ -142,6 +150,105 @@ async def test_qq_stream_uses_cumulative_chunks_as_snapshots(): ] +@pytest.mark.asyncio +async def test_qq_markdown_messages_use_markdown_payloads(): + requests = [] + + def capture_request(request: httpx.Request) -> httpx.Response: + requests.append((str(request.url), json.loads(request.content))) + return httpx.Response(200, json={}) + + client = QQOfficialClient('secret', 'token', 'app-id', AsyncMock()) + client.access_token = 'access-token' + client.access_token_expiry_time = time.time() + 3600 + client._http_clients[None] = httpx.AsyncClient(transport=httpx.MockTransport(capture_request)) + + try: + await client.send_private_markdown_msg('user-1', '# Hello', msg_id='message-1', msg_seq=2) + await client.send_group_markdown_msg('group-1', '* Hello', event_id='event-1', msg_seq=3) + finally: + await client.close() + + assert requests == [ + ( + 'https://api.sgroup.qq.com/v2/users/user-1/messages', + {'msg_type': 2, 'markdown': {'content': '# Hello'}, 'msg_seq': 2, 'msg_id': 'message-1'}, + ), + ( + 'https://api.sgroup.qq.com/v2/groups/group-1/messages', + {'msg_type': 2, 'markdown': {'content': '* Hello'}, 'msg_seq': 3, 'event_id': 'event-1'}, + ), + ] + + +@pytest.mark.asyncio +async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies(): + adapter = _stream_test_adapter() + adapter.config = {'enable-markdown-rendering': True} + + await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', '# Hello', msg_id='message-1') + await adapter._send_c2c_or_group_text_reply('group', 'group-1', '* Hello', event_id='event-1') + + adapter.bot.send_private_markdown_msg.assert_awaited_once_with( + user_openid='user-1', + content='# Hello', + msg_id='message-1', + event_id=None, + msg_seq=1, + ) + adapter.bot.send_group_markdown_msg.assert_awaited_once_with( + group_openid='group-1', + content='* Hello', + msg_id=None, + event_id='event-1', + msg_seq=1, + ) + adapter.bot.send_private_text_msg.assert_not_awaited() + adapter.bot.send_group_text_msg.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_qq_markdown_rendering_defaults_to_plain_text_replies(): + adapter = _stream_test_adapter() + adapter.config = {} + + await adapter._send_c2c_or_group_text_reply('c2c', 'user-1', 'Hello') + await adapter._send_c2c_or_group_text_reply('group', 'group-1', 'Hello') + + adapter.bot.send_private_text_msg.assert_awaited_once() + adapter.bot.send_group_text_msg.assert_awaited_once() + adapter.bot.send_private_markdown_msg.assert_not_awaited() + adapter.bot.send_group_markdown_msg.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_qq_markdown_rendering_does_not_affect_channel_messages(): + adapter = _stream_test_adapter() + adapter.config = {'enable-markdown-rendering': True} + message = platform_message.MessageChain([platform_message.Plain(text='# Hello')]) + + channel_source = MagicMock() + channel_source.t = 'AT_MESSAGE_CREATE' + channel_source.channel_id = 'channel-1' + channel_source.d_id = 'message-1' + channel_event = MagicMock() + channel_event.source_platform_object = channel_source + await adapter.reply_message(channel_event, message) + + dm_source = MagicMock() + dm_source.t = 'DIRECT_MESSAGE_CREATE' + dm_source.guild_id = 'guild-1' + dm_source.d_id = 'message-2' + dm_event = MagicMock() + dm_event.source_platform_object = dm_source + await adapter.reply_message(dm_event, message) + + adapter.bot.send_channle_group_text_msg.assert_awaited_once_with('channel-1', '# Hello', 'message-1') + adapter.bot.send_channle_private_text_msg.assert_awaited_once_with('guild-1', '# Hello', 'message-2') + adapter.bot.send_private_markdown_msg.assert_not_awaited() + adapter.bot.send_group_markdown_msg.assert_not_awaited() + + @pytest.mark.asyncio async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only(): from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter