fix(qqofficial): preserve native mentions and reply sequences (LANG-181)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Chin Rock
2026-10-28 10:11:53 +00:00
co-authored by multica-agent
parent 3a77306c3c
commit ca36046f0d
15 changed files with 335 additions and 42 deletions
+4
View File
@@ -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 模型切换。
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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]
+1 -1
View File
@@ -1,2 +1,2 @@
langbot-plugin==0.7.18
langbot-plugin>=0.7.18,<0.8.0
pydantic>=2.0.0
@@ -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"]
+2 -2
View File
@@ -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" },
+1 -1
View File
@@ -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"
+21 -15
View File
@@ -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
@@ -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)
@@ -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'<qqbot-at-user id="{target}" />')
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('<qqbot-at-everyone />' 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
@@ -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)
+4 -4
View File
@@ -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"
}
@@ -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()
@@ -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<qqbot-at-user id="user-1" />@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)
@@ -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 '<qqbot-at-user id="user-one" />'
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 == '<qqbot-at-user id="sender-openid" /> 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'] == '<qqbot-at-user id="sender" /> 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"/><qqbot-at-everyone', display='Alice')]), 'group'
)
assert payloads == [{'type': 'text', 'content': '<qqbot-at-user id="bad&quot;/&gt;&lt;qqbot-at-everyone" />'}]
@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'] == '<qqbot-at-user id="member" /><qqbot-at-everyone /> 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