按钮回调防抖与先应答: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

+1
View File
@@ -10,6 +10,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk),
- **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音 - **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 - **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
- **回调防抖与先应答**:按钮回调进门先回应互动(`PUT /interactions`,几秒内不应答 QQ 客户端就报超时),同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;播放/下载先回「正在获取」占位消息再取直链,结果以递增的 `msg_seq` 补发(同一事件相同 `msg_seq` 的第二次回复会被 QQ 拒绝)
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台 - **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启 - **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启) - 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
+168 -34
View File
@@ -4,11 +4,15 @@
点编号 → 弹歌曲卡片(歌名/来源 + [▶播放][⬇下载] 回调按钮),卡片上再点才执行。 点编号 → 弹歌曲卡片(歌名/来源 + [▶播放][⬇下载] 回调按钮),卡片上再点才执行。
播放发语音、下载发文件,失败均回退直链文本。 播放发语音、下载发文件,失败均回退直链文本。
按钮回调进门先回应互动(防 QQ 客户端等超时),同一互动 uuid / 同群同按钮
10 秒内只处理一次(防抖);播放/下载先回「正在获取」占位,结果再补发。
自动撤回(翻页撤旧菜单、选歌后撤菜单、播放/下载后撤卡片)由 WITHDRAW 自动撤回(翻页撤旧菜单、选歌后撤菜单、播放/下载后撤卡片)由 WITHDRAW
开关统一控制,默认开。""" 开关统一控制,默认开。"""
import re import re
import time import time
import weakref
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import quote, unquote from urllib.parse import quote, unquote
@@ -47,8 +51,9 @@ _HELP = (
"输入框敲 / 也能唤出这些指令" "输入框敲 / 也能唤出这些指令"
) )
# MusicServer(lx-music 聚合源)实际支持的四个平台,没有 mg(咪咕)
_SOURCE_NAMES = { _SOURCE_NAMES = {
"tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕", "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云",
} }
# 内嵌键盘上限:5 行、每行 5 个按钮;歌曲占行 + 1 行翻页 → 每页最多 20 首 # 内嵌键盘上限:5 行、每行 5 个按钮;歌曲占行 + 1 行翻页 → 每页最多 20 首
@@ -84,20 +89,27 @@ _seen_messages: dict[str, float] = {}
_DEDUP_TTL = 300.0 # 秒;被动回复时效是分钟级,重复推送不会来得更晚 _DEDUP_TTL = 300.0 # 秒;被动回复时效是分钟级,重复推送不会来得更晚
_DEDUP_MAX = 1024 # 条;字典按插入序,超限淘汰最旧 _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:
"""剪掉过期记录(按插入序,头部最旧)。""" def _prune(table: dict[str, float], ttl: float, now: float) -> None:
while _seen_messages: """剪掉 table 里过期记录(按插入序,头部最旧)。"""
oldest = next(iter(_seen_messages)) while table:
if now - _seen_messages[oldest] < _DEDUP_TTL: oldest = next(iter(table))
if now - table[oldest] < ttl:
break break
del _seen_messages[oldest] del table[oldest]
def _mark(message_id: str) -> None: def _mark(message_id: str) -> None:
"""记下一个 msg_id(已存在则只刷新时间),超出上限时淘汰最旧。""" """记下一个 msg_id(已存在则只刷新时间),超出上限时淘汰最旧。"""
now = time.monotonic() now = time.monotonic()
_prune(now) _prune(_seen_messages, _DEDUP_TTL, now)
_seen_messages[message_id] = now _seen_messages[message_id] = now
if len(_seen_messages) > _DEDUP_MAX: if len(_seen_messages) > _DEDUP_MAX:
del _seen_messages[next(iter(_seen_messages))] del _seen_messages[next(iter(_seen_messages))]
@@ -105,13 +117,25 @@ def _mark(message_id: str) -> None:
def _handled(message_id: str) -> bool: def _handled(message_id: str) -> bool:
"""这个 msg_id 近期是否已处理过;否者记下并返回 False。""" """这个 msg_id 近期是否已处理过;否者记下并返回 False。"""
_prune(time.monotonic()) _prune(_seen_messages, _DEDUP_TTL, time.monotonic())
if message_id in _seen_messages: if message_id in _seen_messages:
return True return True
_mark(message_id) _mark(message_id)
return False 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: def _sent_id(resp: Any) -> str | None:
"""从发送响应取消息 id(官方字段是 id,部分接口用 message_id)。""" """从发送响应取消息 id(官方字段是 id,部分接口用 message_id)。"""
if isinstance(resp, dict): if isinstance(resp, dict):
@@ -248,29 +272,43 @@ async def _reply(
content: str, content: str,
msg_id: str | None = None, msg_id: str | None = None,
event_id: str | None = None, event_id: str | None = None,
msg_seq: int = 1,
) -> None: ) -> None:
if event_id: if event_id:
resp = await api.post_group_message( 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: else:
resp = await api.post_group_message( 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): if sent := _sent_id(resp):
_mark(sent) # 自己的消息若被回推,按重复跳过 _mark(sent) # 自己的消息若被回推,按重复跳过
async def _soft_reply( 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: ) -> None:
"""尽力回复:失败(如互动 event_id 失效)仅记日志,让按钮回应照常进行。""" """尽力回复:失败(如互动 event_id 失效)仅记日志,让按钮回应照常进行。"""
try: 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: except _API_FAILS as e:
logger.warning(f"回复失败: {e!r}") 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: async def _withdraw(api: BotApi, group_openid: str, message_id: str) -> None:
"""撤回 bot 自己发的旧消息(超时限/无权限等失败仅记日志)。""" """撤回 bot 自己发的旧消息(超时限/无权限等失败仅记日志)。"""
try: try:
@@ -495,6 +533,7 @@ async def send_song(
voice: bool, voice: bool,
msg_id: str | None = None, msg_id: str | None = None,
event_id: str | None = None, event_id: str | None = None,
msg_seq: int = 1,
) -> None: ) -> None:
"""取可抓取的播放直链后发送。 """取可抓取的播放直链后发送。
@@ -505,13 +544,17 @@ async def send_song(
配置源直链抓不动时 QQ 服务器侧下载同样会失败(40093007),故先探测 配置源直链抓不动时 QQ 服务器侧下载同样会失败(40093007),故先探测
可抓性、必要时换候选源;keyword 用于候选源搜索。 可抓性、必要时换候选源;keyword 用于候选源搜索。
msg_seq 是本事件被动回复的序号:调用方若已先回过占位消息,须传 2
(同一 msg_id/event_id 相同 msg_seq 的第二次回复会被 QQ 拒绝)。
卡片撤回不在这里做:调用方 handle_button 会把这次点击当成一次性操作, 卡片撤回不在这里做:调用方 handle_button 会把这次点击当成一次性操作,
无论成败都撤回卡片。""" 无论成败都撤回卡片。"""
found = await _fetchable_play(http, config, song, keyword) found = await _fetchable_play(http, config, song, keyword)
if found is None: if found is None:
logger.warning(f"[{group_openid}] 无可用直链:{song.name}") logger.warning(f"[{group_openid}] 无可用直链:{song.name}")
await _reply( 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 return
_, url = found _, url = found
@@ -527,6 +570,7 @@ async def send_song(
content=_caption(song), content=_caption(song),
msg_id=msg_id, msg_id=msg_id,
event_id=event_id, event_id=event_id,
msg_seq=msg_seq,
media=upload, media=upload,
) )
if sent := _sent_id(resp): if sent := _sent_id(resp):
@@ -544,7 +588,7 @@ async def send_song(
if no_permission: if no_permission:
text = f"{_caption(song)}\n(本群文件上传仅限群主/管理员,先给直链)\n{url}" text = f"{_caption(song)}\n(本群文件上传仅限群主/管理员,先给直链)\n{url}"
try: 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)}") logger.info(f"[{group_openid}] 直链已发:{_caption(song)}")
except _API_FAILS as e: except _API_FAILS as e:
# 被动回复被拒(如互动 event_id 超时效)→ 降级为主动消息 # 被动回复被拒(如互动 event_id 超时效)→ 降级为主动消息
@@ -617,8 +661,62 @@ async def _search_source(
return None return None
# 直链的候选回退源:tx 等源直链常受 CDN 风控抓不动,kw 实测可抓 # 直链回退源的优先顺序:tx 等源直链常受 CDN 风控抓不动,kw 实测可抓。
_DOWNLOAD_FALLBACK_SOURCES = ("kw", "wy", "mg") # 实际候选取 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( async def _fetchable_play(
@@ -627,9 +725,10 @@ async def _fetchable_play(
song: Song, song: Song,
keyword: str, keyword: str,
) -> tuple[Song, str] | None: ) -> tuple[Song, str] | None:
"""逐候选源取可直接抓取的播放直链:先原曲,抓不动再按 kw/wy/mg """逐候选源取可直接抓取的播放直链:先原曲,抓不动再按候选源
逐个搜索探测(懒探测:原曲可用时不产生候选源搜索)。全失败返回 None。""" (/sources 动态解析,kw 优先)逐个搜索探测(懒探测:原曲可用时
fallbacks = iter(s for s in _DOWNLOAD_FALLBACK_SOURCES if s != song.source) 不产生候选源搜索)。全失败返回 None。"""
fallbacks = iter(await _fallback_sources(http, config, song))
cand: Song | None = song cand: Song | None = song
while cand is not None: while cand is not None:
try: try:
@@ -664,50 +763,71 @@ async def handle_button(
interaction_id 仅用于回应互动(PUT /interactions);消息被动回复的 interaction_id 仅用于回应互动(PUT /interactions);消息被动回复的
event_id 须用事件最外层 id("INTERACTION_CREATE:<uuid>"),用 d.id event_id 须用事件最外层 id("INTERACTION_CREATE:<uuid>"),用 d.id
裸 uuid 会被 QQ 判为无效(40034025)。""" 裸 uuid 会被 QQ 判为无效(40034025)。
进门先应答互动再干活(几秒内不应答客户端就报超时);play/dl 取直链
可能要几十秒,先回一条「正在获取」占位,结果以 msg_seq=2 补发。同一
互动 uuid(重复推送)或同群同按钮 data(连点)在防抖窗口内只处理
第一次。"""
parts = data.split("|") parts = data.split("|")
action, args = (parts[1] if len(parts) > 1 else ""), parts[2:] action, args = (parts[1] if len(parts) > 1 else ""), parts[2:]
# 歌曲按钮的 args 为 [source, song_id, name, singer](+ img,后加故兼容 4 段) # 歌曲按钮的 args 为 [source, song_id, name, singer](+ img,后加故兼容 4 段)
is_song = action in ("pick", "play", "dl") and len(args) in (4, 5) 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: try:
if action == "page" and len(args) == 2: if action == "page":
keyword, page = unquote(args[0]), int(args[1]) keyword, page = unquote(args[0]), int(args[1])
await send_menu( await send_menu(
api, http, config, group_openid, keyword, page, api, http, config, group_openid, keyword, page,
event_id=event_id, event_id=event_id,
) )
elif action == "pick" and is_song: elif action == "pick":
await send_card( await send_card(
api, http, config, group_openid, _song_of_data(args), api, http, config, group_openid, _song_of_data(args),
event_id=event_id, event_id=event_id,
) )
elif action in ("play", "dl") and is_song: else: # play / dl(进门已校验 is_song)
song = _song_of_data(args) song = _song_of_data(args)
try: try:
await send_song( await send_song(
api, http, config, group_openid, song, api, http, config, group_openid, song,
keyword=f"{song.name} {song.singer}".strip(), 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: finally:
# 卡片是一次性 UI:这次点击已被消费,无论成败都撤回 # 卡片是一次性 UI:这次点击已被消费,无论成败都撤回
# (放 finally 才能覆盖 send_song 抛错,如候选源搜索失败) # (放 finally 才能覆盖 send_song 抛错,如候选源搜索失败)
await _retire(api, config, group_openid) await _retire(api, config, group_openid)
else:
return False # 非本 bot 的按钮
except (MusicServerError, ValueError) as e: except (MusicServerError, ValueError) as e:
logger.warning(f"按钮处理失败: {e}") logger.warning(f"按钮处理失败: {e}")
await _soft_reply( await _soft_reply(
api, group_openid, "音乐服务暂时不可用,稍后再试试", event_id api, group_openid, "音乐服务暂时不可用,稍后再试试", event_id, seq
) )
except _API_FAILS as e: except _API_FAILS as e:
logger.warning(f"按钮处理失败: {e!r}") 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 return True
@@ -840,3 +960,17 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None:
@ee.on("READY") @ee.on("READY")
async def on_ready(_: Ready, api: BotApi) -> None: async def on_ready(_: Ready, api: BotApi) -> None:
await sync_command_panel(api) 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} 不在上述平台里,点歌/歌词会报错"
)
+149 -5
View File
@@ -80,8 +80,15 @@ class FakeMusicServer:
路由,模拟 CDN 风控(测直链探测与候选源回退)。 路由,模拟 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.tx_denied = tx_denied
self.serve_sources = serve_sources
self.no_url_sources = no_url_sources
self.runner: web.AppRunner | None = None self.runner: web.AppRunner | None = None
self.port = 0 self.port = 0
self.queries: list[dict] = [] self.queries: list[dict] = []
@@ -91,6 +98,8 @@ class FakeMusicServer:
app.router.add_get("/search", self._search) app.router.add_get("/search", self._search)
app.router.add_get("/url/{source}/{song_id}", self._url) app.router.add_get("/url/{source}/{song_id}", self._url)
app.router.add_get("/lyric/{source}/{song_id}", self._lyric) 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("/media/{tail:.+}", self._media)
app.router.add_get("/denied/{tail:.+}", self._denied) app.router.add_get("/denied/{tail:.+}", self._denied)
self.runner = web.AppRunner(app) self.runner = web.AppRunner(app)
@@ -154,6 +163,25 @@ class FakeMusicServer:
async def _denied(self, _: web.Request) -> web.Response: async def _denied(self, _: web.Request) -> web.Response:
return web.Response(status=403, text="forbidden") 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: 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" 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}}) return web.json_response({"code": 0, "data": {"lyric": lyric}})
@@ -180,12 +208,14 @@ async def env(server) -> AsyncIterator[tuple]:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_menu_state(): def _reset_menu_state():
"""菜单/卡片消息记录与消息去重表是模块级状态,测试间清空避免串扰。""" """菜单/卡片消息记录与消息/回调去重表是模块级状态,测试间清空避免串扰。"""
music._last_msg.clear() music._last_msg.clear()
music._seen_messages.clear() music._seen_messages.clear()
music._recent_clicks.clear()
yield yield
music._last_msg.clear() music._last_msg.clear()
music._seen_messages.clear() music._seen_messages.clear()
music._recent_clicks.clear()
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义) # 回调按钮 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"]] == [ assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
PLAY_TX, DL_TX, PLAY_TX, DL_TX,
] ]
# 已回应互动 # 进门先回应互动(避免 QQ 客户端等超时),再发卡片
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1") 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): 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 直链 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): async def test_page_button_new_menu(env):
http, config = env http, config = env
api, calls = make_api() api, calls = make_api()
@@ -566,13 +659,64 @@ async def test_page_button_new_menu(env):
body = posts(calls)[0]["json"] body = posts(calls)[0]["json"]
assert body["msg_type"] == 2 # 翻页后发新菜单 assert body["msg_type"] == 2 # 翻页后发新菜单
assert "点歌:稻香" in body["markdown"]["content"] 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() api, calls = make_api()
assert not await handle_button(api, http, config, "G1", "I1", "别的插件|xx") assert not await handle_button(api, http, config, "G1", "I1", "别的插件|xx")
assert calls == [] # 非音乐按钮不处理也不回应 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): async def test_slash_commands_share_text_parsing(env):
"""斜杠指令与裸文本命令同一套解析:剥掉开头的 / 与 @机器人 前缀。""" """斜杠指令与裸文本命令同一套解析:剥掉开头的 / 与 @机器人 前缀。"""
http, config = env http, config = env
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "ujson" }, { name = "ujson" },
] ]
sdist = { hash = "sha256:59960044772157383d1fba3506531e34f89102d17fcad9a14a99873566b6c526" } sdist = { hash = "sha256:162be4706f256ccee5e371232d591776ce0997cb5481f61554a9b16e1b569bf5" }
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [