按钮回调防抖与先应答: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` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
- **直链候选源回退**: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`(本仓库已开启)
+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} 不在上述平台里,点歌/歌词会报错"
)
+149 -5
View File
@@ -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
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "ujson" },
]
sdist = { hash = "sha256:59960044772157383d1fba3506531e34f89102d17fcad9a14a99873566b6c526" }
sdist = { hash = "sha256:162be4706f256ccee5e371232d591776ce0997cb5481f61554a9b16e1b569bf5" }
[package.metadata]
requires-dist = [