按钮回调防抖与先应答: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
+168
-34
@@ -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} 不在上述平台里,点歌/歌词会报错"
|
||||
)
|
||||
Reference in new issue
Block a user