Files
MusicBot/tests/test_music.py
T
snowy-yang bb60137ac7
代码测试 / ruff + pytest (push) Canceled after 0s
fix: B站点歌无反应——songmid自带竖线拆散按钮回调data
bili 的 songmid 形如「BV号|cid」,与回调 data 的分隔符撞车:拆段
从 5 变 6,handle_button 判为非本 bot 按钮直接丢弃,且发生在回应
互动之前——客户端按钮一直转圈、日志无任何记录,表象即「点歌卡死」。

_song_data 给歌曲 id 一并 % 转义(_q),_song_of_data 对应 unquote;
旧按钮 id 不含 %,unquote 幂等不受影响。附 bili 往返回归测试。
2026-10-04 17:54:24 +08:00

1898 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""点歌业务测试:假 MusicServer + Recording BotApi,覆盖选歌菜单与分页、
歌曲卡片、按钮回调、默认 tx 源与聚合兜底、直链候选源回退、文件代理、
错误路径。"""
from collections.abc import AsyncIterator
from dataclasses import replace
from pathlib import Path
from typing import Any
from urllib.parse import quote
import pytest
from aiohttp import ClientSession, web
from qqbotsdk import EventEmitter
from qqbotsdk.api import ApiError, BotApi
from musicbot import music, proxy
from musicbot.config import BotConfig
from musicbot.music import (
handle_button,
handle_command,
register,
search,
send_menu,
)
from musicbot.panel import sync_command_panel
from musicbot.proxy import FileProxy
def make_api(
fail_types: set[int] | None = None,
fail_md: bool = False,
fail_event: bool = False,
fail_upload: bool = False,
fail_file_perm: bool = False,
fail_keyboard: bool = False,
):
"""替换 upload_group_file / Markdown 消息以模拟失败,request 记录其余调用。
fail_event=True 时拒绝一切带 event_id 的被动回复(模拟互动回执失效);
fail_upload=True 时上传一律抛 TimeoutError(模拟 QQ 侧抓流超过请求超时);
fail_file_perm=True 时 file_type=4(文件)返回 40093005(仅群主/管理员可传);
fail_keyboard=True 时拒绝一切带 keyboard 的消息(模拟键盘无权限)。
"""
calls: list[dict] = []
class Recording(BotApi):
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
body = kwargs.get("json", {})
if fail_md and body.get("msg_type") == 2:
raise ApiError(400, 304036, "无 Markdown 权限")
if fail_keyboard and "keyboard" in body:
raise ApiError(400, 304003, "无键盘权限")
if fail_event and "event_id" in body:
raise ApiError(400, 40034025, "请求参数event_id无效")
if path.endswith("/files"):
if fail_upload:
raise TimeoutError()
if fail_file_perm and body.get("file_type") == 4:
raise ApiError(400, 40093005, "仅允许群主和管理员上传文件")
if fail_types and body.get("file_type") in fail_types:
raise ApiError(400, "40054002", "上传失败")
calls.append(
{
"method": method,
"path": path,
"file_type": body.get("file_type"),
"url": body.get("url"),
"timeout": kwargs.get("timeout"),
}
)
return {"file_info": f"f{body.get('file_type')}"}
calls.append({"method": method, "path": path, **kwargs})
return {"id": "MSG"} if path.endswith("/messages") else {}
return Recording.__new__(Recording), calls
class FakeMusicServer:
"""模拟 MusicServer 的统一信封接口(路径式 /search/{source}/{keyword}),
记录搜索/榜单请求参数。
/url 返回指向自身的直链;tx_denied=True 时 tx 的直链指向 403
路由,模拟 CDN 风控(测直链探测与候选源回退)。
"""
def __init__(
self,
tx_denied: bool = False,
serve_sources: bool = True,
no_url_sources: frozenset[str] = frozenset(),
extra_platforms: tuple[dict, ...] = (),
album_total: int = 23,
) -> None:
self.tx_denied = tx_denied
self.serve_sources = serve_sources
self.no_url_sources = no_url_sources
self.extra_platforms = extra_platforms
self.album_total = album_total
self.album_empty_ids: frozenset[str] = frozenset() # 已下线的节目
self.runner: web.AppRunner | None = None
self.port = 0
self.queries: list[dict] = []
self.board_queries: list[dict] = []
self.album_queries: list[dict] = []
async def start(self) -> None:
app = web.Application()
app.router.add_get("/search/{source}/{keyword}/{page}", self._search)
app.router.add_get("/search/{source}/{keyword}", self._search)
app.router.add_get("/url/{source}/{song_id}", self._url)
app.router.add_get("/lyric/{source}/{song_id}", self._lyric)
app.router.add_get("/hot-search/{source}", self._hot)
app.router.add_get("/boards/{source}/{board_id}/{page}", self._board_detail)
app.router.add_get("/boards/{source}", self._boards)
app.router.add_get("/album/{source}/{album_id}/{page}", self._album)
app.router.add_get("/album/{source}/{album_id}", self._album)
app.router.add_get("/login/status", self._login_status)
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)
await self.runner.setup()
site = web.TCPSite(self.runner, "127.0.0.1", 0)
await site.start()
# sockets 未列入 aiohttp 的类型存根,只能从私有属性取实际端口
assert site._server is not None
self.port = site._server.sockets[0].getsockname()[1] # type: ignore[attr-defined]
async def stop(self) -> None:
assert self.runner is not None
await self.runner.cleanup()
async def _search(self, request: web.Request) -> web.Response:
info = request.match_info
source, keyword = info["source"], info["keyword"]
self.queries.append({
"keyword": keyword,
"source": source,
"page": info.get("page", "1"),
"limit": request.query.get("limit"),
})
items: list[dict] = []
if "空" not in keyword:
if "兜底" in keyword:
# tx 搜不到、聚合(all)有 —— 测兜底路径
items = [] if source == "tx" else [
{"source": "kw", "songmid": "S1",
"name": keyword, "singer": "歌手A"},
]
elif source == "kw":
items = [
{"source": "kw", "songmid": "S1",
"name": keyword, "singer": "歌手A"},
]
elif source == "kg":
items = [
# 不带 source 字段:真机 kg 带、xm/me/qt/lr 不带,
# 这里顺带覆盖 _songs_of 的请求级补源
{"songmid": "332", "hash": "G1",
"name": keyword, "singer": "歌手G"}
]
elif source in ("tx", "all"):
items = [
{
"source": "tx",
"songmid": "T1",
"name": keyword,
"singer": "歌手T",
"albumName": "专辑T",
"interval": "04:00",
"img": "https://img.test/cover.jpg",
},
{"source": "kw", "songmid": "S1",
"name": keyword, "singer": "歌手A"},
]
return web.json_response({"code": 0, "data": {"list": items}})
async def _hot(self, request: web.Request) -> web.Response:
return web.json_response({
"code": 0,
"data": {
"source": request.match_info["source"],
"list": ["热词一", "热词二", "热词三"],
},
})
async def _boards(self, request: web.Request) -> web.Response:
source = request.match_info["source"]
return web.json_response({
"code": 0,
"data": {"list": [
{"id": f"{source}__93", "name": "飙升榜", "bangid": "93"},
{"id": f"{source}__16", "name": "热歌榜", "bangid": "16"},
]},
})
async def _board_detail(self, request: web.Request) -> web.Response:
info = request.match_info
limit = request.query.get("limit", "10")
self.board_queries.append({
"board": info["board_id"], "page": info["page"], "limit": limit,
})
page = int(info["page"])
songs = [
{
"source": "tx",
"songmid": f"{info['board_id']}-P{page}-{i}",
"name": f"榜歌{i}",
"singer": f"歌手{i}",
"interval": "03:00",
"img": "https://img.test/board.jpg",
}
for i in range(1, int(limit) + 1)
]
return web.json_response({
"code": 0,
"data": {"list": songs, "total": 30, "page": page, "limit": int(limit)},
})
async def _album(self, request: web.Request) -> web.Response:
"""选集接口,仿真真机差异:me 忽略 page 参数、一次给足 limit;
lr/qt 按服务端翻页切片。album_empty_ids 里的节目给空列表(已下线)。"""
info = request.match_info
source, album_id = info["source"], info["album_id"]
page = int(info.get("page", 1))
limit = int(request.query.get("limit", 20))
self.album_queries.append({
"source": source, "album_id": album_id, "page": page, "limit": limit,
})
if album_id in self.album_empty_ids:
return web.json_response({"code": 0, "data": {"list": [], "total": 0}})
eps = [
{
"songmid": f"{album_id}-{i}",
"albumId": album_id,
"name": f"第{i}期",
"singer": "节目名",
"interval": "0",
"img": "https://img.test/ep.jpg",
}
for i in range(1, self.album_total + 1)
]
if source == "me":
window = eps[:limit]
else:
window = eps[(page - 1) * limit: page * limit]
return web.json_response({
"code": 0, "data": {"list": window, "total": self.album_total},
})
async def _login_status(self, _: web.Request) -> web.Response:
return web.json_response({
"code": 0,
"data": {
"kg": {"logged": True, "nickname": "测试号"},
"tx": {"logged": False},
},
})
async def _url(self, request: web.Request) -> web.Response:
info = request.match_info
prefix = "denied" if info["source"] == "tx" and self.tx_denied else "media"
return web.json_response(
{
"code": 0,
"data": {
"type": "128k",
"url": f"http://127.0.0.1:{self.port}/{prefix}/{info['source']}/{info['song_id']}.mp3",
},
}
)
async def _media(self, _: web.Request) -> web.Response:
return web.Response(body=b"fake-audio-bytes")
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,
"hotSearch": pid in ("kw", "kg", "tx", "wy"),
"boards": pid in ("kw", "kg", "tx", "wy"),
},
}
for pid, name in (
("kw", "酷我音乐"),
("kg", "酷狗音乐"),
("tx", "QQ音乐"),
("wy", "网易音乐"),
("qs", "汽水音乐"),
)
]
platforms.extend(self.extra_platforms)
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}})
@pytest.fixture
async def server():
s = FakeMusicServer()
await s.start()
yield s
await s.stop()
@pytest.fixture
async def env(server) -> AsyncIterator[tuple]:
async with ClientSession() as http:
config = BotConfig(
music_server_url=f"http://127.0.0.1:{server.port}",
music_quality="128k",
music_source="tx",
)
yield http, config
@pytest.fixture(autouse=True)
def _reset_menu_state():
"""菜单/卡片消息记录、消息/回调/跨事件去重表、平台缓存、下载别名与
文件代理是模块级状态,测试间清空/复位避免串扰。"""
tables = (music._seen_messages, music._recent_clicks, music._msg_dup_keys)
def reset() -> None:
music._last_msg.clear()
for t in tables:
t.clear()
music._file_proxy = None
music._platforms_cache.clear()
music._apply_aliases(music._STATIC_PLATFORMS)
reset()
yield
reset()
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义)
IMG_URL = "https://img.test/cover.jpg"
PICK_1 = music._song_data("pick", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
PLAY_TX = music._song_data("play", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
DL_TX = music._song_data("dl", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
# 旧版按钮 data(无封面段)—— 兼容性用例
PICK_1_LEGACY = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
def posts(calls: list[dict]) -> list[dict]:
return [c for c in calls if c.get("path", "").endswith("/messages")]
def buttons(body: dict) -> list[dict]:
"""键盘的行列表;每行是 {"buttons": [...]}。"""
return body["keyboard"]["content"]["rows"]
def labels(body: dict) -> list[list[str]]:
return [[b["render_data"]["label"] for b in r["buttons"]] for r in buttons(body)]
async def test_search_prefers_tx_and_falls_back(server, env):
http, config = env
song = await search(http, config, "稻香")
assert song is not None and (song.source, song.song_id) == ("tx", "T1")
assert server.queries == [
{"keyword": "稻香", "source": "tx", "page": "1", "limit": "5"}
]
server.queries.clear()
fallback = await search(http, config, "兜底")
assert fallback is not None
assert (fallback.source, fallback.song_id) == ("kw", "S1") # tx 没有结果 → 聚合兜底
assert [q["source"] for q in server.queries] == ["tx", "all"]
async def test_menu_only_selects_songs(env):
"""搜索菜单只负责选歌:编号回调按钮,不放播放/下载、除封面图外不放链接、
不带音源。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2
md = body["markdown"]["content"]
assert "## 点歌:稻香" in md
assert "1. 稻香 - 歌手T" in md
assert "QQ音乐" not in md and "酷我" not in md # 每首歌后面不再标音源
# 有封面的条目(tx)编号行下跟一张小封面图,无封面的(kw)不加
assert f"1. 稻香 - 歌手T\n![封面 #80px #80px]({IMG_URL})" in md
assert md.count("![") == 1
# 菜单里除封面图外没有任何链接
assert md.replace(IMG_URL, "").count("http") == 0
# 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲(含封面)
rows = buttons(body)
assert [b["render_data"]["label"] for b in rows[0]["buttons"]] == ["1", "2"]
assert [b["action"]["type"] for b in rows[0]["buttons"]] == [1, 1]
assert rows[0]["buttons"][0]["action"]["data"] == PICK_1
# 首页无上一页,本页未满一页(2 首 < 10)→ 也不给下一页
assert labels(body)[-1] == ["1", "2"]
async def test_menu_pagination_buttons(env):
"""满页给「下一页」,非首页给「上一页」,data 带页码与关键词。"""
http, config = env
api, calls = make_api()
full = replace(config, menu_page_size=2)
await send_menu(api, http, full, "G1", "稻香", page=2)
body = posts(calls)[0]["json"]
nav = [b["render_data"]["label"] for b in buttons(body)[-1]["buttons"]]
assert nav == ["上一页", "下一页"]
data = [b["action"]["data"] for b in buttons(body)[-1]["buttons"]]
assert data[0] == f"音乐|page|{quote('稻香', safe='')}|1"
assert data[1] == f"音乐|page|{quote('稻香', safe='')}|3"
async def test_menu_page_size_row_layout(env):
"""每行最多 5 个按钮,行数不超过键盘上限。"""
http, config = env
api, calls = make_api()
big = replace(config, menu_page_size=20)
await send_menu(api, http, big, "G1", "稻香")
rows = buttons(posts(calls)[0]["json"])
assert len(rows) <= 5
for row in rows[:-1]:
assert 1 <= len(row["buttons"]) <= 5
async def test_menu_withdraws_previous(env):
"""同群出新菜单时撤回上一张菜单;不同群互不影响。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
assert await handle_command(api, http, config, "G2", "m2", "点歌 稻香")
assert not [c for c in calls if c["method"] == "DELETE"]
assert await handle_command(api, http, config, "G1", "m3", "点歌 稻香")
assert [c["path"] for c in calls if c["method"] == "DELETE"] == [
"/v2/groups/G1/messages/MSG"
]
async def test_menu_fallback_keeps_keyboard(env):
http, config = env
api, calls = make_api(fail_md=True)
await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
sent = posts(calls)
assert len(sent) == 1 # Markdown 失败后回退文本 + 按钮仍带上
body = sent[0]["json"]
assert body["msg_type"] == 0
assert "稻香 - 歌手T" in body["content"]
assert "(点下方编号选歌)" in body["content"]
assert "![" not in body["content"] # 回退文本不带图片语法
assert "keyboard" in body
async def test_pick_button_sends_card(env):
"""点编号 → 弹歌曲卡片:封面图 + 歌名/来源 + [▶播放][⬇下载] 回调按钮。
卡片由回调 data 还原(自包含 song 字段),故不含专辑/时长等菜单外信息。"""
http, config = env
api, calls = make_api()
await handle_button(
api, http, config, "G1", "I1", PICK_1, event_id="INTERACTION_CREATE:I1"
)
card = posts(calls)[0]["json"]
assert card["msg_type"] == 2
md = card["markdown"]["content"]
assert "## 稻香 - 歌手T" in md
assert "![封面 #240px #240px](https://img.test/cover.jpg)" in md # 专辑封面
assert "来源:QQ音乐" in md # 卡片保留音源
assert labels(card) == [["▶ 播放", "⬇ 下载"]]
assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1]
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
PLAY_TX, DL_TX,
]
# 进门先回应互动(避免 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):
"""无封面源的歌曲(如 kw):卡片不含图片,其余照常。"""
http, config = env
api, calls = make_api()
no_img = "音乐|pick|kw|S1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BA|"
await handle_button(api, http, config, "G1", "I1", no_img)
md = posts(calls)[0]["json"]["markdown"]["content"]
assert "![封面" not in md
assert "## 稻香 - 歌手A" in md
async def test_card_for_qs_song_shows_source_and_cover(env):
"""qs(汽水音乐)歌曲:来源显示「汽水音乐」,qs 搜索带封面 → 卡片带图。"""
http, config = env
api, calls = make_api()
cover = "https://p3-luna.douyinpic.com/img/x~c5_500x500.jpg"
data = music._song_data(
"pick", music.Song("qs", "7679694801201334312", "稻香", "歌手Q", img=cover)
)
await handle_button(api, http, config, "G1", "I1", data)
md = posts(calls)[0]["json"]["markdown"]["content"]
assert "## 稻香 - 歌手Q" in md
assert "来源:汽水音乐" in md
assert f"![封面 #240px #240px]({cover})" in md
async def test_bili_songmid_pipe_round_trips(env):
"""bili 的 songmid 是「BV号|cid」,自带分隔符:按钮 data 里的 id 必须
% 转义,否则拆段变 6 段、整个回调被当成非本 bot 按钮静默丢弃(点歌
无反应)。转义后 pick 正常弹卡片,播放/下载按钮携带完整 id。"""
http, config = env
api, calls = make_api()
data = music._song_data(
"pick",
music.Song("bili", "BV1vg2AYdE4J|26216173455", "听", "UP主"),
)
assert data.count("|") == 6 # 7 段:头+action+source+id+name+singer+img
await handle_button(api, http, config, "G1", "I1", data)
card = posts(calls)[0]["json"]
assert "## 听 - UP主" in card["markdown"]["content"]
assert "来源:哔哩哔哩" in card["markdown"]["content"]
datas = [b["action"]["data"] for b in buttons(card)[0]["buttons"]]
assert "BV1vg2AYdE4J%7C26216173455" in datas[0] # ▶ 播放带完整 id
async def test_card_cover_url_is_encoded(env):
"""卡片封面 URL 走标准百分号编码:空格/中文转义、Markdown 语法里的
括号也要转(否则会截断 ![]() 链接),查询串结构保留。"""
http, config = env
api, calls = make_api()
img = "https://img.test/封面 图(1).jpg?q=1&x=2"
data = music._song_data("pick", music.Song("kw", "S1", "稻香", "歌手A", img=img))
await handle_button(api, http, config, "G1", "I1", data)
md = posts(calls)[0]["json"]["markdown"]["content"]
assert (
"![封面 #240px #240px]"
"(https://img.test/%E5%B0%81%E9%9D%A2%20%E5%9B%BE%281%29.jpg?q=1&x=2)"
) in md
async def test_direct_link_url_is_encoded(env):
"""源站直链带空格/中文时,发出的文本与上传 URL 都是标准编码形态。"""
http, config = env
encoded = "/media/kw/%E7%A8%BB%20%E9%A6%99(1).mp3" # 稻 香(1)
weird = music._song_data("dl", music.Song("kw", "稻 香(1)", "稻香", "歌手A"))
api, calls = make_api() # link 模式:直链文本
await handle_button(api, http, config, "G1", "I1", weird)
assert encoded in posts(calls)[-1]["json"]["content"]
api, calls = make_api() # file 模式:上传给 QQ 的 url 同样编码
music._recent_clicks.clear() # 同一按钮 data,避开连点防抖
await handle_button(api, http, replace(config, send_mode="file"), "G1", "I2", weird)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["url"].endswith(encoded)
def test_encode_url_standard_and_idempotent():
"""URL 统一标准编码:只转义非法字符(空格/中文/引号),结构字符与已有
%XX 原样保留,重复调用结果一致(不二次编码)。"""
raw = 'https://cdn.test/目录/歌 曲(1)"x".mp3?a=1&b=x y#片段'
enc = (
"https://cdn.test/%E7%9B%AE%E5%BD%95/%E6%AD%8C%20%E6%9B%B2(1)%22x%22"
".mp3?a=1&b=x%20y#%E7%89%87%E6%AE%B5"
)
assert music._encode_url(raw) == enc
assert music._encode_url(enc) == enc # 幂等
md = music._encode_url("https://img.test/a b(1).jpg", music._MD_URL_SAFE)
assert md == "https://img.test/a%20b%281%29.jpg"
async def test_legacy_button_data_without_img(env):
"""旧版 4 段 data 仍能处理(封面缺省),保证已发出的菜单按钮不失效。"""
http, config = env
api, calls = make_api()
assert await handle_button(api, http, config, "G1", "I1", PICK_1_LEGACY)
md = posts(calls)[0]["json"]["markdown"]["content"]
assert "## 稻香 - 歌手T" in md
assert "![封面" not in md
async def test_card_play_button_sends_voice(env):
"""卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。"""
http, config = env
api, calls = make_api()
await handle_button(
api, http, config, "G1", "I2", PLAY_TX, event_id="INTERACTION_CREATE:I2"
)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 3
assert upload["url"].endswith("/media/tx/T1.mp3")
assert upload["timeout"].total == 120 # 上传放宽超时:QQ 侧抓流慢
body = posts(calls)[-1]["json"]
assert body["msg_type"] == 7
assert body["event_id"] == "INTERACTION_CREATE:I2"
async def test_card_dl_button_sends_file(env):
"""SEND_MODE=file 时下载上传群文件(file_type=4)。"""
http, config = env
api, calls = make_api()
file_cfg = replace(config, send_mode="file")
await handle_button(
api, http, file_cfg, "G1", "I3", DL_TX, event_id="INTERACTION_CREATE:I3"
)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 4
assert posts(calls)[-1]["json"]["msg_type"] == 7
async def test_default_link_mode_skips_upload(env):
"""默认 SEND_MODE=link:下载直接发直链,根本不上传(避开 40093005)。"""
http, config = env
assert config.send_mode == "link"
api, calls = make_api()
await handle_button(
api, http, config, "G1", "I3", DL_TX, event_id="INTERACTION_CREATE:I3"
)
assert not [c for c in calls if c.get("file_type")] # 无上传请求
body = posts(calls)[-1]["json"]
assert body["msg_type"] == 0
assert "/media/tx/T1.mp3" in body["content"]
async def test_pick_swaps_menu_for_card(env):
"""点编号后:撤回旧菜单、发出卡片、记录换成卡片消息 id。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
assert music._last_msg.get("G1") == "MSG" # 菜单消息
await handle_button(api, http, config, "G1", "I1", PICK_1)
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
assert "## 稻香 - 歌手T" in posts(calls)[-1]["json"]["markdown"]["content"]
async def test_play_after_card_withdraws_card(env):
"""卡片上点播放成功后,撤回已完成使命的卡片。"""
http, config = env
api, calls = make_api()
await handle_button(api, http, config, "G1", "I1", PICK_1)
calls.clear()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 3
assert upload["url"].endswith("/media/tx/T1.mp3")
assert posts(calls)[-1]["json"]["msg_type"] == 7
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
assert "G1" not in music._last_msg
async def test_card_withdrawn_even_on_failure(env):
"""卡片是一次性 UI:即使发送回退文本,卡片也要撤回(原实现只在成功时撤)。"""
http, config = env
api, calls = make_api(fail_types={3}) # 语音上传失败 → 回退文本
await handle_button(api, http, config, "G1", "I1", PICK_1)
calls.clear()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
assert "G1" not in music._last_msg
async def test_card_withdrawn_when_play_url_unavailable(env):
"""拿不到播放链接(MusicServer 不可达,send_song 直接 reply 后返回)时
也要撤回卡片。"""
http, config = env
api, calls = make_api()
await handle_button(api, http, config, "G1", "I1", PICK_1)
calls.clear()
broken = replace(config, music_server_url="http://127.0.0.1:1")
await handle_button(api, http, broken, "G1", "I3", DL_TX)
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
async def test_file_upload_permission_denied_falls_back(env):
"""SEND_MODE=file 且群文件上传被拒(40093005)→ 回退直链文本 + 说明,并撤回卡片。"""
http, config = env
api, calls = make_api(fail_file_perm=True)
file_cfg = replace(config, send_mode="file")
await handle_button(api, http, file_cfg, "G1", "I1", PICK_1)
calls.clear()
await handle_button(api, http, file_cfg, "G1", "I3", DL_TX)
body = posts(calls)[-1]["json"]
assert body["msg_type"] == 0
assert "/media/tx/T1.mp3" in body["content"]
assert "仅限群主/管理员" in body["content"]
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
async def test_voice_upload_unaffected_by_file_permission(env):
"""40093005 只针对文件;语音上传不受影响(仍是语音消息)。"""
http, config = env
api, calls = make_api(fail_file_perm=True)
await handle_button(api, http, config, "G1", "I1", PICK_1)
calls.clear()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 3
assert posts(calls)[-1]["json"]["msg_type"] == 7
async def test_withdraw_disabled_keeps_messages(env):
"""WITHDRAW 关闭时不出任何撤回请求(菜单/卡片都留着)。"""
http, config = env
api, calls = make_api()
off = replace(config, withdraw=False)
await send_menu(api, http, off, "G1", "稻香")
await send_menu(api, http, off, "G1", "稻香") # 出新菜单也不撤旧
await handle_button(api, http, off, "G1", "I1", PICK_1)
await handle_button(api, http, off, "G1", "I2", PLAY_TX)
assert not [c for c in calls if c["method"] == "DELETE"]
async def test_play_voice_falls_back_to_text(env):
"""播放只发语音:语音失败直接回退直链文本,不再尝试文件。"""
http, config = env
api, calls = make_api(fail_types={3})
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
body = posts(calls)[-1]["json"]
assert body["msg_type"] == 0
assert "/media/tx/T1.mp3" in body["content"]
async def test_dl_falls_back_to_text(env):
"""SEND_MODE=file 时文件上传失败 → 回退直链文本。"""
http, config = env
api, calls = make_api(fail_types={4})
file_cfg = replace(config, send_mode="file")
await handle_button(api, http, file_cfg, "G1", "I3", DL_TX)
body = posts(calls)[-1]["json"]
assert body["msg_type"] == 0
assert "/media/tx/T1.mp3" in body["content"]
async def test_upload_timeout_falls_back_to_text(env):
"""上传超时(QQ 服务器抓流超过请求超时)→ 回退文本。"""
http, config = env
api, calls = make_api(fail_upload=True)
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
body = posts(calls)[-1]["json"]
assert "/media/tx/T1.mp3" in body["content"]
# ---- 文件代理:登记换链 → 请求时重解析直链 → 流式转发 ----
async def start_file_proxy(http: ClientSession, config: BotConfig) -> FileProxy:
"""起一个绑临时端口的 FileProxy(与 music.register 同款解析接线),
起好后把对外基址改成实际端口,claim 出的 URL 才打得通。"""
p = FileProxy(
"http://127.0.0.1:1",
http,
lambda song, keyword: music._fetchable_play(http, config, song, keyword),
port=0,
)
await p.start()
p._base = f"http://127.0.0.1:{p.port}"
return p
async def test_file_proxy_delivers_and_falls_back(server, env):
"""端到端:claim 换链 → 请求时现场重解析并转发字节。tx 直链被风控
(403)也能交付——解析自带候选源回退落到 kw,正是本功能要解决的场景。"""
http, config = env
server.tx_denied = True # 直链探测 403,逼解析走 kw
p = await start_file_proxy(http, config)
try:
url = p.claim(
music.Song("tx", "T1", "稻香", "歌手T"), "稻香 歌手T", "稻香 - 歌手T.mp3"
)
assert url.startswith(f"http://127.0.0.1:{p.port}/file/")
assert url.endswith("/" + quote("稻香 - 歌手T.mp3")) # 尾段是下载文件名
async with http.get(url) as resp:
assert resp.status == 200
assert await resp.read() == b"fake-audio-bytes" # kw 媒体字节
assert resp.headers["Content-Type"] == "application/octet-stream"
assert resp.content_length == 16 # 上游 Content-Length 透传
assert resp.headers["Content-Disposition"] == (
"attachment; filename*=UTF-8''" + quote("稻香 - 歌手T.mp3")
)
finally:
await p.stop()
async def test_file_proxy_key_lifecycle(server, env, monkeypatch):
"""key 不可猜、会过期;登记总量封顶、按插入序淘汰最旧。"""
http, config = env
p = await start_file_proxy(http, config)
try:
async with http.get(
f"http://127.0.0.1:{p.port}/file/no-such-key/x.mp3"
) as resp:
assert resp.status == 404 # 未知 key
monkeypatch.setattr(proxy, "_TTL", 0.0) # 登记即过期
url = p.claim(music.Song("kw", "S1", "稻香", "歌手A"), "稻香", "a.mp3")
async with http.get(url) as resp:
assert resp.status == 404
monkeypatch.setattr(proxy, "_TTL", 7200.0)
monkeypatch.setattr(proxy, "_MAX_ENTRIES", 2)
first = p.claim(music.Song("kw", "S1", "稻香", "歌手A"), "稻香", "a.mp3")
p.claim(music.Song("kw", "S2", "晴天", "歌手B"), "晴天", "b.mp3")
third = p.claim(music.Song("kw", "S3", "夜曲", "歌手C"), "夜曲", "c.mp3")
async with http.get(first) as resp: # 超量淘汰最旧
assert resp.status == 404
async with http.get(third) as resp:
assert resp.status == 200
finally:
await p.stop()
async def _resolve_none(song, keyword):
return None
async def _resolve_boom(song, keyword):
raise RuntimeError("解析坏了")
async def test_file_proxy_resolve_failure_502(server, env):
"""解析失败(返回 None 或抛错)→ 502,请求方拿到可读的失败而非 500 栈。"""
http, config = env
for resolver in (_resolve_none, _resolve_boom):
p = FileProxy("http://127.0.0.1:1", http, resolver, port=0)
await p.start()
p._base = f"http://127.0.0.1:{p.port}"
try:
url = p.claim(music.Song("kw", "S1", "稻香", "歌手A"), "稻香", "a.mp3")
async with http.get(url) as resp:
assert resp.status == 502
finally:
await p.stop()
async def test_send_song_swaps_urls_to_proxy(server, env):
"""开了代理后:上传给 QQ 的与直链文本里的 URL 都是本机代理地址,
QQ 服务器/群友随后取这个地址能拿到字节(请求时重解析)。"""
http, config = env
p = await start_file_proxy(http, config)
music._file_proxy = p
try:
api, calls = make_api()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 3
assert upload["url"].startswith(f"http://127.0.0.1:{p.port}/file/")
async with http.get(upload["url"]) as resp: # QQ 侧抓这个地址
assert resp.status == 200
assert await resp.read() == b"fake-audio-bytes"
api, calls = make_api() # link 模式下载文本同样换链
await handle_button(api, http, config, "G1", "I3", DL_TX)
body = posts(calls)[-1]["json"]
assert f"http://127.0.0.1:{p.port}/file/" in body["content"]
assert "/media/" not in body["content"]
finally:
music._file_proxy = None
await p.stop()
async def test_file_proxy_not_ready_keeps_direct_link(server, env):
"""代理实例在但服务没起来(端口被占等)→ ready=False 不换链,
发送链路退回原直链、不受影响。"""
http, config = env
p = FileProxy(
"http://127.0.0.1:1",
http,
lambda song, keyword: music._fetchable_play(http, config, song, keyword),
port=1,
) # 不 start:ready=False
music._file_proxy = p
api, calls = make_api()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
upload = [c for c in calls if c.get("file_type")][0]
assert upload["url"].endswith("/media/tx/T1.mp3")
async def test_register_wires_file_proxy(server, env):
"""register 按配置创建代理(接线与解析闭包),未配置时为 None 不启用。"""
http, config = env
on = replace(
config, file_proxy=True, public_url="http://proxy.test:16689",
proxy_port=16689,
)
register(EventEmitter(), on, http)
assert music._file_proxy is not None
assert music._file_proxy._base == "http://proxy.test:16689"
# 解析闭包已接上 _fetchable_play:能取到可抓取直链
found = await music._file_proxy._resolve(
music.Song("tx", "T1", "稻香", "歌手T"), "稻香"
)
assert found is not None and found[1].endswith("/media/tx/T1.mp3")
register(EventEmitter(), config, http) # 未配置 PUBLIC_URL → 不启用
assert music._file_proxy is None
async def test_legacy_pick_text_is_ignored(env):
"""旧的「点歌播放 …」文字指令已废除,不再匹配(按钮回调取代)。"""
http, config = env
api, calls = make_api()
assert not await handle_command(api, http, config, "G1", "m1", "点歌播放 1")
assert calls == []
async def test_card_withdrawn_when_send_song_raises(env):
"""send_song 抛错(如候选源搜索时 MusicServer 挂掉)也要撤回卡片——
这是 finally 存在的理由,否则卡片会永久残留。"""
http, config = env
api, calls = make_api()
await handle_button(api, http, config, "G1", "I1", PICK_1)
calls.clear()
# 换一个不可达的 MusicServer:直链探测与候选源搜索都会失败
broken = replace(config, music_server_url="http://127.0.0.1:1")
await handle_button(api, http, broken, "G1", "I2", PLAY_TX)
assert ("DELETE", "/v2/groups/G1/messages/MSG") in [
(c["method"], c["path"]) for c in calls
]
assert "G1" not in music._last_msg
async def test_card_play_falls_back_to_candidate_source(server):
"""tx 直链不可抓取时,卡片上的播放同样走候选源探测,用 kw 直链上传。"""
s = FakeMusicServer(tx_denied=True)
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",
)
api, calls = make_api()
await handle_button(api, http, config, "G1", "I2", PLAY_TX)
finally:
await s.stop()
upload = [c for c in calls if c.get("file_type")][0]
assert upload["file_type"] == 3
assert "/media/kw/" in upload["url"] # 绕开被风控的 tx 直链
async def test_fallback_sources_follow_server_platforms():
"""回退链由 /sources 动态解析:具备 search+url 能力的平台按优先序
排、其余追加在后,并排除原曲所在源与不可作候选的源(tx/qs)——
tx 的候选 = kw, wy, kg(qs 有 url 能力也被排除)。"""
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"]
got = await music._fallback_sources(
http, config, music.Song("qs", "Q1", "稻香", "歌手Q")
)
assert got == ["kw", "wy", "kg"]
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()
data = f"音乐|page|{quote('稻香', safe='')}|2"
assert await handle_button(
api, http, config, "G1", "I1", data, event_id="INTERACTION_CREATE:I1"
)
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2 # 翻页后发新菜单
assert "点歌:稻香" in body["markdown"]["content"]
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_source_download_command(server, env):
"""「酷狗下载 <歌名>」:在指定平台搜歌出选歌菜单(与点歌同款交互),
标题带平台名、点编号后才播放/下载;全名「酷狗音乐下载」同样命中。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "酷狗下载 稻香")
assert server.queries[-1]["source"] == "kg" # 指定源搜索,不走聚合
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2
assert "## 酷狗音乐:稻香" in body["markdown"]["content"]
assert "1. 稻香 - 歌手G" in body["markdown"]["content"]
assert labels(body) == [["1"]] # 一首候选:一个编号按钮、无翻页行
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m2", "/酷狗音乐下载 稻香")
assert "## 酷狗音乐:稻香" in posts(calls)[0]["json"]["markdown"]["content"]
async def test_source_menu_pages_within_source(server, env):
"""「<平台>下载」菜单的翻页按钮走 spage 动作、携带平台,翻页仍搜同一
平台且标题不变。"""
http, config = env
config = replace(config, menu_page_size=1) # 一首即满员,逼出下一页按钮
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "酷狗下载 稻香")
body = posts(calls)[0]["json"]
nav = buttons(body)[-1]["buttons"]
assert [b["render_data"]["label"] for b in nav] == ["下一页"]
assert nav[0]["action"]["data"] == f"音乐|spage|kg|{quote('稻香', safe='')}|2"
api, calls = make_api()
assert await handle_button(
api, http, config, "G1", "I1", nav[0]["action"]["data"],
event_id="INTERACTION_CREATE:I1",
)
assert server.queries[-1] == {
"keyword": "稻香", "source": "kg", "page": "2", "limit": "1",
}
menu = posts(calls)[0]["json"]
assert "## 酷狗音乐:稻香" in menu["markdown"]["content"]
# 第 2 页有回翻;假服务器页页满员,「下一页」按本页满员启发式保留
assert labels(menu) == [["1"], ["上一页", "下一页"]]
async def test_pick_show_opens_track_menu(server, env):
"""节目级条目(me 的 d: 前缀)点编号先出选集菜单:标题带总集数、集条目
抹掉与节目名重复的歌手与 0 时长;选集再点编号弹的是该集卡片。"""
http, config = env
api, calls = make_api()
show = music.Song("me", "d:44753", "节目名", "", img=IMG_URL)
await handle_button(
api, http, config, "G1", "I1", music._song_data("pick", show),
event_id="E1",
)
body = posts(calls)[0]["json"]
assert "## 选集:节目名(共23集)" in body["markdown"]["content"]
assert "1. 第1期" in body["markdown"]["content"]
assert " - 节目名" not in body["markdown"]["content"] # 冗余歌手已抹
assert labels(body) == [
["1", "2", "3", "4", "5"], ["6", "7", "8", "9", "10"], ["下一页"],
]
nav = buttons(body)[-1]["buttons"][0]["action"]["data"]
assert nav == f"音乐|track|me|44753|{quote('节目名', safe='')}|2"
# 选集编号 → 该集卡片(普通单曲,照常播放/下载)
api, calls = make_api()
episode = music.Song("me", "44753-1", "第1期", "", img=IMG_URL)
await handle_button(
api, http, config, "G1", "I2", music._song_data("pick", episode),
event_id="E2",
)
assert "## 第1期" in posts(calls)[0]["json"]["markdown"]["content"]
async def test_pick_lr_book_opens_track_menu(server, env):
"""懒人/蜻蜓搜索返回的整条都是书/频道(直接取链 502):点编号同样先出
选集菜单。"""
http, config = env
api, calls = make_api()
book = music.Song("lr", "50678018", "三体广播剧", "")
await handle_button(
api, http, config, "G1", "I1", music._song_data("pick", book),
event_id="E1",
)
assert "## 选集:三体广播剧(共23集)" in posts(calls)[0]["json"]["markdown"]["content"]
async def test_track_menu_local_paging_for_me(server, env):
"""me 的 /album 忽略 page 参数:一次拉大 limit 本地切片翻页;第 3 页
只剩 3 集、无下一页。"""
http, config = env
api, calls = make_api()
await music.send_track_menu(
api, http, config, "G1", "me", "44753", "节目名", 3, event_id="E1",
)
assert server.album_queries[-1] == {
"source": "me", "album_id": "44753", "page": 1, "limit": 50,
} # 不依赖 page 参数,单次拉 50
body = posts(calls)[0]["json"]
assert "1. 第21期" in body["markdown"]["content"]
assert "第20期" not in body["markdown"]["content"]
assert labels(body) == [["1", "2", "3"], ["上一页"]]
async def test_track_menu_server_paging_for_lr(server, env):
"""lr/qt 服务端翻页可用:按页直取当页。"""
http, config = env
api, calls = make_api()
await music.send_track_menu(
api, http, config, "G1", "lr", "50678018", "三体", 2, event_id="E1",
)
assert server.album_queries[-1] == {
"source": "lr", "album_id": "50678018", "page": 2, "limit": 10,
}
body = posts(calls)[0]["json"]
assert "1. 第11期" in body["markdown"]["content"] # 服务端切好的第 11-20 集
assert labels(body) == [
["1", "2", "3", "4", "5"], ["6", "7", "8", "9", "10"],
["上一页", "下一页"],
]
async def test_track_menu_empty_and_delisted(server, env):
"""空选集:第 1 页空(已下线/需登录)给明确提示,翻过头给「没有更多了」。"""
http, config = env
server.album_empty_ids = frozenset({"62806"})
api, calls = make_api()
await music.send_track_menu(
api, http, config, "G1", "me", "62806", "三体", 1, event_id="E1",
)
assert "拿不到剧集列表" in posts(calls)[0]["json"]["content"]
api, calls = make_api()
await music.send_track_menu(
api, http, config, "G1", "me", "62806", "三体", 2, event_id="E1",
)
assert posts(calls)[0]["json"]["content"] == "没有更多了"
async def test_source_download_usage_and_miss(server, env):
"""只敲「酷狗下载」给用法提示;指定源搜不到给源名提示。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "酷狗下载")
assert posts(calls)[0]["json"]["content"] == "用法:酷狗下载 <歌名>"
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m2", "酷我下载 空白歌")
assert posts(calls)[-1]["json"]["content"] == "在酷我音乐没找到「空白歌」"
async def test_hot_search_flow(server, env):
"""热搜:编号列表 + 热词按钮,点热词直接出该词的选歌菜单。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "热搜")
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2
assert "## QQ音乐热搜" in body["markdown"]["content"] # 配置源 tx 支持
assert labels(body) == [["热词一", "热词二", "热词三"]]
hot_data = buttons(body)[0]["buttons"][0]["action"]["data"]
assert hot_data == f"音乐|hot|{quote('热词一', safe='')}"
api, calls = make_api()
assert await handle_button(
api, http, config, "G1", "I1", hot_data, event_id="INTERACTION_CREATE:I1"
)
menu = posts(calls)[0]["json"]
assert "点歌:热词一" in menu["markdown"]["content"]
assert (calls[0]["method"], calls[0]["path"]) == ("PUT", "/interactions/I1")
async def test_boards_flow(server, env):
"""榜单:点榜单名出榜单选歌菜单,翻页携带榜单上下文(总数决定下一页)。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "榜单")
body = posts(calls)[0]["json"]
assert "## QQ音乐榜单" in body["markdown"]["content"]
assert labels(body) == [["飙升榜", "热歌榜"]]
board_data = buttons(body)[0]["buttons"][1]["action"]["data"]
assert board_data == (
f"音乐|board|tx|tx__16|{quote('热歌榜', safe='')}|1"
)
api, calls = make_api()
assert await handle_button(
api, http, config, "G1", "I1", board_data, event_id="INTERACTION_CREATE:I1"
)
menu = posts(calls)[0]["json"]
md = menu["markdown"]["content"]
assert "榜单:热歌榜" in md
assert "1. 榜歌1 - 歌手1" in md
nav = buttons(menu)[-1]["buttons"]
assert [b["render_data"]["label"] for b in nav] == ["下一页"] # total 30 > 1×10
next_data = nav[0]["action"]["data"]
api, calls = make_api()
assert await handle_button(
api, http, config, "G1", "I2", next_data, event_id="INTERACTION_CREATE:I2"
)
assert server.board_queries[-1] == {"board": "tx__16", "page": "2", "limit": "10"}
assert "榜单:热歌榜" in posts(calls)[0]["json"]["markdown"]["content"]
async def test_panel_items_follow_server(server, env):
"""面板条目按 /sources 现算:基础指令 + 热搜/榜单 + 各平台「<平台>下载」
(指令名取惯用/短名:酷狗、QQ音乐、网易云),帮助垫底。"""
http, config = env
items = await music.panel_items(http, config)
assert [i["name"] for i in items] == [
"点歌", "歌词", "热搜", "榜单",
"酷我下载", "酷狗下载", "QQ音乐下载", "网易云下载", "汽水下载",
"帮助",
]
assert all(i["type"] == "command" for i in items)
kg = next(i for i in items if i["name"] == "酷狗下载")
assert kg["desc"] == "在酷狗音乐搜歌出选歌菜单"
async def test_panel_items_degrade_without_sources():
"""/sources 不可用时退回静态表:xm 等新平台也有下载指令,热搜/榜单在。"""
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",
)
names = [i["name"] for i in await music.panel_items(http, config)]
finally:
await s.stop()
assert "酷狗下载" in names and "喜马拉雅下载" in names
assert "热搜" in names and "榜单" in names
async def test_panel_items_fit_qq_width_limits():
"""/v2/panels 长度按宽度算(ASCII 1、汉字 2)而非字符数:「Apple Music
下载」全名超宽(15>14)借短名「Apple下载」入面板;换无可换的平台只能
放弃;指令满 20 条时「帮助」仍垫底不被挤掉。宽度超限曾致 PUT 30013。"""
caps = {"search": True, "url": True}
extras = (
{"id": "apple", "name": "Apple Music", "capabilities": caps},
{"id": "toolong", "name": "名字特别长的平台呀", "capabilities": caps},
*[{"id": f"p{i}", "name": f"平台{i}号", "capabilities": caps} for i in range(14)],
)
s = FakeMusicServer(extra_platforms=extras)
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",
)
items = await music.panel_items(http, config)
finally:
await s.stop()
width = lambda t: sum(1 if ord(c) < 128 else 2 for c in t) # noqa: E731
assert all(width(i["name"]) <= 14 and width(i["desc"]) <= 30 for i in items)
names = [i["name"] for i in items]
assert "Apple下载" in names
assert not any("名字特别长" in i["desc"] for i in items)
# 基础 4 + 下载 20 截到 19,再垫「帮助」:总数 20、帮助在最后
assert len(items) == 20 and items[-1]["name"] == "帮助"
def test_songs_of_injects_source_and_drops_unknown():
"""无 source 字段的条目(xm/me/qt/lr 真机如此)按请求源补上;
聚合请求(all)无从判断来源,这类条目只能丢弃。"""
items = [{"songmid": "X1", "name": "歌", "singer": "手"}]
assert [s.source for s in music._songs_of(items, "xm")] == ["xm"]
assert music._songs_of(items, "all") == []
assert [s.source for s in music._songs_of(
[{"source": "kw", "songmid": "S1", "name": "歌", "singer": "手"}], "xm"
)] == ["kw"] # 自带 source 的不覆盖
def test_alias_rules():
"""别名规则:剥「音乐」短名、纯拉丁词干不剥、wy 惯用名「网易云」;
/sources 增删平台后正则即时重建。"""
music._apply_aliases({
"kg": {"name": "酷狗音乐"},
"tx": {"name": "QQ音乐"},
"wy": {"name": "网易音乐"},
})
try:
assert music._alias_to_source["酷狗"] == "kg"
assert music._alias_to_source["酷狗音乐"] == "kg"
assert music._alias_to_source["网易云"] == "wy"
assert "QQ" not in music._alias_to_source # 纯拉丁词干不剥
m = music._dl_re.search("QQ音乐下载 稻香")
assert m is not None and m.group(1) == "QQ音乐"
m = music._dl_re.search("网易云下载 稻香")
assert m is not None and m.group(1) == "网易云"
assert music._dl_re.search("喜马拉雅下载 三体") is None # 表里没有
finally:
music._apply_aliases(music._STATIC_PLATFORMS)
async def test_login_status_smoke(server, env):
"""登录态日志不抛错(老版服务器无此接口时静默跳过)。"""
http, config = env
await music._log_login_status(http, config)
broken = replace(config, music_server_url="http://127.0.0.1:1")
await music._log_login_status(http, broken)
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
for content in ("点歌 稻香", "/点歌 稻香", "<@!BOT123> /点歌 稻香"):
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", content)
body = posts(calls)[0]["json"]
assert body["msg_type"] == 2
assert "点歌:稻香" in body["markdown"]["content"]
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "/歌词 稻香")
assert posts(calls)[0]["json"]["content"] == "第一行\n第二行"
async def test_slash_help_sends_command_keyboard(env):
"""/帮助 出帮助正文 + type=2 指令按钮(点一下往输入框填斜杠指令)。"""
http, config = env
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
body = posts(calls)[0]["json"]
assert "点歌" in body["content"] and "歌词" in body["content"]
rows = buttons(body)
assert len(rows) == 1
datas = [b["action"]["data"] for b in rows[0]["buttons"]]
assert [b["action"]["type"] for b in rows[0]["buttons"]] == [2, 2, 2, 2]
assert datas == ["/点歌 ", "/歌词 ", "/热搜", "/榜单"]
async def test_slash_help_falls_back_to_plain_text(env):
http, config = env
api, calls = make_api(fail_keyboard=True)
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
sent = posts(calls)
assert len(sent) == 1 # 键盘被拒,回退为一条纯文本
assert "keyboard" not in sent[0]["json"]
assert "点歌" in sent[0]["json"]["content"]
async def test_bare_command_replies_usage(env):
"""斜杠菜单的参数可选时可能不带参发出来:只敲命令给用法提示。"""
http, config = env
for content, hint in (("点歌", "用法:点歌"), ("/歌词", "用法:歌词")):
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", content)
assert posts(calls)[0]["json"]["content"].startswith(hint)
async def test_lyric_miss_help_and_no_hit(env):
http, config = env
api, calls = make_api()
await handle_command(api, http, config, "G1", "m1", "歌词 稻香")
assert posts(calls)[0]["json"]["content"] == "第一行\n第二行"
api, calls = make_api()
await handle_command(api, http, config, "G1", "m1", "点歌 空白歌")
assert posts(calls)[0]["json"]["content"] == "没找到「空白歌」"
broken = BotConfig(
music_server_url="http://127.0.0.1:1",
music_quality="128k",
music_source="tx",
)
api, calls = make_api()
await handle_command(api, http, broken, "G1", "m1", "点歌 稻香")
assert posts(calls)[0]["json"]["content"] == "音乐服务暂时不可用,稍后再试试"
api, calls = make_api()
assert await handle_command(api, http, config, "G1", "m1", "音乐帮助")
assert "点歌" in posts(calls)[0]["json"]["content"]
assert not await handle_command(api, http, config, "G1", "m1", "早上好")
def test_trim_strips_tags_and_truncates():
from musicbot.music import _trim
text = "[kuwo:113]\n[ti:稻香]\n[00:01.00]正文一行\n[offset:0]\n"
assert _trim(text) == "正文一行"
long = "\n".join(f"[00:{i:02d}.00]第{i}行" for i in range(100))
trimmed = _trim(long, limit=100)
assert trimmed.endswith("……(歌词过长已截断)")
assert len(trimmed) <= 100 + len("\n……(歌词过长已截断)")
class PanelRecording(BotApi):
"""预设 /v2/panels 响应并记录调用:fail_get=True 时查询抛 403。"""
def __init__(self, records: list | None = None, fail_get: bool = False) -> None:
self.records = records or []
self.fail_get = fail_get
self.calls: list[dict] = []
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
if self.fail_get and path == "/v2/panels":
raise ApiError(403, 11253, "无权限")
self.calls.append({"method": method, "path": path, **kwargs})
if method == "GET":
# 响应整体包一层 {"data": ...},顺带覆盖 _unwrap 的拆包路径
return {"data": {"records": self.records, "is_end": True}}
if method == "POST":
return {"panel_id": "P1"}
return {"version": 2}
_PANEL_ITEMS = [
{"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"},
{"name": "歌词", "type": "command", "desc": "查看歌词"},
{"name": "酷狗下载", "type": "command", "desc": "在酷狗音乐搜歌出选歌菜单"},
{"name": "帮助", "type": "command", "desc": "查看帮助"},
]
async def test_panel_created_when_missing():
api = PanelRecording()
await sync_command_panel(api, _PANEL_ITEMS)
assert [(c["method"], c["path"]) for c in api.calls] == [
("GET", "/v2/panels"),
("POST", "/v2/panels"),
]
body = api.calls[1]["json"]
assert body["scope"] == "group" and body["target_type"] == "all"
assert [i["name"] for i in body["panel"]["items"]] == [
"点歌", "歌词", "酷狗下载", "帮助",
]
async def test_panel_skipped_when_identical():
record = {"panel_id": "P9", "panel": {"items": _PANEL_ITEMS, "remark": "音乐bot"}}
api = PanelRecording(records=[record])
await sync_command_panel(api, _PANEL_ITEMS)
assert [(c["method"], c["path"]) for c in api.calls] == [("GET", "/v2/panels")]
async def test_panel_updated_when_differs():
# 这条用响应不包 {"data"} 外层的形态,覆盖 _unwrap 的另一分支
api = PanelRecording(
records=[
{
"panel_id": "P9",
"panel": {
"items": [{"name": "点歌", "type": "command", "desc": "旧"}],
"remark": "音乐bot",
},
}
]
)
await sync_command_panel(api, _PANEL_ITEMS)
assert (api.calls[1]["method"], api.calls[1]["path"]) == ("PUT", "/v2/panels/P9")
body = api.calls[1]["json"]
assert [i["name"] for i in body["panel"]["items"]] == [
"点歌", "歌词", "酷狗下载", "帮助",
]
async def test_panel_failure_swallowed():
api = PanelRecording(fail_get=True)
await sync_command_panel(api, _PANEL_ITEMS) # 失败只记日志,绝不抛出
assert api.calls == []
def test_config_send_mode(monkeypatch):
"""SEND_MODE 默认 link(避开文件上传权限),file 可显式开启,非法值回落 link。"""
from musicbot.config import BotConfig
monkeypatch.delenv("SEND_MODE", raising=False)
assert BotConfig.load().send_mode == "link"
monkeypatch.setenv("SEND_MODE", "FILE") # 大小写不敏感
assert BotConfig.load().send_mode == "file"
monkeypatch.setenv("SEND_MODE", "随便写的")
assert BotConfig.load().send_mode == "link" # 非法值不报错,按默认处理
def test_config_file_proxy(monkeypatch):
"""文件代理开关:默认关;配了 PUBLIC_URL 即开(FILE_PROXY=off 可关);
监听端口缺省跟 PUBLIC_URL 里的端口,无端口用默认。"""
from musicbot.config import BotConfig
for var in ("PUBLIC_URL", "FILE_PROXY", "PROXY_PORT"):
monkeypatch.delenv(var, raising=False)
cfg = BotConfig.load()
assert cfg.file_proxy is False and cfg.public_url == ""
monkeypatch.setenv("PUBLIC_URL", "http://1.2.3.4:16690/")
cfg = BotConfig.load()
assert cfg.file_proxy is True
assert cfg.public_url == "http://1.2.3.4:16690" # 去掉尾斜杠
assert cfg.proxy_port == 16690 # 缺省跟 URL 里的端口
monkeypatch.setenv("PUBLIC_URL", "http://music.example.com")
assert BotConfig.load().proxy_port == 16689 # 无端口用默认
monkeypatch.setenv("PUBLIC_URL", "http://1.2.3.4:16690")
monkeypatch.setenv("PROXY_PORT", "17777") # 监听端口可独立指定
assert BotConfig.load().proxy_port == 17777
monkeypatch.setenv("FILE_PROXY", "off") # 显式关闭
assert BotConfig.load().file_proxy is False
def test_config_log_level(monkeypatch):
"""LOG_LEVEL 默认 INFO,可显式设置(大小写不敏感),非法值回落 INFO。"""
from musicbot.config import BotConfig
monkeypatch.delenv("LOG_LEVEL", raising=False)
assert BotConfig.load().log_level == "INFO"
monkeypatch.setenv("LOG_LEVEL", "debug")
assert BotConfig.load().log_level == "DEBUG"
monkeypatch.setenv("LOG_LEVEL", "乱写")
assert BotConfig.load().log_level == "INFO"
def test_setup_logging_writes_file(tmp_path: Path):
"""setup_logging 同时写控制台与文件;写不进去时退化为仅控制台不抛错。"""
from loguru import logger as lg
from musicbot.config import BotConfig
from musicbot.main import setup_logging
cfg = BotConfig(
music_server_url="http://127.0.0.1:1",
music_quality="128k",
music_source="tx",
log_level="INFO",
log_dir=tmp_path,
)
try:
setup_logging(cfg)
lg.info("测试日志落盘")
lg.complete() # 等 enqueue 队列刷完
logfile = tmp_path / "musicbot.log"
assert logfile.is_file()
assert "测试日志落盘" in logfile.read_text(encoding="utf-8")
finally:
lg.remove() # 清掉本用例加的 handler,避免影响其他测试
# 目录不可写(用一个文件当目录)→ 不抛错,仅告警
blocked = tmp_path / "not-a-dir"
blocked.write_text("x", encoding="utf-8")
setup_logging(replace(cfg, log_dir=blocked))
lg.remove()
def test_config_reload_flag(monkeypatch):
"""RELOAD 默认关(生产不受影响),on 可开启,非法值按关处理。"""
from musicbot.config import BotConfig
monkeypatch.delenv("RELOAD", raising=False)
assert BotConfig.load().reload is False
monkeypatch.setenv("RELOAD", "on")
assert BotConfig.load().reload is True
monkeypatch.setenv("RELOAD", "随便写")
assert BotConfig.load().reload is False
# ---- 热重载:文件监听与进程监督 ----
def test_watched_files_and_skip_rules(tmp_path: Path):
"""监控 .py/.toml 与 .env;跳过 logs/、__pycache__/、.venv/ 与无关文件。"""
from musicbot import reload
(tmp_path / "musicbot").mkdir()
(tmp_path / "musicbot" / "music.py").write_text("", encoding="utf-8")
(tmp_path / "tests").mkdir()
(tmp_path / "tests" / "test_x.py").write_text("", encoding="utf-8")
(tmp_path / "intents.toml").write_text("", encoding="utf-8")
(tmp_path / ".env").write_text("", encoding="utf-8")
(tmp_path / "README.md").write_text("", encoding="utf-8") # 无关后缀
(tmp_path / "logs").mkdir()
(tmp_path / "logs" / "musicbot.log").write_text("", encoding="utf-8")
(tmp_path / "musicbot" / "__pycache__").mkdir()
(tmp_path / "musicbot" / "__pycache__" / "x.py").write_text("", encoding="utf-8")
watched = reload.iter_watched_files(tmp_path)
names = {p.relative_to(tmp_path).as_posix() for p in watched}
assert names == {"musicbot/music.py", "tests/test_x.py", "intents.toml", ".env"}
def test_changed_files_detects_edit_add_remove(tmp_path: Path):
"""快照比对:改动/新增/删除都能识别,未变则返回空。"""
from musicbot import reload
f = tmp_path / "musicbot"
f.mkdir()
mod = f / "music.py"
mod.write_text("a", encoding="utf-8")
before = reload.snapshot(tmp_path)
assert reload.changed_files(before, reload.snapshot(tmp_path)) == []
mod.write_text("bb", encoding="utf-8")
assert reload.changed_files(before, reload.snapshot(tmp_path)) == [mod]
def test_is_child_guard(monkeypatch):
"""子进程标记生效:带标记的进程不再做监督,避免 --reload 无限自我复制。"""
from musicbot import reload
monkeypatch.delenv(reload.CHILD_ENV, raising=False)
assert reload.is_child() is False
monkeypatch.setenv(reload.CHILD_ENV, "1")
assert reload.is_child() is True
def test_main_child_does_not_supervise(monkeypatch):
"""子进程即使带 --reload / RELOAD=on 也只跑 bot,不起监督
(否则 --reload 会无限自我复制)。"""
from musicbot import main as mb_main
from musicbot import reload
called: dict[str, bool] = {}
async def fake_amain() -> None:
called["bot"] = True
monkeypatch.setattr(reload, "is_child", lambda: True)
monkeypatch.setattr(mb_main, "amain", fake_amain)
monkeypatch.setattr(
reload, "run_supervisor",
lambda *a, **k: called.setdefault("supervisor", True),
)
monkeypatch.setattr(mb_main.BotConfig, "load", staticmethod(
lambda: replace(
BotConfig(
music_server_url="http://127.0.0.1:1",
music_quality="128k",
music_source="tx",
),
reload=True,
)
))
mb_main.main(["--reload"])
assert called == {"bot": True} # 跑的是 bot,没有起监督
# ---- 全量模式(不 @ 也能用)与去重 ----
def make_emitter(api, http, config) -> EventEmitter:
"""按 main.py 的装配方式注册 handler,手工注入 SDK 会在 bot.start()
装配时登记的 BotApi;http 走闭包(与 main.py 一致)。"""
ee = EventEmitter()
register(ee, config, http)
ee.services.update({BotApi: api})
return ee
async def push(ee: EventEmitter, event: str, d: dict) -> None:
await ee.emit({"op": 0, "t": event, "d": d})
def group_payload(
msg_id: str, content: str, *, bot: bool = False, user: str = "U1"
) -> dict:
return {
"id": msg_id,
"content": content,
"group_openid": "G1",
"author": {"member_openid": user, "bot": bot},
}
async def test_no_at_message_sends_menu(env):
"""全量模式:群里不 @ 机器人也点歌(GROUP_MESSAGE_CREATE),
被动回复仍用这条消息的 msg_id。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m1", "点歌 稻香"))
body = posts(calls)[0]["json"]
assert body["msg_id"] == "m1"
# 官方 event_id 支持列表不含 GROUP_MESSAGE_CREATE,只能用 msg_id
assert "event_id" not in body
assert "## 点歌:稻香" in body["markdown"]["content"]
async def test_at_message_still_handled(env):
"""@消息(默认事件)行为不变。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
await push(ee, "GROUP_AT_MESSAGE_CREATE", group_payload("m1", "点歌 稻香"))
assert "## 点歌:稻香" in posts(calls)[0]["json"]["markdown"]["content"]
async def test_group_message_ignores_own_and_other_chat(env):
"""机器人自己的消息(可能被回推)与非命令消息一律不回复。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m1", "点歌 稻香", bot=True))
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m2", ""))
await push(ee, "GROUP_MESSAGE_CREATE", {
"id": "m3", "content": "早上好", "group_openid": "G1",
"author": {"member_openid": "U1"},
})
assert calls == []
async def test_duplicate_push_handled_once(env):
"""同一 msg_id 重复推送、或 @消息同时命中两个事件(两事件 msg_id 不同,
2026-10-03 线上因此双占位双直链)时只处理一次。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
payload = group_payload("m1", "点歌 稻香")
await push(ee, "GROUP_MESSAGE_CREATE", payload)
await push(ee, "GROUP_MESSAGE_CREATE", payload)
# 双事件的另一帧:msg_id 不同、文本归一后一致(@ 前缀 + 斜杠都有)
await push(
ee, "GROUP_AT_MESSAGE_CREATE", group_payload("m2", "<@!BOT9> /点歌 稻香")
)
assert len(posts(calls)) == 1
async def test_cross_event_dedup_is_scoped(env, monkeypatch):
"""内容级去重只吞同群同人同文本的短窗重复:换人、换文本、窗口过期
都照常处理。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m1", "点歌 稻香"))
# 同群同人同文本、不同 msg_id(双事件的另一帧)→ 只处理第一次
await push(ee, "GROUP_AT_MESSAGE_CREATE", group_payload("m2", "点歌 稻香"))
assert len(posts(calls)) == 1
# 换用户同文本 → 照常
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m3", "点歌 稻香", user="U2"))
# 同用户换文本 → 照常
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m4", "点歌 晴天"))
assert len(posts(calls)) == 3
# 窗口过期后同人同文本再来 → 照常
monkeypatch.setattr(music, "_MSG_DUP_TTL", 0.0)
await push(ee, "GROUP_MESSAGE_CREATE", group_payload("m5", "点歌 稻香"))
assert len(posts(calls)) == 4
async def test_no_at_command_without_content_is_ignored(env):
"""纯图片等无正文消息(全量模式同样推送)不触发任何请求。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
await push(ee, "GROUP_MESSAGE_CREATE", {
"id": "m1",
"group_openid": "G1",
"author": {"member_openid": "U1"},
"attachments": [{"content_type": 1, "url": "https://x/1.png"}],
})
assert calls == []
def test_dedup_expires_and_bounds(monkeypatch):
"""去重表:过期记录可再次处理,条数达到上限时淘汰最旧。"""
assert not music._handled("m1")
assert music._handled("m1")
# TTL 过去后同一条消息重新可处理
monkeypatch.setattr(music, "_DEDUP_TTL", 0.0)
assert not music._handled("m1")
# 记录数超过上限时淘汰最旧的
monkeypatch.setattr(music, "_DEDUP_TTL", 300.0)
monkeypatch.setattr(music, "_DEDUP_MAX", 2)
music._seen_messages.clear()
for i in range(3):
music._handled(f"n{i}")
assert list(music._seen_messages) == ["n1", "n2"]
async def test_bot_own_reply_is_not_self_triggered(env):
"""bot 自己发的帮助文本里有「点歌」「音乐帮助」,被全量模式回推时不能
自触发(否则递归出菜单);作者无 bot 标记时靠发送记录兜底。"""
http, config = env
api, calls = make_api()
ee = make_emitter(api, http, config)
assert await handle_command(api, http, config, "G1", "m1", "音乐帮助")
assert len(posts(calls)) == 1
# 平台把这个已发出的消息(id=MSG)回推,author 不含 bot 字段
await push(ee, "GROUP_MESSAGE_CREATE", {
"id": "MSG", "content": "♪ 音乐 bot\n点歌 <歌名>:出菜单",
"group_openid": "G1", "author": {"member_openid": "U1"},
})
assert len(posts(calls)) == 1 # 未再发任何消息
# 同样内容的新消息(不同 id)来自真人 → 正常处理
await push(ee, "GROUP_MESSAGE_CREATE", {
"id": "m9", "content": "音乐帮助", "group_openid": "G1",
"author": {"member_openid": "U2"},
})
assert len(posts(calls)) == 2