mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-24 11:07:14 +00:00
feat(qqofficial): add markdown reply rendering (#2459)
This commit is contained in:
@@ -422,6 +422,69 @@ class QQOfficialClient:
|
|||||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||||
raise ValueError(response)
|
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(
|
async def send_group_text_msg(
|
||||||
self,
|
self,
|
||||||
group_openid: str,
|
group_openid: str,
|
||||||
|
|||||||
@@ -329,17 +329,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
content_type = content.get('type', 'text')
|
content_type = content.get('type', 'text')
|
||||||
|
|
||||||
if content_type == 'text':
|
if content_type == 'text':
|
||||||
if target_type == 'c2c':
|
if target_type in {'c2c', 'group'}:
|
||||||
await self.bot.send_private_text_msg(
|
await self._send_c2c_or_group_text_reply(
|
||||||
|
target_type,
|
||||||
target_id,
|
target_id,
|
||||||
content['content'],
|
content['content'],
|
||||||
qq_official_event.d_id,
|
msg_id=qq_official_event.d_id,
|
||||||
)
|
|
||||||
elif target_type == 'group':
|
|
||||||
await self.bot.send_group_text_msg(
|
|
||||||
target_id,
|
|
||||||
content['content'],
|
|
||||||
qq_official_event.d_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
elif content_type == 'image':
|
elif content_type == 'image':
|
||||||
@@ -383,6 +378,39 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
pass
|
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(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
event_type: typing.Type[platform_events.Event],
|
event_type: typing.Type[platform_events.Event],
|
||||||
@@ -778,20 +806,13 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_type == 'c2c':
|
await self._send_c2c_or_group_text_reply(
|
||||||
await self.bot.send_private_text_msg(
|
target_type,
|
||||||
user_openid=target_id,
|
target_id,
|
||||||
content=text,
|
text,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
msg_seq=msg_seq,
|
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,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
await self.logger.error(f'QQ Official: synthetic reply delivery failed: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ spec:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
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
|
- name: webhook_url
|
||||||
label:
|
label:
|
||||||
en_US: Webhook Callback URL
|
en_US: Webhook Callback URL
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Tests for QQ Official keyboard payload helpers."""
|
"""Tests for QQ Official message and keyboard payload helpers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
@@ -99,6 +101,12 @@ def _stream_test_adapter():
|
|||||||
adapter.bot = MagicMock()
|
adapter.bot = MagicMock()
|
||||||
adapter.bot.send_stream_msg = AsyncMock(return_value={'id': 'stream-1'})
|
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_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.ap = None
|
||||||
adapter._stream_ctx = {}
|
adapter._stream_ctx = {}
|
||||||
adapter._stream_ctx_ts = {}
|
adapter._stream_ctx_ts = {}
|
||||||
@@ -142,6 +150,105 @@ async def test_qq_stream_replace_mode_sends_complete_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
|
@pytest.mark.asyncio
|
||||||
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
async def test_qq_non_streaming_fallback_keeps_latest_snapshot_only():
|
||||||
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter
|
||||||
|
|||||||
Reference in New Issue
Block a user