mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 09:26:07 +00:00
fix(qqofficial): allocate fresh msg_seq per inbound msg_id to avoid 40054005 dedup
QQ Official v2 API deduplicates passive messages by (msg_id, msg_seq). The adapter reused msg_seq across multiple sends under the same inbound msg_id, so multi-part text replies, rich media, and especially streaming chunks (msg_seq pinned at 1) were rejected with: 40054005 消息被去重,请检查请求msgseq Add a per-inbound-msg_id sequence allocator (next_reply_msg_seq) on QQOfficialClient, backed by a bounded OrderedDict + asyncio lock, and use it for C2C/group text sends and rich media. Streaming now advances ctx['msg_seq'] per chunk. Proactive sends (no msg_id) fall back to the existing global counter. Closes #2290
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
from quart import request
|
from quart import request
|
||||||
import httpx
|
import httpx
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
@@ -35,6 +36,11 @@ class QQOfficialClient:
|
|||||||
self.access_token_expiry_time = None
|
self.access_token_expiry_time = None
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._msg_seq_counter = 0
|
self._msg_seq_counter = 0
|
||||||
|
# Per-inbound-msg_id 回复序号分配器。QQ 官方 v2 API 通过 (msg_id, msg_seq)
|
||||||
|
# 对被动消息去重,同一 msg_id 复用 msg_seq 会被拒绝 (40054005 消息被去重)。
|
||||||
|
self._reply_msg_seq: 'collections.OrderedDict[str, int]' = collections.OrderedDict()
|
||||||
|
self._reply_msg_seq_lock = asyncio.Lock()
|
||||||
|
self._reply_msg_seq_max_keys = 1024
|
||||||
self._token_refresh_task: Optional[asyncio.Task] = None
|
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
async def check_access_token(self):
|
async def check_access_token(self):
|
||||||
@@ -177,6 +183,27 @@ class QQOfficialClient:
|
|||||||
content_type = attachment.get('content_type', '')
|
content_type = attachment.get('content_type', '')
|
||||||
return content_type.startswith('image/')
|
return content_type.startswith('image/')
|
||||||
|
|
||||||
|
async def next_reply_msg_seq(self, msg_id: Optional[str]) -> int:
|
||||||
|
"""为同一条入站 msg_id 关联的每次被动下发分配递增的 msg_seq。
|
||||||
|
|
||||||
|
QQ 官方 v2 API 通过 (msg_id, msg_seq) 去重,同一 msg_id 复用 msg_seq 会被
|
||||||
|
拒绝 (40054005 消息被去重)。多段文本、富媒体、重试等场景下每次发送都需要
|
||||||
|
新的 msg_seq。无 msg_id(主动消息等)时回退到全局自增计数器。
|
||||||
|
"""
|
||||||
|
if not msg_id:
|
||||||
|
self._msg_seq_counter += 1
|
||||||
|
return self._msg_seq_counter
|
||||||
|
|
||||||
|
key = str(msg_id)
|
||||||
|
async with self._reply_msg_seq_lock:
|
||||||
|
seq = self._reply_msg_seq.get(key, 0) + 1
|
||||||
|
self._reply_msg_seq[key] = seq
|
||||||
|
self._reply_msg_seq.move_to_end(key)
|
||||||
|
# 限制字典大小,避免长期运行的内存增长
|
||||||
|
while len(self._reply_msg_seq) > self._reply_msg_seq_max_keys:
|
||||||
|
self._reply_msg_seq.popitem(last=False)
|
||||||
|
return seq
|
||||||
|
|
||||||
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
|
async def send_private_text_msg(self, user_openid: str, content: str, msg_id: str):
|
||||||
"""发送私聊消息"""
|
"""发送私聊消息"""
|
||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
@@ -192,6 +219,7 @@ class QQOfficialClient:
|
|||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_id': msg_id,
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': await self.next_reply_msg_seq(msg_id),
|
||||||
}
|
}
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
@@ -216,6 +244,7 @@ class QQOfficialClient:
|
|||||||
'content': content,
|
'content': content,
|
||||||
'msg_type': 0,
|
'msg_type': 0,
|
||||||
'msg_id': msg_id,
|
'msg_id': msg_id,
|
||||||
|
'msg_seq': await self.next_reply_msg_seq(msg_id),
|
||||||
}
|
}
|
||||||
response = await client.post(url, headers=headers, json=data)
|
response = await client.post(url, headers=headers, json=data)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
@@ -363,8 +392,7 @@ class QQOfficialClient:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported target_type: {target_type}')
|
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||||
|
|
||||||
self._msg_seq_counter += 1
|
msg_seq = await self.next_reply_msg_seq(msg_id)
|
||||||
msg_seq = self._msg_seq_counter
|
|
||||||
body = {
|
body = {
|
||||||
'msg_type': 7,
|
'msg_type': 7,
|
||||||
'media': {'file_info': file_info},
|
'media': {'file_info': file_info},
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
|
|||||||
ctx['stream_msg_id'] = resp['id']
|
ctx['stream_msg_id'] = resp['id']
|
||||||
ctx['sent_length'] = len(ctx['accumulated_text'])
|
ctx['sent_length'] = len(ctx['accumulated_text'])
|
||||||
ctx['index'] += 1
|
ctx['index'] += 1
|
||||||
|
# 每个 chunk 单独 HTTP 下发,msg_seq 必须递增,否则后续 chunk 会被 QQ
|
||||||
|
# 以 (msg_id, msg_seq) 去重 (40054005 消息被去重)。
|
||||||
|
ctx['msg_seq'] += 1
|
||||||
if is_final:
|
if is_final:
|
||||||
self._stream_ctx.pop(message_id, None)
|
self._stream_ctx.pop(message_id, None)
|
||||||
self._stream_ctx_ts.pop(message_id, None)
|
self._stream_ctx_ts.pop(message_id, None)
|
||||||
|
|||||||
@@ -346,3 +346,92 @@ async def test_qqofficial_send_reply_stream_platform_api_and_unsupported():
|
|||||||
|
|
||||||
with pytest.raises(NotSupportedError):
|
with pytest.raises(NotSupportedError):
|
||||||
await adapter.call_platform_api('missing', {})
|
await adapter.call_platform_api('missing', {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qqofficial_client_next_reply_msg_seq_allocator():
|
||||||
|
"""msg_seq must increment per inbound msg_id to avoid 40054005 dedup (issue #2290)."""
|
||||||
|
from langbot.libs.qq_official_api.api import QQOfficialClient
|
||||||
|
|
||||||
|
client = QQOfficialClient(secret='s', token='t', app_id='a', logger=DummyLogger())
|
||||||
|
|
||||||
|
# same msg_id -> strictly increasing 1, 2, 3
|
||||||
|
assert [await client.next_reply_msg_seq('m1') for _ in range(3)] == [1, 2, 3]
|
||||||
|
# a different msg_id is tracked independently
|
||||||
|
assert await client.next_reply_msg_seq('m2') == 1
|
||||||
|
# missing msg_id falls back to the global proactive counter, independent of per-id seqs
|
||||||
|
assert [await client.next_reply_msg_seq(None) for _ in range(2)] == [1, 2]
|
||||||
|
# concurrent allocations under the same msg_id are unique and gap-free
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
concurrent = await _asyncio.gather(*[client.next_reply_msg_seq('m3') for _ in range(50)])
|
||||||
|
assert sorted(concurrent) == list(range(1, 51))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qqofficial_text_send_carries_incrementing_msg_seq(monkeypatch):
|
||||||
|
"""Multiple text replies under one inbound msg_id get distinct msg_seq values."""
|
||||||
|
from langbot.libs.qq_official_api.api import QQOfficialClient
|
||||||
|
|
||||||
|
client = QQOfficialClient(secret='s', token='t', app_id='a', logger=DummyLogger())
|
||||||
|
client.access_token = 'token'
|
||||||
|
client.access_token_expiry_time = 9999999999
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {'id': 'ok'}
|
||||||
|
|
||||||
|
class _DummyAsyncClient:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, headers=None, json=None):
|
||||||
|
captured.append(json)
|
||||||
|
return _Resp()
|
||||||
|
|
||||||
|
monkeypatch.setattr('langbot.libs.qq_official_api.api.httpx.AsyncClient', _DummyAsyncClient)
|
||||||
|
|
||||||
|
await client.send_private_text_msg('user-1', 'first', 'inbound-1')
|
||||||
|
await client.send_private_text_msg('user-1', 'second', 'inbound-1')
|
||||||
|
await client.send_group_text_msg('group-1', 'third', 'inbound-1')
|
||||||
|
|
||||||
|
seqs = [body['msg_seq'] for body in captured]
|
||||||
|
assert seqs == [1, 2, 3]
|
||||||
|
assert all(body['msg_id'] == 'inbound-1' for body in captured)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qqofficial_stream_chunks_increment_msg_seq():
|
||||||
|
"""Each stream chunk must advance msg_seq so QQ does not dedup later chunks (issue #2290)."""
|
||||||
|
adapter = make_adapter()
|
||||||
|
source_event = await QQOfficialEventConverter().target2yiri(qq_event('C2C_MESSAGE_CREATE'))
|
||||||
|
|
||||||
|
assert await adapter.create_message_card('msg-1', source_event) is True
|
||||||
|
bot_message = {'resp_message_id': 'msg-1'}
|
||||||
|
|
||||||
|
for idx, text in enumerate(['hello ', 'world ', 'done']):
|
||||||
|
# reset the per-chunk rate limiter so each chunk actually sends
|
||||||
|
adapter._stream_ctx['msg-1']['last_update_ts'] = 0
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
source_event,
|
||||||
|
bot_message,
|
||||||
|
platform_message.MessageChain([platform_message.Plain(text=text)]),
|
||||||
|
is_final=(idx == 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_calls = [call[1] for call in adapter.bot.sent if call[0] == 'stream']
|
||||||
|
seqs = [kwargs['msg_seq'] for kwargs in stream_calls]
|
||||||
|
# strictly increasing, no duplicates
|
||||||
|
assert seqs == sorted(set(seqs))
|
||||||
|
assert len(seqs) == len(set(seqs))
|
||||||
|
assert seqs[0] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user