From ca36046f0db8da671490e3f8ae988aeeb4cecac7 Mon Sep 17 00:00:00 2001 From: Chin Rock Date: Wed, 28 Oct 2026 10:11:53 +0000 Subject: [PATCH] fix(qqofficial): preserve native mentions and reply sequences (LANG-181) Co-authored-by: multica-agent --- plugins/Runner/LocalAgent/README.md | 4 + plugins/Runner/LocalAgent/manifest.yaml | 2 +- plugins/Runner/LocalAgent/pyproject.toml | 4 +- plugins/Runner/LocalAgent/requirements.txt | 2 +- .../tests/test_sdk_dependency_contract.py | 39 +++ plugins/Runner/LocalAgent/uv.lock | 4 +- plugins/catalog.json | 2 +- src/langbot/libs/qq_official_api/api.py | 36 +-- .../platform/adapters/qqofficial/adapter.py | 8 +- .../adapters/qqofficial/message_converter.py | 35 ++- .../pkg/platform/sources/qqofficial.py | 2 +- .../legacy_plugin_identity_sources.json | 8 +- .../platform/test_qqofficial_api.py | 4 +- .../platform/test_qqofficial_eba_adapter.py | 4 +- .../test_qqofficial_message_sequences.py | 223 ++++++++++++++++++ 15 files changed, 335 insertions(+), 42 deletions(-) create mode 100644 plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py create mode 100644 tests/unit_tests/platform/test_qqofficial_message_sequences.py diff --git a/plugins/Runner/LocalAgent/README.md b/plugins/Runner/LocalAgent/README.md index 4214ad578..fae574ec5 100644 --- a/plugins/Runner/LocalAgent/README.md +++ b/plugins/Runner/LocalAgent/README.md @@ -6,6 +6,10 @@ Local Agent 是 LangBot 官方的进程内运行器。它在 LangBot 插件进 `plugin:langbot-team/LocalAgent/default` +## SDK 兼容性 + +LocalAgent 0.2.3 使用 `langbot-plugin>=0.7.18,<0.8.0`,允许同一 0.7 系列的稳定补丁更新,不再精确锁定单个 SDK 版本。测试环境的 `uv.lock` 仍固定已验证版本以保证可复现;插件包的 `requirements.txt` 与项目声明使用相同的兼容范围。跨 0.8 系列升级需要单独验证。 + ## 主要能力 - 支持主模型与 fallback 模型切换。 diff --git a/plugins/Runner/LocalAgent/manifest.yaml b/plugins/Runner/LocalAgent/manifest.yaml index 9f114e15a..4079c78b6 100644 --- a/plugins/Runner/LocalAgent/manifest.yaml +++ b/plugins/Runner/LocalAgent/manifest.yaml @@ -3,7 +3,7 @@ kind: Plugin metadata: author: langbot-team name: LocalAgent - version: 0.2.2 + version: 0.2.3 repository: https://github.com/langbot-app/LangBot/tree/master/plugins/Runner/LocalAgent label: en_US: Local Agent diff --git a/plugins/Runner/LocalAgent/pyproject.toml b/plugins/Runner/LocalAgent/pyproject.toml index 1d93f02a7..67c1041e0 100644 --- a/plugins/Runner/LocalAgent/pyproject.toml +++ b/plugins/Runner/LocalAgent/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "langbot-local-agent" -version = "0.2.2" +version = "0.2.3" description = "Built-in agent with model fallback, tools, and knowledge retrieval" requires-python = ">=3.11,<4.0" dependencies = [ "pydantic>=2.0.0", - "langbot-plugin==0.7.18", + "langbot-plugin>=0.7.18,<0.8.0", ] [project.optional-dependencies] diff --git a/plugins/Runner/LocalAgent/requirements.txt b/plugins/Runner/LocalAgent/requirements.txt index e72c24809..1130156db 100644 --- a/plugins/Runner/LocalAgent/requirements.txt +++ b/plugins/Runner/LocalAgent/requirements.txt @@ -1,2 +1,2 @@ -langbot-plugin==0.7.18 +langbot-plugin>=0.7.18,<0.8.0 pydantic>=2.0.0 diff --git a/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py b/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py new file mode 100644 index 000000000..b7a5cd282 --- /dev/null +++ b/plugins/Runner/LocalAgent/tests/test_sdk_dependency_contract.py @@ -0,0 +1,39 @@ +import tomllib +from pathlib import Path + +import pytest +import yaml +from packaging.requirements import Requirement + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] + + +def _sdk_requirements(): + project = tomllib.loads((PLUGIN_ROOT / "pyproject.toml").read_text()) + packaged = (PLUGIN_ROOT / "requirements.txt").read_text().splitlines() + return [ + next(Requirement(line) for line in requirements if line.startswith("langbot-plugin")) + for requirements in (project["project"]["dependencies"], packaged) + ] + + +def test_project_and_packaged_sdk_constraints_match(): + project, packaged = _sdk_requirements() + assert project.specifier == packaged.specifier + assert str(project.specifier) == "<0.8.0,>=0.7.18" + + +@pytest.mark.parametrize("version", ["0.7.18", "0.7.19", "0.7.99"]) +def test_sdk_constraints_accept_compatible_stable_patches(version): + assert all(requirement.specifier.contains(version) for requirement in _sdk_requirements()) + + +@pytest.mark.parametrize("version", ["0.7.16", "0.7.17", "0.8.0", "1.0.0"]) +def test_sdk_constraints_reject_unverified_versions(version): + assert not any(requirement.specifier.contains(version) for requirement in _sdk_requirements()) + + +def test_manifest_and_project_versions_match(): + project = tomllib.loads((PLUGIN_ROOT / "pyproject.toml").read_text()) + manifest = yaml.safe_load((PLUGIN_ROOT / "manifest.yaml").read_text()) + assert project["project"]["version"] == manifest["metadata"]["version"] diff --git a/plugins/Runner/LocalAgent/uv.lock b/plugins/Runner/LocalAgent/uv.lock index e3537f13e..4163b0818 100644 --- a/plugins/Runner/LocalAgent/uv.lock +++ b/plugins/Runner/LocalAgent/uv.lock @@ -456,7 +456,7 @@ wheels = [ [[package]] name = "langbot-local-agent" -version = "0.2.2" +version = "0.2.3" source = { editable = "." } dependencies = [ { name = "langbot-plugin" }, @@ -472,7 +472,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "langbot-plugin", specifier = "==0.7.18" }, + { name = "langbot-plugin", specifier = ">=0.7.18,<0.8.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, diff --git a/plugins/catalog.json b/plugins/catalog.json index 5c1621731..d829d2b0c 100644 --- a/plugins/catalog.json +++ b/plugins/catalog.json @@ -36,7 +36,7 @@ }, { "id": "langbot-team/LocalAgent", - "version": "0.2.2", + "version": "0.2.3", "category": "Runner", "path": "Runner/LocalAgent", "repository": "https://github.com/langbot-app/LangBot/tree/master/plugins/Runner/LocalAgent" diff --git a/src/langbot/libs/qq_official_api/api.py b/src/langbot/libs/qq_official_api/api.py index 4fedae5ca..5dce0ce9a 100644 --- a/src/langbot/libs/qq_official_api/api.py +++ b/src/langbot/libs/qq_official_api/api.py @@ -401,13 +401,21 @@ class QQOfficialClient: content_type = attachment.get('content_type', '') return content_type.startswith('image/') + def _resolve_msg_seq(self, msg_seq: Optional[int] = None) -> int: + """Allocate automatic sequences across message types, preserving explicit stream sequences.""" + if msg_seq is None: + self._msg_seq_counter += 1 + return self._msg_seq_counter + self._msg_seq_counter = max(self._msg_seq_counter, msg_seq) + return msg_seq + async def send_private_text_msg( self, user_openid: str, content: str, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ): """Send a c2c text message. @@ -427,7 +435,7 @@ class QQOfficialClient: data: dict[str, Any] = { 'content': content, 'msg_type': 0, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(msg_seq), } if msg_id: data['msg_id'] = msg_id @@ -448,7 +456,7 @@ class QQOfficialClient: content: str, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ) -> None: """Send a Markdown message to a C2C user or QQ group.""" if not await self.check_access_token(): @@ -464,7 +472,7 @@ class QQOfficialClient: data: dict[str, Any] = { 'msg_type': 2, 'markdown': {'content': content}, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(msg_seq), } if msg_id: data['msg_id'] = msg_id @@ -488,7 +496,7 @@ class QQOfficialClient: content: str, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ) -> None: """Send a Markdown C2C message.""" await self._send_markdown_msg('c2c', user_openid, content, msg_id, event_id, msg_seq) @@ -499,7 +507,7 @@ class QQOfficialClient: content: str, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ) -> None: """Send a Markdown QQ group message.""" await self._send_markdown_msg('group', group_openid, content, msg_id, event_id, msg_seq) @@ -510,7 +518,7 @@ class QQOfficialClient: content: str, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ): """Send a group text message. @@ -529,7 +537,7 @@ class QQOfficialClient: data: dict[str, Any] = { 'content': content, 'msg_type': 0, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(msg_seq), } if msg_id: data['msg_id'] = msg_id @@ -685,12 +693,10 @@ class QQOfficialClient: else: raise ValueError(f'Unsupported target_type: {target_type}') - self._msg_seq_counter += 1 - msg_seq = self._msg_seq_counter body = { 'msg_type': 7, 'media': {'file_info': file_info}, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(), } if content: body['content'] = content @@ -792,7 +798,7 @@ class QQOfficialClient: 'content_raw': content, 'event_id': event_id, 'msg_id': msg_id, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(msg_seq), 'index': index, } if stream_msg_id: @@ -817,7 +823,7 @@ class QQOfficialClient: keyboard: Optional[dict] = None, msg_id: Optional[str] = None, event_id: Optional[str] = None, - msg_seq: int = 1, + msg_seq: Optional[int] = None, ) -> dict: """Send a ``msg_type=2`` (markdown) message carrying a keyboard. @@ -838,7 +844,7 @@ class QQOfficialClient: event_id: Use ``INTERACTION_CREATE`` event id from a prior button click to keep within the 30-minute passive window without an inbound msg_id. - msg_seq: De-dup counter when reusing msg_id. + msg_seq: Explicit de-dup counter, or None to allocate automatically. """ if not await self.check_access_token(): await self.get_access_token() @@ -853,7 +859,7 @@ class QQOfficialClient: body: dict[str, Any] = { 'msg_type': 2, 'markdown': {'content': markdown_content}, - 'msg_seq': msg_seq, + 'msg_seq': self._resolve_msg_seq(msg_seq), } if keyboard and keyboard.get('content', {}).get('rows'): body['keyboard'] = keyboard diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index d8dcad0c9..6a3fb351a 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -139,7 +139,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl message: platform_message.MessageChain, ) -> platform_events.MessageResult: raw = await self._send_content_list( - str(target_type), str(target_id), await QQOfficialMessageConverter.yiri2target(message) + str(target_type), + str(target_id), + await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(str(target_type))), ) return platform_events.MessageResult(raw={'results': raw}) @@ -156,7 +158,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl raw = await self._send_content_list( target_type, target_id, - await QQOfficialMessageConverter.yiri2target(message), + await QQOfficialMessageConverter.yiri2target(message, self._normalize_target_type(target_type)), msg_id=source.d_id, ) return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw}) @@ -177,7 +179,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl *, msg_id: typing.Optional[str] = None, event_id: typing.Optional[str] = None, - msg_seq: int = 1, + msg_seq: typing.Optional[int] = None, ) -> typing.Any: """Send a text reply using the configured C2C/group render mode.""" use_markdown = self.config.get('enable-markdown-rendering', False) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py index 545046f1a..ae3c935ab 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/message_converter.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import html import re from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent @@ -21,18 +22,35 @@ def _is_base64_data(value: str) -> bool: class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @staticmethod - async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]: + async def yiri2target(message_chain: platform_message.MessageChain, target_type: str | None = None) -> list[dict]: content_list: list[dict] = [] + text_parts: list[str] = [] + + def flush_text() -> None: + content = ''.join(text_parts) + if content: + content_list.append({'type': 'text', 'content': content}) + text_parts.clear() + for component in message_chain: if isinstance(component, platform_message.Source): continue if isinstance(component, platform_message.Plain): - content_list.append({'type': 'text', 'content': component.text}) + text_parts.append(component.text) + continue elif isinstance(component, platform_message.At): - content_list.append({'type': 'text', 'content': f'@{component.display or component.target}'}) + if target_type in {None, 'group', 'channel'}: + target = html.escape(str(component.target), quote=True) + text_parts.append(f'') + else: + text_parts.append(f'@{component.display or component.target}') + continue elif isinstance(component, platform_message.AtAll): - content_list.append({'type': 'text', 'content': '@all'}) - elif isinstance(component, platform_message.Image): + text_parts.append('' if target_type == 'channel' else '@all') + continue + + flush_text() + if isinstance(component, platform_message.Image): content_list.append(QQOfficialMessageConverter._media_payload(component, 'image')) elif isinstance(component, platform_message.Voice): content_list.append(QQOfficialMessageConverter._media_payload(component, 'voice')) @@ -44,15 +62,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)) + content_list.extend(await QQOfficialMessageConverter.yiri2target(component.origin, target_type)) 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)) + content_list.extend( + await QQOfficialMessageConverter.yiri2target(node.message_chain, target_type) + ) else: text = str(component) if text: content_list.append({'type': 'text', 'content': text}) + flush_text() return content_list @staticmethod diff --git a/src/langbot/pkg/platform/sources/qqofficial.py b/src/langbot/pkg/platform/sources/qqofficial.py index fcc4c1a93..69c513ae1 100644 --- a/src/langbot/pkg/platform/sources/qqofficial.py +++ b/src/langbot/pkg/platform/sources/qqofficial.py @@ -387,7 +387,7 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter *, msg_id: typing.Optional[str] = None, event_id: typing.Optional[str] = None, - msg_seq: int = 1, + msg_seq: typing.Optional[int] = None, ) -> None: """Send a text reply using the configured C2C/group render mode.""" use_markdown = self.config.get('enable-markdown-rendering', False) diff --git a/tests/fixtures/legacy_plugin_identity_sources.json b/tests/fixtures/legacy_plugin_identity_sources.json index 89e22e7c8..da8609482 100644 --- a/tests/fixtures/legacy_plugin_identity_sources.json +++ b/tests/fixtures/legacy_plugin_identity_sources.json @@ -21,7 +21,7 @@ "Runner/LocalAgent/components/runner/default.py": "49f80640d5b26442516b210cd9e65c452c9ce3a9909d6dbd1aa05e5bf3b232ac", "Runner/LocalAgent/components/runner/default.yaml": "c1364cf0f211162bb701e27ed92b351fd9ff4cfa0b4469cfeb83ad58136cdf47", "Runner/LocalAgent/main.py": "0cde45d7f6f411563fbded09be70d0a497c7c7999b5eaa27f83052383348277f", - "Runner/LocalAgent/manifest.yaml": "bc04121a6ffe73fee06c70634726216a6c45b656a350fdab7807d06403cad2cf", + "Runner/LocalAgent/manifest.yaml": "d4ccb50d83e83e452ab0b14822f245d5dc4bb85c7bc963ee467f299d4723063f", "Runner/LocalAgent/pkg/__init__.py": "ce5142b4971e8d7945fb3c202bacbc28011b033de0ddcb612d5d29106d6985ec", "Runner/LocalAgent/pkg/agent_core/__init__.py": "56e95f2391f796e17aee6a168603853ea78b59df97a136bfa33532aede2fa413", "Runner/LocalAgent/pkg/agent_core/langbot.py": "6bc0e6ee89e8ba3705d14545169f25f54a0c56dc5da89bd8384b3e22e3f21fea", @@ -34,9 +34,9 @@ "Runner/LocalAgent/pkg/model_calling.py": "3ebdb8900bb0de5b14e84bf027cc4ff01e59a4669d28dcede7ca3dcea3a3de4c", "Runner/LocalAgent/pkg/rag.py": "ea407af8d4f6fe5b00cde72c7d85ccc541d6fc46254188c372351d93da4a1f0f", "Runner/LocalAgent/pkg/run_assembly.py": "e1e2dd0029c976b6c0eb3763e5f94a369fd1909e8bf796a2cb84658c59727630", - "Runner/LocalAgent/pyproject.toml": "7f75c366ef0c6817c84cc906c9cd2783df9782876983ae6b15cb299a27409e62", - "Runner/LocalAgent/requirements.txt": "5c91c75dba82f776218d1e63541df3d2f444bb1f9354ea991826df57dad401c9", - "Runner/LocalAgent/uv.lock": "fc82991d3b2fadff999198c3c1ab35fc04eb8f64aa3f1f464aa93c87afa5ae01" + "Runner/LocalAgent/pyproject.toml": "18ec79e6bf40cca8d7b49df93489e2b3d9d74d820ac2f2032fb86d99b0b3cec4", + "Runner/LocalAgent/requirements.txt": "7ae9e2f4a957346c938f46dca3769c386c57cc4bc9044fd342358c0163d82bc4", + "Runner/LocalAgent/uv.lock": "6ee65461bcff71e93e0701ec91d56f298449144f708d83dd7dde3b1b8f548d05" }, "root": "plugins" } diff --git a/tests/unit_tests/platform/test_qqofficial_api.py b/tests/unit_tests/platform/test_qqofficial_api.py index c0dfdf84c..7bec61a5a 100644 --- a/tests/unit_tests/platform/test_qqofficial_api.py +++ b/tests/unit_tests/platform/test_qqofficial_api.py @@ -194,14 +194,14 @@ async def test_qq_markdown_rendering_switches_c2c_and_group_text_replies(): content='# Hello', msg_id='message-1', event_id=None, - msg_seq=1, + msg_seq=None, ) 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, + msg_seq=None, ) adapter.bot.send_private_text_msg.assert_not_awaited() adapter.bot.send_group_text_msg.assert_not_awaited() diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py index a5aee2934..8a0aecec7 100644 --- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -243,9 +243,7 @@ async def test_qqofficial_message_converter_maps_common_components_to_send_paylo ) ) - assert {'type': 'text', 'content': 'hi'} in payload - assert {'type': 'text', 'content': '@Alice'} in payload - assert {'type': 'text', 'content': '@all'} in payload + assert {'type': 'text', 'content': 'hi@all'} in payload assert any(item['type'] == 'image' and item['url'] == 'https://example.test/a.png' for item in payload) assert any(item['type'] == 'voice' and item['base64'].startswith('data:audio') for item in payload) assert any(item['type'] == 'file' and item['name'] == 'a.txt' for item in payload) diff --git a/tests/unit_tests/platform/test_qqofficial_message_sequences.py b/tests/unit_tests/platform/test_qqofficial_message_sequences.py new file mode 100644 index 000000000..a46a0ab12 --- /dev/null +++ b/tests/unit_tests/platform/test_qqofficial_message_sequences.py @@ -0,0 +1,223 @@ +import asyncio +import json +import time +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +import pytest_asyncio + +import langbot.pkg.core.app # noqa: F401 +from langbot.libs.qq_official_api.api import QQOfficialClient +from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent +from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter +from langbot.pkg.platform.adapters.qqofficial.event_converter import QQOfficialEventConverter +from langbot.pkg.platform.adapters.qqofficial.message_converter import QQOfficialMessageConverter +from langbot.pkg.platform.sources.qqofficial import QQOfficialAdapter as LegacyQQOfficialAdapter +from langbot_plugin.api.entities.builtin.platform import message + + +@pytest_asyncio.fixture +async def captured_client(): + requests = [] + + async def respond(request): + await asyncio.sleep(0) + requests.append((request.url.path, json.loads(request.content))) + return httpx.Response(200, json={'id': 'reply', 'file_info': 'uploaded-media'}) + + client = QQOfficialClient('fixture-secret', '', 'fixture-app', AsyncMock()) + client.access_token = 'fixture-token' + client.access_token_expiry_time = time.time() + 3600 + for timeout in (None, 30, 120): + client._http_clients[timeout] = httpx.AsyncClient(transport=httpx.MockTransport(respond)) + try: + yield client, requests + finally: + await client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('adapter_class', [QQOfficialAdapter, LegacyQQOfficialAdapter]) +@pytest.mark.parametrize('markdown', [False, True]) +@pytest.mark.parametrize('event_type', ['C2C_MESSAGE_CREATE', 'GROUP_AT_MESSAGE_CREATE', 'GROUP_MESSAGE_CREATE']) +async def test_segmented_replies_share_sequences(captured_client, adapter_class, markdown, event_type): + client, requests = captured_client + adapter = adapter_class.model_construct( + bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock() + ) + source = SimpleNamespace( + source_platform_object=QQOfficialEvent( + {'t': event_type, 'd_id': 'source-one', 'user_openid': 'user-one', 'group_openid': 'group-one'} + ) + ) + await adapter.reply_message( + source, + message.MessageChain( + [message.At(target='user-one'), message.Plain(text='first'), message.Plain(text='second')] + ), + ) + await adapter.reply_message(source, message.MessageChain([message.Plain(text='follow-up')])) + + payloads = [body for _, body in requests] + expected_sequences = [1, 2] if adapter_class is QQOfficialAdapter else [1, 2, 3] + assert [body['msg_seq'] for body in payloads] == expected_sequences + assert all(body['msg_id'] == 'source-one' for body in payloads) + assert all(body['msg_type'] == (2 if markdown else 0) for body in payloads) + expected_path = ( + '/v2/users/user-one/messages' if event_type == 'C2C_MESSAGE_CREATE' else '/v2/groups/group-one/messages' + ) + assert all(path == expected_path for path, _ in requests) + 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 '' + assert texts == [f'{mention}firstsecond', 'follow-up'] + else: + assert texts == ['first', 'second', 'follow-up'] + + +@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): + client, requests = captured_client + adapter = QQOfficialAdapter.model_construct( + bot=client, config={'enable-markdown-rendering': markdown}, logger=AsyncMock() + ) + source = await QQOfficialEventConverter().target2yiri( + QQOfficialEvent( + { + 't': event_type, + 'd_id': 'source-one', + 'member_openid': 'sender-openid', + 'group_openid': 'group-one', + 'content': 'hello', + 'timestamp': '2026-10-28T10:00:00+0000', + } + ) + ) + await adapter.reply_message( + source, + message.MessageChain( + [ + message.At(target=source.sender.id, display='Alice'), + message.Plain(text=' hello'), + message.Plain(text='!'), + ] + ), + ) + + assert len(requests) == 1 + path, payload = requests[0] + assert path == '/v2/groups/group-one/messages' + assert payload['msg_id'] == 'source-one' + assert payload['msg_seq'] == 1 + content = payload['markdown']['content'] if markdown else payload['content'] + assert content == ' hello!' + + +@pytest.mark.asyncio +async def test_merged_text_preserves_media_order_and_sequences(captured_client): + client, requests = captured_client + adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock()) + source = SimpleNamespace( + source_platform_object=QQOfficialEvent( + {'t': 'GROUP_MESSAGE_CREATE', 'd_id': 'source-one', 'group_openid': 'group-one'} + ) + ) + await adapter.reply_message( + source, + message.MessageChain( + [ + message.At(target='sender'), + message.Plain(text=' image:'), + message.Image(url='https://example.test/image.png'), + message.Plain(text='after'), + message.Plain(text=' image'), + ] + ), + ) + 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'] == ' image:' + assert payloads[2]['content'] == 'after image' + + +@pytest.mark.asyncio +async def test_mentions_escape_ids_without_using_display_names(): + payloads = await QQOfficialMessageConverter.yiri2target( + message.MessageChain([message.At(target='bad"/>'}] + + +@pytest.mark.asyncio +async def test_empty_text_does_not_emit_an_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.Plain(text='')])) + assert requests == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target_type', ['channel', 'guild']) +async def test_channel_mentions_are_native_and_not_split(captured_client, target_type): + client, requests = captured_client + adapter = QQOfficialAdapter.model_construct(bot=client, config={}, logger=AsyncMock()) + await adapter.send_message( + target_type, + 'channel-one', + message.MessageChain([message.At(target='member'), message.AtAll(), message.Plain(text=' hello')]), + ) + assert len(requests) == 1 + assert requests[0][1]['content'] == ' hello' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('target_type', ['c2c', 'group']) +async def test_text_media_markdown_and_keyboard_share_sequences(captured_client, target_type): + client, requests = captured_client + text_sender = client.send_private_text_msg if target_type == 'c2c' else client.send_group_text_msg + markdown_sender = client.send_private_markdown_msg if target_type == 'c2c' else client.send_group_markdown_msg + await text_sender('target', 'first', msg_id='source') + await client.send_image_msg(target_type, 'target', file_url='https://example.test/image.png', msg_id='source') + await markdown_sender('target', '**second**', msg_id='source') + await client.send_markdown_keyboard(target_type, 'target', 'third', {}, msg_id='source') + await text_sender('target', 'fourth', msg_id='source') + + payloads = [body for path, body in requests if path.endswith('/messages')] + assert [body['msg_seq'] for body in payloads] == [1, 2, 3, 4, 5] + assert [body['msg_type'] for body in payloads] == [0, 7, 2, 2, 0] + assert all(body['msg_id'] == 'source' for body in payloads) + + +@pytest.mark.asyncio +async def test_concurrent_replies_do_not_reuse_sequences(captured_client): + client, requests = captured_client + await asyncio.gather(*(client.send_group_text_msg('group', str(index), msg_id='source') for index in range(20))) + assert sorted(body['msg_seq'] for _, body in requests) == list(range(1, 21)) + + +@pytest.mark.asyncio +async def test_explicit_stream_sequence_is_preserved_and_reserved(captured_client): + client, requests = captured_client + await client.send_stream_msg('user', 'first', event_id='source', msg_id='source', msg_seq=7) + await client.send_stream_msg( + 'user', 'final', event_id='source', msg_id='source', msg_seq=7, stream_msg_id='reply', input_state=10 + ) + await client.send_private_text_msg('user', 'follow-up', msg_id='source') + await client.send_private_markdown_msg('user', '**explicit**', msg_id='source', msg_seq=10) + await client.send_private_text_msg('user', 'automatic', msg_id='source') + assert [body['msg_seq'] for _, body in requests] == [7, 7, 8, 10, 11] + + +@pytest.mark.asyncio +async def test_failed_send_does_not_reuse_sequence(captured_client): + client, requests = captured_client + client._http_clients[None].post = AsyncMock(side_effect=httpx.ReadTimeout('response lost')) + with pytest.raises(httpx.ReadTimeout): + await client.send_group_text_msg('group', 'first', msg_id='source') + await client.send_markdown_keyboard('group', 'group', 'retry', {}, msg_id='source') + assert requests[0][1]['msg_seq'] == 2