按钮回调防抖与先应答:10 秒去重、进门回应互动、播放/下载先回占位消息
- 同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;连点 仍应答互动,关掉客户端 loading - 播放/下载结果以 msg_seq=2 补发(同一事件相同 msg_seq 的二次回复会被 QQ 拒绝) - 一并入库此前未提交的 /sources 平台发现(直链回退链按服务器能力动态 解析);uv.lock 随上游 qqbotsdk main 更新 sdist hash
This commit is contained in:
1 parent
24c235c93d
commit
324fe86962
4 files changed
+319
-40
No files matched your search
+149
-5
@@ -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
|
||||
|
||||
Reference in new issue
Block a user