fix(qqofficial): respect group mention rendering capability

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Chin Rock
2026-10-28 10:27:28 +00:00
co-authored by multica-agent
parent ca36046f0d
commit a620c36557
7 changed files with 102 additions and 12 deletions
@@ -82,6 +82,12 @@ As shown in the image:
For example, if the domain is `testlb.com`, then enter `testlb.com/bots/xxxxxx-xxxxxx` in the request address, and click to confirm the configuration. If the callback address is saved successfully, it means the deployment is successful. If you see `Verification Failed`, please carefully check if the above configuration items are filled in correctly.
## Group replies and At components
QQ Official Omni disables `Enable Markdown Rendering` by default. Plain-text group replies omit At components rather than exposing literal markup, while preserving the original reply `msg_id`. This does not guarantee a mention notification in QQ.
For explicit group mentions, obtain Markdown permission for your QQ bot and enable this option. Markdown uses the member's original OpenID, not their display name or QQ number. Verify rendering in the actual QQ client; LangBot does not automatically switch to a potentially unauthorized Markdown mode. Although the official text-interaction documentation lists plain-text support, [adapter testing](https://github.com/nonebot/adapter-qq/issues/220) and user acceptance have shown literal tags in plain-text group replies.
## Publication Method
Enter the release process in `Home` and follow the process to publish and launch the bot.
@@ -82,6 +82,12 @@ LangBot 設定ページに移動し、対応する QQ ボットを有効にし
たとえば、ドメインが `testlb.com` の場合、リクエストアドレスに `testlb.com/bots/xxxxxx-xxxxxx` を入力し、設定の確認をクリックします。コールバックアドレスが正常に保存された場合、デプロイは成功です。「検証失敗」が表示された場合は、上記の設定項目が正しく入力されているかどうかを注意深く確認してください。
## グループ返信と At コンポーネント
QQ Official Omni の `Enable Markdown Rendering` は初期状態で無効です。グループのプレーンテキスト返信では、タグがそのまま表示されないよう At コンポーネントを省略し、元のメッセージの `msg_id` を保持します。QQ クライアントでのメンション通知を保証するものではありません。
明示的なメンションには、QQ ボットの Markdown 権限を確認してからこの設定を有効にしてください。表示名や QQ 番号ではなく、メンバーの元の OpenID を使用します。実際の QQ クライアントで表示を確認してください。LangBot が自動的に Markdown に切り替えることはありません。公式文書にはプレーンテキスト対応の記載がありますが、[アダプターでの検証](https://github.com/nonebot/adapter-qq/issues/220)とユーザーの確認ではタグがそのまま表示されています。
## 公開方法
「ホーム」のリリースプロセスに入り、プロセスに従ってボットを公開して起動します。
@@ -82,6 +82,14 @@ description: "通过 QQ 官方机器人 API(Webhook)接入 LangBot,在 QQ
比如域名为 `testlb.com`,那么请求地址中填写 `testlb.com/bots/xxxxxx-xxxxxx` ,点击确定配置。如果回调地址保存成功,那么意味着部署成功。若出现 `校验失败` 字样,请认真检查上述配置项是否填写正确。
## 群聊回复与 At 组件
在 QQ Official Omni 的机器人配置中,`启用 Markdown 渲染` 默认关闭。普通文本群聊回复不会输出 At 组件的原始标签;At 与正文仍合并处理,回复保留原消息的 `msg_id`。这不保证 QQ 客户端产生 @ 提醒。
需要显式 @ 群成员时,请先确认机器人已获得 QQ 的 Markdown 消息权限,再开启该选项。Markdown 回复使用发送者的原始 OpenID 生成原生 At 标签;显示名或 QQ 号码不能代替 OpenID。部分客户端对标签的解析存在差异,需要在实际 QQ 客户端验收。LangBot 不会为实现 At 自动切换到可能无权限的 Markdown 模式。
QQ 的[文本交互文档](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/text-chain.html)声明普通文本也支持 At,但已有[适配器实测反馈](https://github.com/nonebot/adapter-qq/issues/220)及用户验收发现普通文本会原样显示标签。因此 LangBot 对普通文本群聊采用上述兼容处理。
## 发布方式
进入 `首页` 的发布流程 ,按照流程进行机器人发布,上线。
@@ -141,7 +141,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
raw = await self._send_content_list(
str(target_type),
str(target_id),
await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(str(target_type))),
await QQOfficialMessageConverter.yiri2target(
message,
self._normalize_target_type(str(target_type)),
use_markdown=self.config.get('enable-markdown-rendering', False),
),
)
return platform_events.MessageResult(raw={'results': raw})
@@ -158,7 +162,11 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
raw = await self._send_content_list(
target_type,
target_id,
await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(target_type)),
await QQOfficialMessageConverter.yiri2target(
message,
self._normalize_target_type(target_type),
use_markdown=self.config.get('enable-markdown-rendering', False),
),
msg_id=source.d_id,
)
return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw})
@@ -52,9 +52,10 @@ spec:
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。頻道訊息一律以純文字傳送,不受此設定影響。
affected by this setting. Group At components require Markdown; plain group replies omit them to avoid literal tags.
Enable only if your QQ bot has Markdown permission.
zh_Hans: 将非流式 C2C 私聊和 QQ 群聊文本回复渲染为 Markdown。群聊 At 组件需使用 Markdown;纯文本群聊回复会省略 At,避免显示原始标签。请确认机器人有 Markdown 权限后开启。频道消息始终以纯文本发送,不受此设置影响。
zh_Hant: 將非串流 C2C 私聊與 QQ 群聊文字回覆渲染為 Markdown。群聊 At 元件需使用 Markdown;純文字群聊回覆會省略 At,避免顯示原始標籤。請確認機器人有 Markdown 權限後開啟。頻道訊息一律以純文字傳送,不受此設定影響。
type: boolean
required: true
default: false
@@ -22,7 +22,12 @@ def _is_base64_data(value: str) -> bool:
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, target_type: str | None = None) -> list[dict]:
async def yiri2target(
message_chain: platform_message.MessageChain,
target_type: str | None = None,
*,
use_markdown: bool = False,
) -> list[dict]:
content_list: list[dict] = []
text_parts: list[str] = []
@@ -39,6 +44,8 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
text_parts.append(component.text)
continue
elif isinstance(component, platform_message.At):
if target_type == 'group' and not use_markdown:
continue
if target_type in {None, 'group', 'channel'}:
target = html.escape(str(component.target), quote=True)
text_parts.append(f'<qqbot-at-user id="{target}" />')
@@ -62,12 +69,18 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
if component.id is not None:
content_list.append({'type': 'text', 'content': f'[Quote {component.id}]'})
if component.origin:
content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin, target_type))
content_list.extend(
await QQOfficialMessageConverter.yiri2target(
component.origin, target_type, use_markdown=use_markdown
)
)
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
if node.message_chain:
content_list.extend(
await QQOfficialMessageConverter.yiri2target(node.message_chain, target_type)
await QQOfficialMessageConverter.yiri2target(
node.message_chain, target_type, use_markdown=use_markdown
)
)
else:
text = str(component)
@@ -72,6 +72,8 @@ async def test_segmented_replies_share_sequences(captured_client, adapter_class,
texts = [body['markdown']['content'] if markdown else body['content'] for body in payloads]
if adapter_class is QQOfficialAdapter:
mention = '@user-one' if event_type == 'C2C_MESSAGE_CREATE' else '<qqbot-at-user id="user-one" />'
if event_type != 'C2C_MESSAGE_CREATE' and not markdown:
mention = ''
assert texts == [f'{mention}firstsecond', 'follow-up']
else:
assert texts == ['first', 'second', 'follow-up']
@@ -80,7 +82,7 @@ async def test_segmented_replies_share_sequences(captured_client, adapter_class,
@pytest.mark.asyncio
@pytest.mark.parametrize('markdown', [False, True])
@pytest.mark.parametrize('event_type', ['GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE'])
async def test_group_reply_mentions_original_sender_in_one_message(captured_client, markdown, event_type):
async def test_group_reply_mentions_follow_render_mode_in_one_message(captured_client, markdown, event_type):
client, requests = captured_client
adapter = QQOfficialAdapter.model_construct(
bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock()
@@ -114,7 +116,8 @@ async def test_group_reply_mentions_original_sender_in_one_message(captured_clie
assert payload['msg_id'] == 'source-one'
assert payload['msg_seq'] == 1
content = payload['markdown']['content'] if markdown else payload['content']
assert content == '<qqbot-at-user id="sender-openid" /> hello!'
assert content == ('<qqbot-at-user id="sender-openid" /> hello!' if markdown else ' hello!')
assert payload['msg_type'] == (2 if markdown else 0)
@pytest.mark.asyncio
@@ -141,14 +144,59 @@ async def test_merged_text_preserves_media_order_and_sequences(captured_client):
payloads = [body for path, body in requests if path.endswith('/messages')]
assert [body['msg_seq'] for body in payloads] == [1, 2, 3]
assert [body['msg_type'] for body in payloads] == [0, 7, 0]
assert payloads[0]['content'] == '<qqbot-at-user id="sender" /> image:'
assert payloads[0]['content'] == ' image:'
assert payloads[2]['content'] == 'after image'
@pytest.mark.asyncio
@pytest.mark.parametrize('markdown', [False, True])
async def test_group_send_mentions_follow_render_mode(captured_client, markdown):
client, requests = captured_client
adapter = QQOfficialAdapter.model_construct(
bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock()
)
await adapter.send_message(
'group',
'group-one',
message.MessageChain([message.At(target='sender'), message.Plain(text='hello')]),
)
assert len(requests) == 1
payload = requests[0][1]
assert payload['msg_type'] == (2 if markdown else 0)
content = payload['markdown']['content'] if markdown else payload['content']
assert content == ('<qqbot-at-user id="sender" />hello' if markdown else 'hello')
@pytest.mark.asyncio
async def test_plain_group_mention_only_does_not_send_empty_message(captured_client):
client, requests = captured_client
adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock())
await adapter.send_message('group', 'group-one', message.MessageChain([message.At(target='sender')]))
assert requests == []
@pytest.mark.asyncio
@pytest.mark.parametrize('markdown', [False, True])
@pytest.mark.parametrize('wrapper', ['quote', 'forward'])
async def test_nested_mentions_preserve_render_mode(markdown, wrapper):
nested = message.MessageChain([message.At(target='sender'), message.Plain(text='hello')])
component = (
message.Quote(origin=nested)
if wrapper == 'quote'
else message.Forward(node_list=[message.ForwardMessageNode(message_chain=nested)])
)
payloads = await QQOfficialMessageConverter.yiri2target(
message.MessageChain([component]), 'group', use_markdown=markdown
)
assert payloads == [{'type': 'text', 'content': '<qqbot-at-user id="sender" />hello' if markdown else 'hello'}]
@pytest.mark.asyncio
async def test_mentions_escape_ids_without_using_display_names():
payloads = await QQOfficialMessageConverter.yiri2target(
message.MessageChain([message.At(target='bad"/><qqbot-at-everyone', display='Alice')]), 'group'
message.MessageChain([message.At(target='bad"/><qqbot-at-everyone', display='Alice')]),
'group',
use_markdown=True,
)
assert payloads == [{'type': 'text', 'content': '<qqbot-at-user id="bad&quot;/&gt;&lt;qqbot-at-everyone" />'}]