按钮回调防抖与先应答: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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in new issue
Block a user