按钮回调防抖与先应答:10 秒去重、进门回应互动、播放/下载先回占位消息

- 同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;连点
  仍应答互动,关掉客户端 loading
- 播放/下载结果以 msg_seq=2 补发(同一事件相同 msg_seq 的二次回复会被
  QQ 拒绝)
- 一并入库此前未提交的 /sources 平台发现(直链回退链按服务器能力动态
  解析);uv.lock 随上游 qqbotsdk main 更新 sdist hash
This commit is contained in:
snowy-yang committed 2026-09-30 18:59:17 +08:00
1 parent 24c235c93d
commit 324fe86962
4 files changed
+319 -40

No files matched your search

+149 -5
View File
@@ -80,8 +80,15 @@ class FakeMusicServer:
路由,模拟 CDN 风控(测直链探测与候选源回退)。
"""
def __init__(self, tx_denied: bool = False) -> None:
def __init__(
self,
tx_denied: bool = False,
serve_sources: bool = True,
no_url_sources: frozenset[str] = frozenset(),
) -> None:
self.tx_denied = tx_denied
self.serve_sources = serve_sources
self.no_url_sources = no_url_sources
self.runner: web.AppRunner | None = None
self.port = 0
self.queries: list[dict] = []
@@ -91,6 +98,8 @@ class FakeMusicServer:
app.router.add_get("/search", self._search)
app.router.add_get("/url/{source}/{song_id}", self._url)
app.router.add_get("/lyric/{source}/{song_id}", self._lyric)
if self.serve_sources:
app.router.add_get("/sources", self._sources)
app.router.add_get("/media/{tail:.+}", self._media)
app.router.add_get("/denied/{tail:.+}", self._denied)
self.runner = web.AppRunner(app)
@@ -154,6 +163,25 @@ class FakeMusicServer:
async def _denied(self, _: web.Request) -> web.Response:
return web.Response(status=403, text="forbidden")
async def _sources(self, _: web.Request) -> web.Response:
platforms = [
{
"id": pid,
"name": name,
"capabilities": {
"search": True,
"url": pid not in self.no_url_sources,
},
}
for pid, name in (
("kw", "酷我音乐"),
("kg", "酷狗音乐"),
("tx", "QQ音乐"),
("wy", "网易音乐"),
)
]
return web.json_response({"code": 0, "data": {"platforms": platforms}})
async def _lyric(self, request: web.Request) -> web.Response:
lyric = "[kuwo:113]\n[ver:v1.0]\n[00:01.00]第一行\n[00:05.00]第二行\n"
return web.json_response({"code": 0, "data": {"lyric": lyric}})
@@ -180,12 +208,14 @@ async def env(server) -> AsyncIterator[tuple]:
@pytest.fixture(autouse=True)
def _reset_menu_state():
"""菜单/卡片消息记录与消息去重表是模块级状态,测试间清空避免串扰。"""
"""菜单/卡片消息记录与消息/回调去重表是模块级状态,测试间清空避免串扰。"""
music._last_msg.clear()
music._seen_messages.clear()
music._recent_clicks.clear()
yield
music._last_msg.clear()
music._seen_messages.clear()
music._recent_clicks.clear()
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义)
@@ -316,8 +346,9 @@ async def test_pick_button_sends_card(env):
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
PLAY_TX, DL_TX,
]
# 已回应互动
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1")
# 进门先回应互动(避免 QQ 客户端等超时),再发卡片
assert (calls[0]["method"], calls[0]["path"]) == ("PUT", "/interactions/I1")
assert posts(calls)[0]["json"]["msg_type"] == 2
async def test_card_without_img_when_source_has_none(env):
@@ -556,6 +587,68 @@ async def test_card_play_falls_back_to_candidate_source(server):
assert "/media/kw/" in upload["url"] # 绕开被风控的 tx 直链
async def test_fallback_sources_follow_server_platforms():
"""回退链由 /sources 动态解析:具备 search+url 能力的平台按优先序
排、其余追加在后,并排除原曲所在源(tx 的候选 = kw, wy, kg)。"""
s = FakeMusicServer()
await s.start()
try:
async with ClientSession() as http:
config = BotConfig(
music_server_url=f"http://127.0.0.1:{s.port}",
music_quality="128k",
music_source="tx",
)
got = await music._fallback_sources(
http, config, music.Song("tx", "T1", "稻香", "歌手T")
)
assert got == ["kw", "wy", "kg"]
got = await music._fallback_sources(
http, config, music.Song("kg", "K1", "稻香", "歌手K")
)
assert got == ["kw", "wy"]
finally:
await s.stop()
async def test_fallback_sources_skip_platforms_without_url():
"""/sources 里 url 能力缺失的平台不进回退链。"""
s = FakeMusicServer(no_url_sources=frozenset({"kg"}))
await s.start()
try:
async with ClientSession() as http:
config = BotConfig(
music_server_url=f"http://127.0.0.1:{s.port}",
music_quality="128k",
music_source="tx",
)
got = await music._fallback_sources(
http, config, music.Song("tx", "T1", "稻香", "歌手T")
)
assert got == ["kw", "wy"]
finally:
await s.stop()
async def test_fallback_sources_degrade_without_sources_api():
"""/sources 不可用时退回内置优先序(排除原曲源),回退不失效。"""
s = FakeMusicServer(serve_sources=False)
await s.start()
try:
async with ClientSession() as http:
config = BotConfig(
music_server_url=f"http://127.0.0.1:{s.port}",
music_quality="128k",
music_source="tx",
)
got = await music._fallback_sources(
http, config, music.Song("tx", "T1", "稻香", "歌手T")
)
assert got == ["kw", "wy"]
finally:
await s.stop()
async def test_page_button_new_menu(env):
http, config = env
api, calls = make_api()
@@ -566,13 +659,64 @@ async def test_page_button_new_menu(env):
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2 # 翻页后发新菜单
assert "点歌:稻香" in body["markdown"]["content"]
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1")
assert (calls[0]["method"], calls[0]["path"]) == ("PUT", "/interactions/I1")
api, calls = make_api()
assert not await handle_button(api, http, config, "G1", "I1", "别的插件|xx")
assert calls == [] # 非音乐按钮不处理也不回应
async def test_button_debounce_skips_repeats(env):
"""防抖:同一互动 uuid 的重复推送、同群同按钮的连点(新 uuid),10 秒
窗口内只处理第一次;连点仍要应答互动,把客户端的 loading 关掉。"""
http, config = env
api, calls = make_api()
assert await handle_button(api, http, config, "G1", "I1", PICK_1)
assert await handle_button(api, http, config, "G1", "I1", PICK_1) # 重复推送
assert len(posts(calls)) == 1 # 卡片只发一次
assert len([c for c in calls if c["method"] == "PUT"]) == 1 # 不重复应答
music._recent_clicks.clear() # 只测连点,不受上一段记下的钥匙影响
api, calls = make_api()
assert await handle_button(api, http, config, "G1", "I3", PICK_1)
assert await handle_button(api, http, config, "G1", "I4", PICK_1) # 连点
assert len(posts(calls)) == 1
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I4")
# 不同群的同一按钮互不影响
music._recent_clicks.clear()
api, calls = make_api()
assert await handle_button(api, http, config, "G2", "I5", PICK_1)
assert len(posts(calls)) == 1
async def test_button_debounce_expires(env, monkeypatch):
"""防抖窗口(TTL)过后,同一按钮可再次处理。"""
http, config = env
api, calls = make_api()
assert await handle_button(api, http, config, "G1", "I1", PICK_1)
monkeypatch.setattr(music, "_CLICK_TTL", 0.0)
assert await handle_button(api, http, config, "G1", "I1", PICK_1)
assert len(posts(calls)) == 2
async def test_play_replies_placeholder_before_fetching(env):
"""播放进门先应答互动、回「正在获取」占位(msg_seq=1),结果以 msg_seq=2
补发——同一事件相同 msg_seq 的第二次回复会被 QQ 拒绝。"""
http, config = env
api, calls = make_api()
await handle_button(
api, http, config, "G1", "I2", PLAY_TX, event_id="INTERACTION_CREATE:I2"
)
assert (calls[0]["method"], calls[0]["path"]) == ("PUT", "/interactions/I2")
ack, result = posts(calls)[0], posts(calls)[-1]
assert "正在获取" in ack["json"]["content"]
assert ack["json"]["msg_seq"] == 1
assert result["json"]["msg_type"] == 7 # 语音照常
assert result["json"]["msg_seq"] == 2
assert result["json"]["event_id"] == "INTERACTION_CREATE:I2"
async def test_slash_commands_share_text_parsing(env):
"""斜杠指令与裸文本命令同一套解析:剥掉开头的 / 与 @机器人 前缀。"""
http, config = env