diff --git a/README.md b/README.md index 992bc84..a47b07a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk), - **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音 - **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 +- **回调防抖与先应答**:按钮回调进门先回应互动(`PUT /interactions`,几秒内不应答 QQ 客户端就报超时),同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;播放/下载先回「正在获取」占位消息再取直链,结果以递增的 `msg_seq` 补发(同一事件相同 `msg_seq` 的第二次回复会被 QQ 拒绝) - **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台 - **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启 - 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启) diff --git a/musicbot/music.py b/musicbot/music.py index 202c7b5..2692293 100644 --- a/musicbot/music.py +++ b/musicbot/music.py @@ -4,11 +4,15 @@ 点编号 → 弹歌曲卡片(歌名/来源 + [▶播放][⬇下载] 回调按钮),卡片上再点才执行。 播放发语音、下载发文件,失败均回退直链文本。 +按钮回调进门先回应互动(防 QQ 客户端等超时),同一互动 uuid / 同群同按钮 +10 秒内只处理一次(防抖);播放/下载先回「正在获取」占位,结果再补发。 + 自动撤回(翻页撤旧菜单、选歌后撤菜单、播放/下载后撤卡片)由 WITHDRAW 开关统一控制,默认开。""" import re import time +import weakref from dataclasses import dataclass from typing import Any from urllib.parse import quote, unquote @@ -47,8 +51,9 @@ _HELP = ( "输入框敲 / 也能唤出这些指令" ) +# MusicServer(lx-music 聚合源)实际支持的四个平台,没有 mg(咪咕) _SOURCE_NAMES = { - "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕", + "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", } # 内嵌键盘上限:5 行、每行 5 个按钮;歌曲占行 + 1 行翻页 → 每页最多 20 首 @@ -84,20 +89,27 @@ _seen_messages: dict[str, float] = {} _DEDUP_TTL = 300.0 # 秒;被动回复时效是分钟级,重复推送不会来得更晚 _DEDUP_MAX = 1024 # 条;字典按插入序,超限淘汰最旧 +# 按钮回调防抖:互动推送自带 uuid(interaction id),同一次点击可能被 +# 平台重复推送(uuid 相同);连点则是不同 uuid、按钮 data 相同——两把 +# 钥匙任一在窗口内出现过都只处理第一次 +_CLICK_TTL = 10.0 # 秒 +_CLICK_MAX = 1024 # 条;字典按插入序,超限淘汰最旧 +_recent_clicks: dict[str, float] = {} -def _prune(now: float) -> None: - """剪掉过期记录(按插入序,头部最旧)。""" - while _seen_messages: - oldest = next(iter(_seen_messages)) - if now - _seen_messages[oldest] < _DEDUP_TTL: + +def _prune(table: dict[str, float], ttl: float, now: float) -> None: + """剪掉 table 里过期记录(按插入序,头部最旧)。""" + while table: + oldest = next(iter(table)) + if now - table[oldest] < ttl: break - del _seen_messages[oldest] + del table[oldest] def _mark(message_id: str) -> None: """记下一个 msg_id(已存在则只刷新时间),超出上限时淘汰最旧。""" now = time.monotonic() - _prune(now) + _prune(_seen_messages, _DEDUP_TTL, now) _seen_messages[message_id] = now if len(_seen_messages) > _DEDUP_MAX: del _seen_messages[next(iter(_seen_messages))] @@ -105,13 +117,25 @@ def _mark(message_id: str) -> None: def _handled(message_id: str) -> bool: """这个 msg_id 近期是否已处理过;否者记下并返回 False。""" - _prune(time.monotonic()) + _prune(_seen_messages, _DEDUP_TTL, time.monotonic()) if message_id in _seen_messages: return True _mark(message_id) return False +def _click_seen(key: str) -> bool: + """这个回调钥匙(互动 uuid 或 群+按钮 data)近期是否已处理过; + 否则记下并返回 False。""" + _prune(_recent_clicks, _CLICK_TTL, time.monotonic()) + if key in _recent_clicks: + return True + _recent_clicks[key] = time.monotonic() + if len(_recent_clicks) > _CLICK_MAX: + del _recent_clicks[next(iter(_recent_clicks))] + return False + + def _sent_id(resp: Any) -> str | None: """从发送响应取消息 id(官方字段是 id,部分接口用 message_id)。""" if isinstance(resp, dict): @@ -248,29 +272,43 @@ async def _reply( content: str, msg_id: str | None = None, event_id: str | None = None, + msg_seq: int = 1, ) -> None: if event_id: resp = await api.post_group_message( - group_openid, content=content, event_id=event_id + group_openid, content=content, event_id=event_id, msg_seq=msg_seq ) else: resp = await api.post_group_message( - group_openid, content=content, msg_id=msg_id + group_openid, content=content, msg_id=msg_id, msg_seq=msg_seq ) if sent := _sent_id(resp): _mark(sent) # 自己的消息若被回推,按重复跳过 async def _soft_reply( - api: BotApi, group_openid: str, content: str, event_id: str | None + api: BotApi, + group_openid: str, + content: str, + event_id: str | None = None, + msg_seq: int = 1, ) -> None: """尽力回复:失败(如互动 event_id 失效)仅记日志,让按钮回应照常进行。""" try: - await _reply(api, group_openid, content, event_id=event_id) + await _reply(api, group_openid, content, event_id=event_id, msg_seq=msg_seq) except _API_FAILS as e: logger.warning(f"回复失败: {e!r}") +async def _respond(api: BotApi, interaction_id: str) -> None: + """回应互动(PUT /interactions):收到回调几秒内必须应答,否则 QQ 客户端 + 一直 loading 直到超时;失败仅记日志,不阻塞后续处理。""" + try: + await api.respond_interaction(interaction_id) + except _API_FAILS as e: + logger.warning(f"回应互动失败: {e!r}") + + async def _withdraw(api: BotApi, group_openid: str, message_id: str) -> None: """撤回 bot 自己发的旧消息(超时限/无权限等失败仅记日志)。""" try: @@ -495,6 +533,7 @@ async def send_song( voice: bool, msg_id: str | None = None, event_id: str | None = None, + msg_seq: int = 1, ) -> None: """取可抓取的播放直链后发送。 @@ -505,13 +544,17 @@ async def send_song( 配置源直链抓不动时 QQ 服务器侧下载同样会失败(40093007),故先探测 可抓性、必要时换候选源;keyword 用于候选源搜索。 + msg_seq 是本事件被动回复的序号:调用方若已先回过占位消息,须传 2 + (同一 msg_id/event_id 相同 msg_seq 的第二次回复会被 QQ 拒绝)。 + 卡片撤回不在这里做:调用方 handle_button 会把这次点击当成一次性操作, 无论成败都撤回卡片。""" found = await _fetchable_play(http, config, song, keyword) if found is None: logger.warning(f"[{group_openid}] 无可用直链:{song.name}") await _reply( - api, group_openid, f"没拿到「{song.name}」的播放链接", msg_id, event_id + api, group_openid, f"没拿到「{song.name}」的播放链接", + msg_id, event_id, msg_seq, ) return _, url = found @@ -527,6 +570,7 @@ async def send_song( content=_caption(song), msg_id=msg_id, event_id=event_id, + msg_seq=msg_seq, media=upload, ) if sent := _sent_id(resp): @@ -544,7 +588,7 @@ async def send_song( if no_permission: text = f"{_caption(song)}\n(本群文件上传仅限群主/管理员,先给直链)\n{url}" try: - await _reply(api, group_openid, text, msg_id, event_id) + await _reply(api, group_openid, text, msg_id, event_id, msg_seq) logger.info(f"[{group_openid}] 直链已发:{_caption(song)}") except _API_FAILS as e: # 被动回复被拒(如互动 event_id 超时效)→ 降级为主动消息 @@ -617,8 +661,62 @@ async def _search_source( return None -# 直链的候选回退源:tx 等源直链常受 CDN 风控抓不动,kw 实测可抓 -_DOWNLOAD_FALLBACK_SOURCES = ("kw", "wy", "mg") +# 直链回退源的优先顺序:tx 等源直链常受 CDN 风控抓不动,kw 实测可抓。 +# 实际候选取 MusicServer /sources 报告的、具备 search+url 能力的平台: +# 先按此优先序排,其余平台追加在后,服务器增删源无需改代码。 +# tx 的 url 能力位只代表「服务器能给直链」,不代表直链抓得动——它是 +# 风控惯犯(回退机制的本因),绝不作为候选源,否则回退末尾几乎必然 +# 白探测一次 +_SOURCE_PREFERENCE = ("kw", "wy") +_NO_FALLBACK_SOURCES = frozenset({"tx"}) + +# /sources 的进程内缓存,按 http 会话隔离(config 是 frozen 的挂不了缓存)。 +# 值为 (到期时刻, 平台表);平台表为 None 表示上游不可用,用短 TTL 防止 +# 每次回退都白打一遍请求 +_PLATFORMS_TTL = 300.0 +_PLATFORMS_FAIL_TTL = 60.0 +_platforms_cache: weakref.WeakKeyDictionary[ + ClientSession, tuple[float, dict[str, dict] | None] +] = weakref.WeakKeyDictionary() + + +async def platforms(http: ClientSession, config: BotConfig) -> dict[str, dict]: + """MusicServer 支持的平台表(id → /sources 条目),带 TTL 缓存; + 上游不可用时返回空表,调用方需自行退化。""" + hit = _platforms_cache.get(http) + now = time.monotonic() + if hit and hit[0] > now: + return hit[1] or {} + try: + data = await _get(http, config, "/sources") + except MusicServerError as e: + logger.warning(f"取 MusicServer 平台列表失败: {e}") + _platforms_cache[http] = (now + _PLATFORMS_FAIL_TTL, None) + return {} + table = {p["id"]: p for p in data.get("platforms") or [] if p.get("id")} + _platforms_cache[http] = (now + _PLATFORMS_TTL, table) + return table + + +async def _fallback_sources( + http: ClientSession, config: BotConfig, song: Song +) -> list[str]: + """直链回退候选源:/sources 里具备 search+url 能力的平台,优先序在 + 前、其余追加在后,并排除原曲所在源;/sources 不可用时退回 + _SOURCE_PREFERENCE。""" + table = await platforms(http, config) + capable = [ + pid for pid, p in table.items() + if all((p.get("capabilities") or {}).get(c) for c in ("search", "url")) + ] + if not capable: + return [s for s in _SOURCE_PREFERENCE if s != song.source] + ordered = [s for s in _SOURCE_PREFERENCE if s in capable] + ordered += [s for s in capable if s not in ordered] + return [ + s for s in ordered + if s != song.source and s not in _NO_FALLBACK_SOURCES + ] async def _fetchable_play( @@ -627,9 +725,10 @@ async def _fetchable_play( song: Song, keyword: str, ) -> tuple[Song, str] | None: - """逐候选源取可直接抓取的播放直链:先原曲,抓不动再按 kw/wy/mg - 逐个搜索探测(懒探测:原曲可用时不产生候选源搜索)。全失败返回 None。""" - fallbacks = iter(s for s in _DOWNLOAD_FALLBACK_SOURCES if s != song.source) + """逐候选源取可直接抓取的播放直链:先原曲,抓不动再按候选源 + (/sources 动态解析,kw 优先)逐个搜索探测(懒探测:原曲可用时 + 不产生候选源搜索)。全失败返回 None。""" + fallbacks = iter(await _fallback_sources(http, config, song)) cand: Song | None = song while cand is not None: try: @@ -664,50 +763,71 @@ async def handle_button( interaction_id 仅用于回应互动(PUT /interactions);消息被动回复的 event_id 须用事件最外层 id("INTERACTION_CREATE:"),用 d.id - 裸 uuid 会被 QQ 判为无效(40034025)。""" + 裸 uuid 会被 QQ 判为无效(40034025)。 + + 进门先应答互动再干活(几秒内不应答客户端就报超时);play/dl 取直链 + 可能要几十秒,先回一条「正在获取」占位,结果以 msg_seq=2 补发。同一 + 互动 uuid(重复推送)或同群同按钮 data(连点)在防抖窗口内只处理 + 第一次。""" parts = data.split("|") action, args = (parts[1] if len(parts) > 1 else ""), parts[2:] # 歌曲按钮的 args 为 [source, song_id, name, singer](+ img,后加故兼容 4 段) is_song = action in ("pick", "play", "dl") and len(args) in (4, 5) + if not ((action == "page" and len(args) == 2) or is_song): + return False # 非本 bot 的按钮 + + # 防抖:uuid 重复推送说明已处理并应答过,直接跳过;连点是新 uuid, + # 也要应答一次把客户端的 loading 关掉,只是不再重复执行 + if _click_seen(interaction_id): + logger.debug(f"[{group_openid}] 回调重复推送,跳过:{data[:40]}") + return True + if _click_seen(f"{group_openid}|{data}"): + logger.info(f"[{group_openid}] 按钮连点,防抖窗口内已处理过,跳过") + await _respond(api, interaction_id) + return True + + # 先应答互动,再做慢活(取直链/换源探测/上传都可能在几秒开外) + await _respond(api, interaction_id) + seq = 1 + if action in ("play", "dl"): + # 同一事件多次回复需递增 msg_seq,先给占位消息把结果让到 seq=2 + await _soft_reply( + api, group_openid, + f"正在获取「{_caption(_song_of_data(args))}」,请稍候…", + event_id, + ) + seq = 2 try: - if action == "page" and len(args) == 2: + if action == "page": keyword, page = unquote(args[0]), int(args[1]) await send_menu( api, http, config, group_openid, keyword, page, event_id=event_id, ) - elif action == "pick" and is_song: + elif action == "pick": await send_card( api, http, config, group_openid, _song_of_data(args), event_id=event_id, ) - elif action in ("play", "dl") and is_song: + else: # play / dl(进门已校验 is_song) song = _song_of_data(args) try: await send_song( api, http, config, group_openid, song, keyword=f"{song.name} {song.singer}".strip(), - voice=action == "play", event_id=event_id, + voice=action == "play", event_id=event_id, msg_seq=seq, ) finally: # 卡片是一次性 UI:这次点击已被消费,无论成败都撤回 # (放 finally 才能覆盖 send_song 抛错,如候选源搜索失败) await _retire(api, config, group_openid) - else: - return False # 非本 bot 的按钮 except (MusicServerError, ValueError) as e: logger.warning(f"按钮处理失败: {e}") await _soft_reply( - api, group_openid, "音乐服务暂时不可用,稍后再试试", event_id + api, group_openid, "音乐服务暂时不可用,稍后再试试", event_id, seq ) except _API_FAILS as e: logger.warning(f"按钮处理失败: {e!r}") - - # SDK 已容忍非 JSON Content-Type(回应接口返回 200 text/plain + {}),无需特判 - try: - await api.respond_interaction(interaction_id) - except _API_FAILS as e: - logger.warning(f"回应互动失败: {e!r}") return True @@ -840,3 +960,17 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None: @ee.on("READY") async def on_ready(_: Ready, api: BotApi) -> None: await sync_command_panel(api) + + # 平台发现也跟着连接走:每次 READY 拉一次 /sources(TTL 缓存内直读), + # 日志报告服务器实际支持的源并校验配置源;重连即重新感知增删 + @ee.on("READY") + async def on_ready_platforms(_: Ready, api: BotApi) -> None: + table = await platforms(http, config) + if not table: + return + names = " ".join(f"{pid}({p.get('name') or pid})" for pid, p in table.items()) + logger.info(f"MusicServer 平台:{names}") + if config.music_source and config.music_source not in table: + logger.warning( + f"配置源 {config.music_source} 不在上述平台里,点歌/歌词会报错" + ) diff --git a/tests/test_music.py b/tests/test_music.py index 44d1ca7..e38aafb 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -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 diff --git a/uv.lock b/uv.lock index 127c201..6fd3f68 100644 --- a/uv.lock +++ b/uv.lock @@ -698,7 +698,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "ujson" }, ] -sdist = { hash = "sha256:59960044772157383d1fba3506531e34f89102d17fcad9a14a99873566b6c526" } +sdist = { hash = "sha256:162be4706f256ccee5e371232d591776ce0997cb5481f61554a9b16e1b569bf5" } [package.metadata] requires-dist = [