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

+168 -34
View File
@@ -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:<uuid>"),用 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} 不在上述平台里,点歌/歌词会报错"
)