diff --git a/.env.example b/.env.example index 219759f..6fe0a42 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ APPID=你的AppID APPSECRET=你的AppSecret CONNECTER=websocket -# MusicServer(lx-music 聚合音源) +# MusicServer(lx-music 聚合音源,需要 /sources 报告平台、路径式 /search 的新版) +# 平台 id 以服务器 /sources 为准:kw/kg/tx/wy/qs + xm/me/qt/lr(音频平台) MUSIC_SERVER_URL=http://127.0.0.1:16688 MUSIC_QUALITY=128k MUSIC_SOURCE=tx # 默认 QQ 音乐,留空聚合全源 @@ -21,6 +22,17 @@ WITHDRAW=on # 播放按钮不受影响,始终发语音。非法值按 link 处理。 SEND_MODE=link +# 文件代理:bot 自建 HTTP 服务中转音频,把上传用/直链文本里的 URL 换成 +# 自己的公开地址——QQ 服务器抓风控直链(40093007)或 qs 本地解密流抓不到 +# 时经 bot 中转即可。PUBLIC_URL 填 QQ 服务器/群友能访问到的本机地址(公网 +# IP:端口,或反代域名),配置后默认开启,FILE_PROXY=off 可关。bot 主机需 +# 公网可达(公网 IP 或端口映射),否则上传失败、回退直链文本。 +#PUBLIC_URL=http://1.2.3.4:16689 +#FILE_PROXY=on +# PROXY_PORT:本机监听端口,缺省用 PUBLIC_URL 里的端口(无端口则 16689); +# https 域名走反向代理时必须显式指定(bot 本身不收 TLS)。 +#PROXY_PORT=16689 + # 日志:级别可选 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL(默认 INFO); # 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天) LOG_LEVEL=INFO diff --git a/README.md b/README.md index a47b07a..33e4349 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,23 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk),两步点歌: -1. **选歌菜单**——`点歌 <歌名 [歌手]>`(或斜杠指令 `/点歌 <歌名 [歌手]>`)只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20) +1. **选歌菜单**——`点歌 <歌名 [歌手]>`(或斜杠指令 `/点歌 <歌名 [歌手]>`)只负责选歌:Markdown 歌单(编号 + 歌名/歌手 + 条目封面小图,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20) 2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行 -- **专辑封面**:卡片用 Markdown 图片语法 `![封面 #240px #240px](img)` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图) +其余指令(都能从输入框 `/` 菜单直接触发): + +- **`/<平台>下载 <歌名>`**(如 `/酷狗下载 稻香`):在指定平台搜歌、出该平台的选歌菜单(与 `/点歌` 同款交互:点编号弹卡片、再点播放/下载;翻页锁定该平台);平台与指令名按 MusicServer `/sources` 动态生成(服务器加源即自动出现新指令),全名/短名/惯用名都可(`酷狗下载`、`酷狗音乐下载`;`网易云下载`、`网易音乐下载`) +- **`/热搜`**:拉取热搜词(编号列表 + 词按钮),点词直接出该词的选歌菜单 +- **音频平台选集菜单**:猫耳FM/懒人/蜻蜓的搜索结果是节目级条目(一个节目多集音频,猫耳为 `d:` 前缀、懒人是书、蜻蜓是频道,直接取链会失败或只拿到一集),点编号先出**选集菜单**(`/album` 接口,标题带总集数、可翻页),选集再点编号才弹卡片播放/下载;猫耳的选集接口不支持翻页参数,一次拉 50 集本地切片分页(超过 50 集的节目被截断),懒人/蜻蜓走服务端翻页;选集为空(已下线/需登录)给明确提示。喜马拉雅搜索返回单集级条目(带真实时长),不受影响 +- **`/榜单`**:拉取榜单列表,点榜单名直接出该榜单的选歌菜单(可翻页,按服务器返回的总数判断) +- **`/歌词 <歌名>`**:歌词按源搜索,无歌词能力的平台(喜马拉雅等音频源)给明确提示 + +- **专辑封面**:卡片与选歌菜单都用 Markdown 图片语法 `![封面 #宽px #高px](img)` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库;卡片 240px 大图,菜单条目 80px 小图放在编号行下);封面来自 MusicServer 的 `img` 字段,tx/wy/qs/xm/me/qt/lr 有值,kw/kg 无(这些源条目就不带图) +- **链接统一标准编码**:发出的直链文本、上传给 QQ 的 URL、封面图地址都先转标准百分号编码(`_encode_url`)——空格/中文/引号等非法字符转义,`:/?#&=%` 等结构字符与已有 `%XX` 原样保留(幂等,不会二次编码);Markdown 图片语法里额外转义 `(` `)`(否则会截断 `![]()` 链接) - 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本 - **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音 -- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源 +- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),取直链时附带歌名/歌手/时长(原源取流失败 MusicServer 会先做酷我兜底匹配),bot 侧上传前再探测直链可抓性,不可抓则按 kw/wy 逐个搜索换源;tx/qs 不作候选(qs 匿名只有 30s 试听,登录态直链是 MusicServer 本地解密流代理,QQ 抓不到) +- **文件代理**(`PUBLIC_URL`,配置即开、`FILE_PROXY=off` 可关):bot 起一个 HTTP 服务,上传用与直链文本里的 URL 换成 `/file//<歌名>.mp3`——QQ 服务器或群友来取时才现场重解析直链(含候选源回退)再流式转发字节,自然绕开 QQ 侧抓风控直链失败(40093007)与 qs 本地解密流外网不可达;需 bot 主机公网可达(公网 IP 或端口映射),`PROXY_PORT` 指定监听端口(缺省跟 `PUBLIC_URL` 的端口);服务起不来时自动退回发原直链 - **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留 - **回调防抖与先应答**:按钮回调进门先回应互动(`PUT /interactions`,几秒内不应答 QQ 客户端就报超时),同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;播放/下载先回「正在获取」占位消息再取直链,结果以递增的 `msg_seq` 补发(同一事件相同 `msg_seq` 的第二次回复会被 QQ 拒绝) - **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台 @@ -17,7 +27,7 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk), ## 配置 -复制 `.env.example` 为 `.env`,填入开放平台 `APPID`/`APPSECRET`。`intents.toml` 控制事件订阅。MusicServer(lx-music 聚合音源)需在本机或 `MUSIC_SERVER_URL` 指向处运行。 +复制 `.env.example` 为 `.env`,填入开放平台 `APPID`/`APPSECRET`。`intents.toml` 控制事件订阅。MusicServer(lx-music 聚合音源)需在本机或 `MUSIC_SERVER_URL` 指向处运行——当前版本 9 个平台:kw 酷我/kg 酷狗/tx QQ音乐/wy 网易云/qs 汽水音乐/xm 喜马拉雅/me 猫耳FM/qt 蜻蜓FM/lr 懒人听书,实际可用平台与显示名以服务器 `/sources` 为准(bot 启动时拉取并记日志),服务器增删源后「`<平台>下载`」指令与直链候选源自动跟着变。 点歌相关开关: @@ -26,6 +36,9 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk), | `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) | | `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 | | `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) | +| `PUBLIC_URL` | 空 | 文件代理对外地址(如 `http://1.2.3.4:16689`);配置后文件代理开启 | +| `FILE_PROXY` | `on` | 文件代理开关,仅在 `PUBLIC_URL` 已配置时生效 | +| `PROXY_PORT` | 跟 `PUBLIC_URL` | 文件代理监听端口(无端口则 `16689`);https 反代时须显式配置 | | `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL | | `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) | | `RELOAD` | `off` | 热重载;`on` 等价于命令行 `--reload`(`start.bat` 已带) | @@ -39,11 +52,13 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk), ## 斜杠指令(输入框 `/` 菜单) -群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时把 `点歌`/`歌词`/`帮助` 三条指令同步上去——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。 +群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时同步一次——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。 + +面板条目按 MusicServer `/sources` **现算**:`点歌`/`歌词` + `热搜`/`榜单`(有平台支持才注册)+ 各平台「`<平台>下载`」(search+url 能力齐备才注册,指令名取惯用/短名:酷狗、QQ音乐、网易云……)+ `帮助` 垫底。当前 9 平台下共 14 条(面板接口上限 20 条,条目生成时按 name ≤14 字符、desc ≤30 字符裁剪)。 指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。 -「帮助」消息带 `[🎤 点歌][📝 歌词]` 指令按钮(action `type=2`):点击把 `/点歌 ` 填进输入框,补上歌名发送即可;键盘无权限时自动回退纯文本。 +「帮助」消息带 `[🎤 点歌][📝 歌词][🔥 热搜][🏆 榜单]` 指令按钮(action `type=2`):点击把对应斜杠指令填进输入框,补上歌名(如需要)发送即可;键盘无权限时自动回退纯文本。 ## 运行 diff --git a/musicbot/config.py b/musicbot/config.py index 151ea2c..7a0993b 100644 --- a/musicbot/config.py +++ b/musicbot/config.py @@ -3,10 +3,14 @@ import os from dataclasses import dataclass from pathlib import Path +from urllib.parse import urlsplit # 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首 _MAX_MENU_PAGE_SIZE = 20 +# PUBLIC_URL 不带端口时文件代理的监听端口(MusicServer 默认在 16688) +_DEFAULT_PROXY_PORT = 16689 + _LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL") @@ -28,6 +32,20 @@ def _send_mode() -> str: return raw if raw in ("file", "link") else "link" +def _proxy_port(public_url: str) -> int: + """PROXY_PORT:文件代理的监听端口。 + + 缺省跟随 PUBLIC_URL 里的显式端口(如 http://1.2.3.4:16689 → 16689), + 没写端口用默认(https 域名走反向代理时必须显式配置,bot 本身不收 TLS)。""" + raw = os.getenv("PROXY_PORT", "").strip() + if raw.isdigit(): + return int(raw) + try: + return urlsplit(public_url).port or _DEFAULT_PROXY_PORT + except ValueError: # URL 端口段写坏了 + return _DEFAULT_PROXY_PORT + + @dataclass(frozen=True, slots=True) class BotConfig: music_server_url: str @@ -36,6 +54,9 @@ class BotConfig: menu_page_size: int = 10 # 选歌菜单每页候选数(1~20) withdraw: bool = True # 自动撤回旧菜单/卡片是否开启 send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链 + file_proxy: bool = False # 文件代理开关(FILE_PROXY=on 且配了 PUBLIC_URL 才开) + public_url: str = "" # 文件代理对外基址(PUBLIC_URL,如 http://1.2.3.4:16689) + proxy_port: int = _DEFAULT_PROXY_PORT # 文件代理监听端口(缺省跟 PUBLIC_URL) command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单) log_level: str = "INFO" # 控制台与文件日志级别 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 @@ -44,6 +65,7 @@ class BotConfig: @classmethod def load(cls) -> "BotConfig": page_size = int(os.getenv("MENU_PAGE_SIZE", "10")) + public_url = os.getenv("PUBLIC_URL", "").strip().rstrip("/") return cls( music_server_url=os.getenv("MUSIC_SERVER_URL", "http://127.0.0.1:16688"), music_quality=os.getenv("MUSIC_QUALITY", "128k"), @@ -51,6 +73,9 @@ class BotConfig: menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE), withdraw=_flag("WITHDRAW", True), send_mode=_send_mode(), + file_proxy=_flag("FILE_PROXY", True) and bool(public_url), + public_url=public_url, + proxy_port=_proxy_port(public_url), command_panel=_flag("COMMAND_PANEL", True), log_level=_log_level(), log_dir=Path(os.getenv("LOG_DIR", "logs")), diff --git a/musicbot/main.py b/musicbot/main.py index 1665f7d..9173627 100644 --- a/musicbot/main.py +++ b/musicbot/main.py @@ -7,7 +7,7 @@ from pathlib import Path from aiohttp import ClientSession from loguru import logger -from qqbotsdk import EventEmitter, run_loop +from qqbotsdk import Bot from . import reload from .config import BotConfig @@ -48,11 +48,11 @@ async def amain() -> None: config = BotConfig.load() setup_logging(config) logger.info(f"musicbot 启动,日志级别 {config.log_level}") - ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例 + bot = Bot() # 构造即读 .env 并校验 SDK 配置(APPID/APPSECRET/CONNECTER) # bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束 async with ClientSession() as http: - register(ee, config, http) - await run_loop(ee) + register(bot, config, http) + await bot.start() def _parse_args(argv: list[str] | None) -> argparse.Namespace: diff --git a/musicbot/music.py b/musicbot/music.py index 2692293..48aeedd 100644 --- a/musicbot/music.py +++ b/musicbot/music.py @@ -1,31 +1,45 @@ """点歌业务:MusicServer 搜索、Markdown 选歌菜单、点选后的歌曲卡片、歌词清洗。 -菜单只负责选歌:每页 N 首(MENU_PAGE_SIZE,默认 10)、编号回调按钮 + 上下页。 -点编号 → 弹歌曲卡片(歌名/来源 + [▶播放][⬇下载] 回调按钮),卡片上再点才执行。 -播放发语音、下载发文件,失败均回退直链文本。 +指令:点歌(选歌菜单)、歌词、热搜(热词点一下直接搜)、榜单(榜单点开 +直接是选歌菜单)、「<平台>下载 <歌名>」(如 酷狗下载 稻香,在指定平台 +搜歌出选歌菜单——与点歌同款交互,点编号弹卡片再播放/下载)——平台与 +指令名按 MusicServer /sources 动态生成,服务器加源即自动生效。菜单只负责 +选歌:每页 N 首(MENU_PAGE_SIZE,默认 10)、编号回调按钮 + 上下页。点 +编号 → 弹歌曲卡片(歌名/来源 + [▶播放][⬇下载] 回调按钮),卡片上再点才 +执行。播放发语音、下载发文件,失败均回退直链文本。音频平台的节目级条目 +(猫耳广播剧 d: 前缀 / 懒人书 / 蜻蜓频道)点编号先出选集菜单(/album 接口, +选集再点编号才弹卡片),不会把一个节目当单个音频播。 按钮回调进门先回应互动(防 QQ 客户端等超时),同一互动 uuid / 同群同按钮 10 秒内只处理一次(防抖);播放/下载先回「正在获取」占位,结果再补发。 +群消息去重两把钥匙:msg_id(同帧重推与 self-echo)加「群+发送人+文本」 +10 秒短窗——全量模式下同一条 @消息会同时命中两个事件且 msg_id 不同 +(2026-10-03 实测因此双发过)。 自动撤回(翻页撤旧菜单、选歌后撤菜单、播放/下载后撤卡片)由 WITHDRAW -开关统一控制,默认开。""" +开关统一控制,默认开。 + +配置了 PUBLIC_URL 时启用文件代理(见 proxy.py):上传给 QQ 的与直链文本 +里的 URL 换成 bot 自己的公开地址,请求到来时现场重解析直链再转发字节, +绕开 QQ 服务器抓不动风控直链(40093007)与 qs 本地流外网不可达的问题。""" import re import time import weakref -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any from urllib.parse import quote, unquote from aiohttp import ClientError, ClientSession, ClientTimeout from loguru import logger -from qqbotsdk import EventEmitter +from qqbotsdk import Bot, EventEmitter from qqbotsdk.api import ApiError, BotApi from qqbotsdk.events import Event from qqbotsdk.payloads import GroupAtMessage, Interaction, Ready from .config import BotConfig from .panel import sync_command_panel +from .proxy import FileProxy _SONG_RE = re.compile(r"点歌\s+(\S.*)") _LYRIC_RE = re.compile(r"歌词\s+(\S.*)") @@ -48,19 +62,119 @@ _HELP = ( "♪ 音乐 bot\n" "/点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n" "/歌词 <歌名>:查看歌词\n" + "/热搜:大家都在搜什么,点词直接搜\n" + "/榜单:热门榜单,点开就是选歌菜单\n" + "/<平台>下载 <歌名>:在指定源搜歌出菜单,如 /酷狗下载 稻香\n" "输入框敲 / 也能唤出这些指令" ) -# MusicServer(lx-music 聚合源)实际支持的四个平台,没有 mg(咪咕) +# 搜索/取流失败时统一给用户的提示 +_UNAVAILABLE = "音乐服务暂时不可用,稍后再试试" + +# 平台显示名兜底表:/sources 可达时以服务器报告的 name 为准 _SOURCE_NAMES = { - "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", + "tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "qs": "汽水音乐", + "xm": "喜马拉雅", "me": "猫耳FM", "qt": "蜻蜓FM", "lr": "懒人听书", + "mg": "咪咕音乐", "bd": "千千音乐", "joox": "JOOX", "bili": "哔哩哔哩", + "5sing": "5sing", "apple": "Apple Music", } +# 「<平台>下载」指令的惯用别名(服务器平台名之外的写法); +# apple 的「Apple Music」全名超出面板指令名的宽度上限,靠短名入面板 +_EXTRA_ALIASES = {"wy": ("网易云",), "apple": ("Apple",)} + +_CJK_RE = re.compile(r"[\u4e00-\u9fff]") + + +def _width(text: str) -> int: + """QQ 的长度限制按宽度算:ASCII 计 1,其余(汉字、全角符号)计 2。""" + return sum(1 if ord(c) < 128 else 2 for c in text) + + +def _fit_width(text: str, limit: int) -> str: + """截到宽度上限内(面板 desc 用;指令名不可截断,超宽只能换短名)。""" + chars: list[str] = [] + used = 0 + for c in text: + w = 1 if ord(c) < 128 else 2 + if used + w > limit: + break + chars.append(c) + used += w + return "".join(chars) + + +def _aliases_of(pid: str, name: str) -> list[str]: + """平台指令别名,按 全名 → 短名 → 惯用名 排列:末位最常用。 + + 「酷狗音乐」剥掉尾部「音乐」得短名「酷狗」;词干纯拉丁(如「QQ」) + 不剥,保留全名。wy 惯用名「网易云」垫底。""" + names = [name] + if name.endswith("音乐"): + stem = name[:-2] + if len(stem) >= 2 and _CJK_RE.search(stem): + names.append(stem) + names.extend(_EXTRA_ALIASES.get(pid, ())) + return names + + +# /sources 不可达时的静态平台兜底(id/name/能力位,照服务器现状写死); +# 能力缺 search/url 的平台不参与下载指令与直链回退 +_STATIC_PLATFORMS: dict[str, dict] = { + pid: { + "id": pid, + "name": name, + "capabilities": { + "search": True, + "url": True, + "lyric": pid not in ("xm", "me", "qt", "lr"), + "hotSearch": pid in ("kw", "kg", "tx", "wy"), + "boards": pid in ("kw", "kg", "tx", "wy"), + }, + } + for pid, name in ( + ("tx", "QQ音乐"), ("kw", "酷我音乐"), ("kg", "酷狗音乐"), ("wy", "网易音乐"), + ("qs", "汽水音乐"), ("xm", "喜马拉雅"), ("me", "猫耳FM"), ("qt", "蜻蜓FM"), + ("lr", "懒人听书"), ("mg", "咪咕音乐"), ("bd", "千千音乐"), ("joox", "JOOX"), + ("bili", "哔哩哔哩"), ("5sing", "5sing"), ("apple", "Apple Music"), + ) +} + + +def _cap(platform: dict, cap: str) -> bool: + """平台是否具备某能力位(capabilities 缺失按不具备算)。""" + return bool((platform.get("capabilities") or {}).get(cap)) + + +# 「<平台>下载」匹配状态:别名表 + 编译好的正则,/sources 刷新时重建 +# (platforms() 换缓存时调用),进程启动先用静态表 +_alias_to_source: dict[str, str] = {} +_dl_re: re.Pattern[str] + + +def _apply_aliases(table: dict[str, dict]) -> None: + """按平台表(id → /sources 条目)重建下载指令的别名表与匹配正则。""" + global _alias_to_source, _dl_re + alias: dict[str, str] = {} + for pid, platform in table.items(): + name = platform.get("name") or _SOURCE_NAMES.get(pid, pid) + for a in _aliases_of(pid, name): + alias.setdefault(a, pid) # 撞名时先到先得(服务器顺序) + _alias_to_source = alias + alts = "|".join(re.escape(a) for a in sorted(alias, key=len, reverse=True)) + _dl_re = re.compile(rf"({alts})下载\s+(\S.*)") + + +_apply_aliases(_STATIC_PLATFORMS) + # 内嵌键盘上限:5 行、每行 5 个按钮;歌曲占行 + 1 行翻页 → 每页最多 20 首 _MAX_ROWS = 5 _MAX_BUTTONS_PER_ROW = 5 _MAX_MENU_SONGS = (_MAX_ROWS - 1) * _MAX_BUTTONS_PER_ROW +# 菜单条目的封面小图边长(卡片大图是 240px) +_MENU_COVER_PX = 80 + class MusicServerError(RuntimeError): """MusicServer 不可达或返回非 0 code。""" @@ -82,13 +196,25 @@ class Song: # 播放/下载成功后撤回卡片。撤回与否由 config.withdraw 决定,失败仅记日志 _last_msg: dict[str, str] = {} +# 文件代理实例(PUBLIC_URL 配置时由 register 创建、READY 时启动); +# None = 未启用,发送时用原直链 +_file_proxy: FileProxy | None = None + # 已处理的群消息 id:官方说明同一 msg_id 可能重复推送(需开发者自行排重, -# 否则会给用户回两条),全量模式下 @消息还可能同时命中两个事件,故统一 -# 去重;bot 自己发出的消息 id 也记在这里,其回推(self-echo)按重复跳过 +# 否则会给用户回两条);bot 自己发出的消息 id 也记在这里,其回推 +# (self-echo)按重复跳过 _seen_messages: dict[str, float] = {} _DEDUP_TTL = 300.0 # 秒;被动回复时效是分钟级,重复推送不会来得更晚 _DEDUP_MAX = 1024 # 条;字典按插入序,超限淘汰最旧 +# 消息内容级去重:全量模式下同一条 @消息会同时命中 GROUP_AT_MESSAGE_CREATE +# 与 GROUP_MESSAGE_CREATE,两个事件的 msg_id 不同(2026-10-03 实测同一命令 +# 双占位双直链),按 id 拦不住;钥匙 = 群|发送人|归一化文本(@ 前缀与斜杠 +# 已剥,两个事件归一后一致)。独立成表,不与按钮防抖互挤容量 +_MSG_DUP_TTL = 10.0 # 秒;双事件两帧背靠背到达,窗口只需盖住抖动 +_MSG_DUP_MAX = 1024 # 条;字典按插入序,超限淘汰最旧 +_msg_dup_keys: dict[str, float] = {} + # 按钮回调防抖:互动推送自带 uuid(interaction id),同一次点击可能被 # 平台重复推送(uuid 相同);连点则是不同 uuid、按钮 data 相同——两把 # 钥匙任一在窗口内出现过都只处理第一次 @@ -124,16 +250,23 @@ def _handled(message_id: str) -> bool: return False +def _seen_key(table: dict[str, float], ttl: float, cap: int, key: str) -> bool: + """这把钥匙近期(ttl 内)是否已处理过;未处理则记下并返回 False。 + 表按插入序,超上限淘汰最旧。""" + now = time.monotonic() + _prune(table, ttl, now) + if key in table: + return True + table[key] = now + if len(table) > cap: + del table[next(iter(table))] + 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 + return _seen_key(_recent_clicks, _CLICK_TTL, _CLICK_MAX, key) def _sent_id(resp: Any) -> str | None: @@ -147,7 +280,7 @@ def _sent_id(resp: Any) -> str | None: def _song_of(item: dict[str, Any]) -> Song | None: source = item.get("source") - # id 约定(MusicServer README):kw/tx/wy/mg 用 songmid,kg 用 hash + # id 约定(MusicServer):kw/tx/wy/qs 用 songmid(qs 是 track id),kg 用 hash song_id = item.get("hash") if source == "kg" else item.get("songmid") if not source or not song_id or not item.get("name"): return None @@ -163,6 +296,19 @@ def _song_of(item: dict[str, Any]) -> Song | None: ) +def _songs_of(items: list[dict[str, Any]], source: str = "") -> list[Song]: + """条目列表转 Song。xm/me/qt/lr 的条目不带 source 字段,按本次请求的 + 平台补上(请求级信息是权威的);聚合(source=all)里这类条目无法判源, + 只能丢弃。""" + out: list[Song] = [] + for item in items or []: + if source and source != "all" and not item.get("source"): + item = {**item, "source": source} + if song := _song_of(item): + out.append(song) + return out + + async def _get(http: ClientSession, config: BotConfig, path: str, **params: str) -> Any: """请求 MusicServer 并拆开统一信封,code≠0 抛 MusicServerError。""" try: @@ -182,15 +328,13 @@ async def _get(http: ClientSession, config: BotConfig, path: str, **params: str) async def search( http: ClientSession, config: BotConfig, keyword: str, page: int = 1 ) -> Song | None: - """按配置源搜索(默认 QQ 音乐),没有结果时聚合全源兜底。""" - for source in (config.music_source, "") if config.music_source else ("",): - data = await _get( - http, config, "/search", - name=keyword, source=source, page=str(page), limit="5", - ) - for item in data.get("list") or []: - if song := _song_of(item): - return song + """按配置源搜索(默认 QQ 音乐),没有结果时聚合全源(source=all)兜底。""" + sources = (config.music_source, "all") if config.music_source else ("all",) + for source in sources: + path = f"/search/{source}/{_q(keyword)}/{page}" + data = await _get(http, config, path, limit="5") + if songs := _songs_of(data.get("list"), source): + return songs[0] return None @@ -198,23 +342,52 @@ async def search_list( http: ClientSession, config: BotConfig, keyword: str, page: int = 1 ) -> list[Song]: """按配置源搜索一页候选,配置源没结果时聚合全源兜底(菜单展示用)。""" - for source in (config.music_source, "") if config.music_source else ("",): + sources = (config.music_source, "all") if config.music_source else ("all",) + for source in sources: data = await _get( - http, config, "/search", - name=keyword, source=source, page=str(page), + http, config, f"/search/{source}/{_q(keyword)}/{page}", limit=str(config.menu_page_size), ) - songs = [s for item in data.get("list") or [] if (s := _song_of(item))] - if songs: + if songs := _songs_of(data.get("list"), source): return songs return [] +async def search_source_list( + http: ClientSession, + config: BotConfig, + keyword: str, + source: str, + page: int = 1, +) -> list[Song]: + """在指定平台搜一页候选(「<平台>下载」的菜单用);不聚合兜底—— + 用户点名了平台,兜底到别的源就失了本意。""" + data = await _get( + http, config, f"/search/{source}/{_q(keyword)}/{page}", + limit=str(config.menu_page_size), + ) + return _songs_of(data.get("list"), source) + + async def _play_url(http: ClientSession, config: BotConfig, song: Song) -> str: - params = {"quality": config.music_quality} + # 附带歌名/歌手/时长:原源取流失败时,服务器按这些信息做酷我兜底匹配 + # (kg 可由 hash 反查歌曲信息,只补专辑 id) + params = { + "quality": config.music_quality, + "name": song.name, + "singer": song.singer, + "interval": song.interval or "", + } if song.source == "kg" and song.album_id: params["albumId"] = song.album_id data = await _get(http, config, f"/url/{song.source}/{song.song_id}", **params) + # 新版响应带实际命中信息:via=兜底通道(kw/script),type=实际音质(可能降级) + via = data.get("via") + if via and via != song.source: + logger.info( + f"「{song.name}」直链经兜底通道取得:{song.source}→{via}" + f"({data.get('type') or config.music_quality})" + ) return data["url"] @@ -243,6 +416,16 @@ def _caption(song: Song) -> str: return f"♪ {song.name} - {song.singer}" +# Windows 文件名与 HTTP 头都不欢迎的字符,代理 URL 尾段前先清掉 +_FILENAME_BAD = re.compile(r'[\\/:*?"<>|\r\n]') + + +def _filename(song: Song) -> str: + """代理 URL 尾段的下载文件名(带扩展名,展示与保存用)。""" + base = f"{song.name} - {song.singer}".strip(" -") or song.song_id + return _FILENAME_BAD.sub("_", base) + ".mp3" + + def _button(bid: str, label: str, data: str) -> dict: """内嵌回调按钮(type=1):点击推 INTERACTION_CREATE 给 bot。""" return { @@ -256,6 +439,21 @@ def _q(s: str) -> str: return quote(s, safe="") +# 标准百分号编码时保留的 URL 结构字符(RFC 3986 reserved + %);% 保留使 +# 已编码的 URL 幂等,不会二次编码 +_URL_SAFE = ":/?#[]@!$&'()*+,;=%" +# Markdown 图片语法的 URL 里 ( ) 会提前截断链接、空格会截断属性,故不含 ( ) +_MD_URL_SAFE = ":/?#[]@!$&'*+,;=%" + + +def _encode_url(url: str, safe: str = _URL_SAFE) -> str: + """把 URL 转成标准百分号编码:只转义空格、中文、引号等 URL 非法字符, + 结构字符与已有 %XX 原样保留(幂等,重复调用结果一致)。发出链接前 + 统一走这里——源站直链/封面地址常带未编码字符,QQ 侧抓取与群友复制 + 都会被截断。""" + return quote(url, safe=safe) + + def _cmd_button(bid: str, label: str, data: str) -> dict: """指令按钮(type=2):点击把 data 填进聊天输入框,用户补上参数再发送, 等价于手敲斜杠指令——不产生回调,bot 收到的是普通消息。""" @@ -385,11 +583,43 @@ def _card_keyboard(song: Song) -> dict: } -def _menu_keyboard( - songs: list[Song], keyword: str, page: int, page_size: int -) -> dict: - """选歌键盘:编号回调按钮每行 5 个(点一下即弹卡片),末行 [上一页][下一页]。 - 首页无上一页;是否给下一页以"本页满员"判断(搜索结果不足一页即到底)。""" +async def _post_markdown( + api: BotApi, + group_openid: str, + lines: list[str], + fallback: str, + keyboard: dict, + msg_id: str | None = None, + event_id: str | None = None, +) -> Any: + """发一条 Markdown 消息(带键盘),无权限等失败回退纯文本(键盘保留); + 两者都失败返回 None。""" + try: + return await api.post_group_message( + group_openid, + markdown="\n".join(lines), + keyboard=keyboard, + msg_id=msg_id, + event_id=event_id, + ) + except _API_FAILS as e: + logger.warning(f"Markdown 发送失败,回退文本: {e!r}") + try: + return await api.post_group_message( + group_openid, + content=fallback, + keyboard=keyboard, + msg_id=msg_id, + event_id=event_id, + ) + except _API_FAILS as e: + logger.warning(f"文本发送失败: {e!r}") + return None + + +def _menu_keyboard(songs: list[Song], nav: list[dict]) -> dict: + """选歌键盘:编号回调按钮每行 5 个(点一下即弹卡片),nav 是调用方 + 组好的翻页按钮行(上一页/下一页,可为空)。""" rows: list[list[dict]] = [] row: list[dict] = [] for i, song in enumerate(songs[:_MAX_MENU_SONGS], 1): @@ -399,16 +629,102 @@ def _menu_keyboard( row = [] if row: rows.append(row) - nav = [] - if page > 1: - nav.append(_button("prev", "上一页", f"音乐|page|{_q(keyword)}|{page - 1}")) - if len(songs) >= page_size: - nav.append(_button("next", "下一页", f"音乐|page|{_q(keyword)}|{page + 1}")) if nav: rows.append(nav) return {"content": {"rows": [{"buttons": r} for r in rows]}} +def _search_nav( + keyword: str, page: int, more: bool, source: str | None = None +) -> list[dict]: + """搜索菜单的翻页按钮(是否给下一页以"本页满员"判断);source 非空是 + 「<平台>下载」的平台内菜单,翻页走 spage 动作、携带平台锁定同一源。""" + action = "spage" if source else "page" + head = f"音乐|{action}" + (f"|{source}" if source else "") + nav = [] + if page > 1: + nav.append(_button("prev", "上一页", f"{head}|{_q(keyword)}|{page - 1}")) + if more: + nav.append(_button("next", "下一页", f"{head}|{_q(keyword)}|{page + 1}")) + return nav + + +def _board_nav( + source: str, board_id: str, name: str, page: int, more: bool +) -> list[dict]: + """榜单菜单的翻页按钮(携带榜单上下文,翻页后仍取同一榜单)。""" + nav = [] + if page > 1: + prev = _board_data(source, board_id, name, page - 1) + nav.append(_button("prev", "上一页", prev)) + if more: + nxt = _board_data(source, board_id, name, page + 1) + nav.append(_button("next", "下一页", nxt)) + return nav + + +def _board_data(source: str, board_id: str, name: str, page: int) -> str: + """榜单回调按钮携带的数据:源、榜单 id、榜单名(% 转义)与页码。""" + return f"音乐|board|{source}|{board_id}|{_q(name)}|{page}" + + +def _track_data(source: str, album_id: str, name: str, page: int) -> str: + """选集翻页按钮携带的数据:源、节目 id、节目名(% 转义)与页码。""" + return f"音乐|track|{source}|{album_id}|{_q(name)}|{page}" + + +def _track_nav( + source: str, album_id: str, name: str, page: int, more: bool +) -> list[dict]: + """选集菜单的翻页按钮(携带节目上下文,翻页仍取同一节目的选集)。""" + nav = [] + if page > 1: + prev = _track_data(source, album_id, name, page - 1) + nav.append(_button("prev", "上一页", prev)) + if more: + nxt = _track_data(source, album_id, name, page + 1) + nav.append(_button("next", "下一页", nxt)) + return nav + + +async def _post_menu( + api: BotApi, + config: BotConfig, + group_openid: str, + title: str, + songs: list[Song], + nav: list[dict], + msg_id: str | None = None, + event_id: str | None = None, +) -> None: + """渲染并发送一张选歌菜单:Markdown 歌单(编号 + 歌名/歌手 + 封面小图) + + 编号回调按钮 + 翻页行,Markdown 失败回退纯文本(按钮保留、不含图片 + 语法);发送后撤同群上一张菜单/卡片。""" + shown = songs[:_MAX_MENU_SONGS] + lines = [f"## {title}"] + texts: list[str] = [] # 纯文本回退行:不含封面图 + for i, song in enumerate(shown, 1): + entry = f"{song.name} - {song.singer}".strip(" -") + texts.append(f"{i}. {entry}") + lines.append(f"{i}. {entry}") + if song.img: + # 与卡片同款图片语法(QQ 后台代理并缓存图片),缩小为条目小图; + # kw/kg 等无封面源的条目自然跳过;URL 转义见 _encode_url + lines.append( + f"![封面 #{_MENU_COVER_PX}px #{_MENU_COVER_PX}px]" + f"({_encode_url(song.img, _MD_URL_SAFE)})" + ) + resp = await _post_markdown( + api, group_openid, lines, + "\n".join(texts) + "\n(点下方编号选歌)", + _menu_keyboard(shown, nav), msg_id, event_id, + ) + if resp is None: + return + await _swap_message(api, config, group_openid, resp) + logger.info(f"[{group_openid}] 菜单已发:{title}({len(shown)}首)") + + async def send_menu( api: BotApi, http: ClientSession, @@ -418,49 +734,39 @@ async def send_menu( page: int = 1, msg_id: str | None = None, event_id: str | None = None, + source: str | None = None, ) -> None: - """搜索并展示选歌菜单:Markdown 歌单(编号 + 歌名/歌手/来源)+ 编号回调按钮 - (每行 5 个)+ 上下页。菜单只负责选歌——不放播放/下载,点编号后由 send_card - 弹卡片。Markdown 失败回退纯文本(按钮保留)。""" + """搜索并展示选歌菜单(渲染见 _post_menu)。source 为空走配置源、无果 + 聚合兜底(点歌/热搜词);source 非空是「<平台>下载」的平台内搜索, + 不兜底,标题带平台名、翻页锁定该平台。""" try: - songs = await search_list(http, config, keyword, page) + if source: + songs = await search_source_list(http, config, keyword, source, page) + else: + songs = await search_list(http, config, keyword, page) except MusicServerError as e: logger.warning(f"搜索失败: {e}") await _reply( - api, group_openid, "音乐服务暂时不可用,稍后再试试", msg_id, event_id + api, group_openid, _UNAVAILABLE, msg_id, event_id ) return if not songs: - await _reply(api, group_openid, f"没找到「{keyword}」", msg_id, event_id) - return - lines = [f"## 点歌:{keyword}"] - for i, song in enumerate(songs, 1): - title = f"{song.name} - {song.singer}".strip(" -") - lines.append(f"{i}. {title}") - keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size) - try: - resp = await api.post_group_message( - group_openid, - markdown="\n".join(lines), - keyboard=keyboard, - msg_id=msg_id, - event_id=event_id, - ) - except _API_FAILS as e: - logger.warning(f"Markdown 菜单发送失败,回退文本: {e!r}") - try: - resp = await api.post_group_message( - group_openid, - content="\n".join(lines[1:]) + "\n(点下方编号选歌)", - keyboard=keyboard, - msg_id=msg_id, - event_id=event_id, + if source: + name = await _source_display(http, config, source) + await _reply( + api, group_openid, f"在{name}没找到「{keyword}」", msg_id, event_id ) - except _API_FAILS as e: - logger.warning(f"文本菜单发送失败: {e!r}") - return - await _swap_message(api, config, group_openid, resp) - logger.info(f"[{group_openid}] 菜单已发:{keyword} 第{page}页 {len(songs)}首") + else: + await _reply(api, group_openid, f"没找到「{keyword}」", msg_id, event_id) + return + title = f"点歌:{keyword}" + if source: + title = f"{await _source_display(http, config, source)}:{keyword}" + await _post_menu( + api, config, group_openid, title, songs, + _search_nav(keyword, page, len(songs) >= config.menu_page_size, source), + msg_id, event_id, + ) async def send_card( @@ -477,36 +783,22 @@ async def send_card( title = f"{song.name} - {song.singer}".strip(" -") lines = [f"## {title}"] if song.img: - # QQ 后台会代理并缓存图片到客户端(不落库),宽高按 #宽px #高px 指定 - lines.append(f"![封面 #240px #240px]({song.img})") + # QQ 后台会代理并缓存图片到客户端(不落库),宽高按 #宽px #高px 指定; + # URL 转义见 _encode_url + lines.append(f"![封面 #240px #240px]({_encode_url(song.img, _MD_URL_SAFE)})") details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"] if song.album: details.append(f"专辑:{song.album}") if song.interval: details.append(f"时长:{song.interval}") lines.append(" ".join(details)) - keyboard = _card_keyboard(song) - try: - resp = await api.post_group_message( - group_openid, - markdown="\n".join(lines), - keyboard=keyboard, - msg_id=msg_id, - event_id=event_id, - ) - except _API_FAILS as e: - logger.warning(f"Markdown 卡片发送失败,回退文本: {e!r}") - try: - resp = await api.post_group_message( - group_openid, - content="\n".join(lines[1:]) + "\n(点下方按钮播放/下载)", - keyboard=keyboard, - msg_id=msg_id, - event_id=event_id, - ) - except _API_FAILS as e: - logger.warning(f"文本卡片发送失败: {e!r}") - return + resp = await _post_markdown( + api, group_openid, lines, + "\n".join(lines[1:]) + "\n(点下方按钮播放/下载)", + _card_keyboard(song), msg_id, event_id, + ) + if resp is None: + return await _swap_message(api, config, group_openid, resp) logger.info(f"[{group_openid}] 选歌:{title}") @@ -542,7 +834,8 @@ async def send_song( 直链、根本不尝试上传。 配置源直链抓不动时 QQ 服务器侧下载同样会失败(40093007),故先探测 - 可抓性、必要时换候选源;keyword 用于候选源搜索。 + 可抓性、必要时换候选源;keyword 用于候选源搜索。文件代理启用时,最终 + 发出的 url(上传与直链文本同源)换成 bot 自己的公开地址。 msg_seq 是本事件被动回复的序号:调用方若已先回过占位消息,须传 2 (同一 msg_id/event_id 相同 msg_seq 的第二次回复会被 QQ 拒绝)。 @@ -558,6 +851,14 @@ async def send_song( ) return _, url = found + if _file_proxy is not None and _file_proxy.ready: + # 换成 bot 自己的公开 URL:QQ 服务器抓风控直链 / qs 本地流会失败, + # 经 bot 中转可抓。请求到来时现场重解析(直链有时效,刚探测的这条 + # 不能复用)。服务没起来(端口被占)则不换链、仍发原直链。 + url = _file_proxy.claim(song, keyword, _filename(song)) + # 发给 QQ 前统一转标准百分号编码:源站直链常带空格/中文等未编码字符, + # 上传与直链文本用同一条(代理 URL 里的文件名已是编码态,此处幂等) + url = _encode_url(url) # 下载在 link 模式下跳过上传(群文件上传仅限群主/管理员,40093005) skip_upload = not voice and config.send_mode == "link" no_permission = False @@ -608,7 +909,17 @@ async def send_lyric( msg_id: str | None = None, event_id: str | None = None, ) -> None: - text = await _lyric(http, config, song) + try: + text = await _lyric(http, config, song) + except MusicServerError as e: + # xm/me/qt/lr 等音频平台没有歌词能力,服务器报非 0 code + logger.warning(f"取歌词失败({song.source}): {e}") + name = _SOURCE_NAMES.get(song.source, song.source) + await _reply( + api, group_openid, f"「{name}」这条没有歌词", + msg_id, event_id, + ) + return if not text.strip(): await _reply( api, group_openid, f"没找到「{song.name}」的歌词", msg_id, event_id @@ -617,20 +928,6 @@ async def send_lyric( await _reply(api, group_openid, _trim(text), msg_id, event_id) -def _help_keyboard() -> dict: - """帮助键盘:[🎤 点歌][📝 歌词] 指令按钮,点一下把斜杠指令填进输入框。""" - return { - "content": { - "rows": [ - {"buttons": [ - _cmd_button("help-cmd-song", "🎤 点歌", "/点歌 "), - _cmd_button("help-cmd-lyric", "📝 歌词", "/歌词 "), - ]} - ] - } - } - - async def _send_help(api: BotApi, group_openid: str, msg_id: str) -> None: """发帮助:正文 + 指令按钮(输入框 / 菜单的入口);按钮被拒回退纯文本。""" try: @@ -650,15 +947,338 @@ async def _send_help(api: BotApi, group_openid: str, msg_id: str) -> None: _mark(sent) +def _help_keyboard() -> dict: + """帮助键盘:指令按钮,点一下把斜杠指令填进输入框。""" + return { + "content": { + "rows": [ + {"buttons": [ + _cmd_button("help-cmd-song", "🎤 点歌", "/点歌 "), + _cmd_button("help-cmd-lyric", "📝 歌词", "/歌词 "), + _cmd_button("help-cmd-hot", "🔥 热搜", "/热搜"), + _cmd_button("help-cmd-board", "🏆 榜单", "/榜单"), + ]} + ] + } + } + + +async def _source_display(http: ClientSession, config: BotConfig, source: str) -> str: + """平台显示名:优先 /sources 报告的 name,缺省退回静态表。""" + table = await platforms(http, config) + platform = table.get(source) + if platform and platform.get("name"): + return platform["name"] + return _SOURCE_NAMES.get(source, source) + + +async def _feature_source( + http: ClientSession, config: BotConfig, cap: str +) -> tuple[str, str] | None: + """挑一个具备指定能力(hotSearch/boards)的平台:配置源优先,否则按 + 服务器顺序取第一个;/sources 不可达时退回静态表。返回 (平台 id, 显示名)。""" + table = await platforms(http, config) or _STATIC_PLATFORMS + if config.music_source in table and _cap(table[config.music_source], cap): + pid = config.music_source + else: + pid = next((p for p in table if _cap(table[p], cap)), "") + if not pid: + return None + name = table[pid].get("name") or _SOURCE_NAMES.get(pid, pid) + return pid, name + + +async def _log_login_status(http: ClientSession, config: BotConfig) -> None: + """READY 时报告各平台登录态(部分源登录后才有高音质/完整曲库)。""" + try: + data = await _get(http, config, "/login/status") + except MusicServerError: + return # 老版 MusicServer 没有该接口,不报 + logged = [ + pid for pid, v in (data or {}).items() + if isinstance(v, dict) and v.get("logged") + ] + state = " ".join(logged) if logged else "全部未登录" + logger.info(f"MusicServer 登录态:{state}") + + +async def send_hot( + api: BotApi, + http: ClientSession, + config: BotConfig, + group_openid: str, + msg_id: str | None = None, + event_id: str | None = None, +) -> None: + """热搜:拉某平台的热搜词,编号列表 + 词按钮,点词直接出选歌菜单。""" + picked = await _feature_source(http, config, "hotSearch") + if picked is None: + await _reply(api, group_openid, "当前音源不支持热搜", msg_id, event_id) + return + source, name = picked + try: + data = await _get(http, config, f"/hot-search/{source}") + except MusicServerError as e: + logger.warning(f"取热搜失败: {e}") + await _reply(api, group_openid, _UNAVAILABLE, msg_id, event_id) + return + words = [w for w in data.get("list") or [] if w][:_MAX_MENU_SONGS] + if not words: + await _reply(api, group_openid, "没拿到热搜词", msg_id, event_id) + return + rows: list[list[dict]] = [] + row: list[dict] = [] + for i, word in enumerate(words, 1): + row.append(_button(f"hot-{i}", word, f"音乐|hot|{_q(word)}")) + if len(row) == _MAX_BUTTONS_PER_ROW: + rows.append(row) + row = [] + if row: + rows.append(row) + lines = [f"## {name}热搜"] + [f"{i}. {w}" for i, w in enumerate(words, 1)] + resp = await _post_markdown( + api, group_openid, lines, "\n".join(lines[1:]) + "\n(点下方热词直接搜)", + {"content": {"rows": [{"buttons": r} for r in rows]}}, msg_id, event_id, + ) + if resp is None: + return + await _swap_message(api, config, group_openid, resp) + logger.info(f"[{group_openid}] 热搜已发:{name} {len(words)}词") + + +async def send_boards( + api: BotApi, + http: ClientSession, + config: BotConfig, + group_openid: str, + msg_id: str | None = None, + event_id: str | None = None, +) -> None: + """榜单:拉某平台的榜单列表,点榜单名直接出该榜单的选歌菜单。""" + picked = await _feature_source(http, config, "boards") + if picked is None: + await _reply(api, group_openid, "当前音源不支持榜单", msg_id, event_id) + return + source, name = picked + try: + data = await _get(http, config, f"/boards/{source}") + except MusicServerError as e: + logger.warning(f"取榜单列表失败: {e}") + await _reply(api, group_openid, _UNAVAILABLE, msg_id, event_id) + return + boards = [ + b for b in data.get("list") or [] if b.get("id") and b.get("name") + ][:_MAX_MENU_SONGS] + if not boards: + await _reply(api, group_openid, "没拿到榜单", msg_id, event_id) + return + rows: list[list[dict]] = [] + row: list[dict] = [] + for i, board in enumerate(boards, 1): + label = board["name"][:8] # 按钮标签过长会被 QQ 截断,先自己裁 + btn_data = _board_data(source, board["id"], board["name"], 1) + row.append(_button(f"board-{i}", label, btn_data)) + if len(row) == _MAX_BUTTONS_PER_ROW: + rows.append(row) + row = [] + if row: + rows.append(row) + lines = [f"## {name}榜单"] + [f"{i}. {b['name']}" for i, b in enumerate(boards, 1)] + resp = await _post_markdown( + api, group_openid, lines, "\n".join(lines[1:]) + "\n(点下方榜单直接听)", + {"content": {"rows": [{"buttons": r} for r in rows]}}, msg_id, event_id, + ) + if resp is None: + return + await _swap_message(api, config, group_openid, resp) + logger.info(f"[{group_openid}] 榜单列表已发:{name} {len(boards)}个") + + +def _album_id_of(song: Song) -> str | None: + """节目级条目(一个节目多个音频)的选集 id;单曲级条目返回 None。 + + me 搜索里节目条目的 songmid 带 d: 前缀(d:44753 → 选集 id 44753), + 声音条目是普通 id;lr/qt 搜索返回的整条都是书/频道(id 即选集 id, + 直接取链 502);xm 返回单集级(带真实时长),不在此列。""" + if song.source == "me" and song.song_id.startswith("d:"): + return song.song_id[2:] + if song.source in ("lr", "qt"): + return song.song_id + return None + + +# /album 的翻页行为按源不同(2026-10-03 实测):me 忽略 page 参数、一次给足 +# limit(50 可用、200 报 422)→ 本地切片分页;lr/qt 服务端翻页正常 → 直取 +# 当页。me 超过 50 集的节目会被截断(猫耳广播剧分季、很少超过) +_ALBUM_LOCAL_PAGE = frozenset({"me"}) +_ALBUM_FETCH_LIMIT = 50 + + +async def send_track_menu( + api: BotApi, + http: ClientSession, + config: BotConfig, + group_openid: str, + source: str, + album_id: str, + name: str, + page: int = 1, + event_id: str | None = None, +) -> None: + """拉节目的选集列表并出选集菜单(渲染同选歌菜单):点编号弹该集卡片、 + 再点播放/下载。选集条目的歌手就是节目名(展示冗余)、时长常为 0,都抹掉。""" + try: + if source in _ALBUM_LOCAL_PAGE: + data = await _get( + http, config, f"/album/{source}/{album_id}", + limit=str(_ALBUM_FETCH_LIMIT), + ) + episodes = _songs_of(data.get("list"), source) + # 服务器截断(>50 集)时以实拿为准,别让翻页按钮指向空页 + total = data.get("total") + if not (isinstance(total, int) and total <= len(episodes)): + total = len(episodes) + start = (page - 1) * config.menu_page_size + songs = episodes[start:start + config.menu_page_size] + more = total > page * config.menu_page_size + else: + data = await _get( + http, config, f"/album/{source}/{album_id}/{page}", + limit=str(config.menu_page_size), + ) + songs = _songs_of(data.get("list"), source) + total = data.get("total") + more = ( + total > page * config.menu_page_size + if isinstance(total, int) + else len(songs) >= config.menu_page_size + ) + except MusicServerError as e: + logger.warning(f"取选集失败({source}): {e}") + await _reply(api, group_openid, _UNAVAILABLE, event_id=event_id) + return + if not songs: + if page > 1: + await _reply(api, group_openid, "没有更多了", event_id=event_id) + else: + await _reply( + api, group_openid, + f"「{name}」拿不到剧集列表(可能已下线或需要登录)", + event_id=event_id, + ) + return + songs = [ + replace( + s, + singer="" if s.singer == name else s.singer, + interval=None if s.interval in ("", "0") else s.interval, + ) + for s in songs + ] + title = f"选集:{name}" + if isinstance(total, int): + title += f"(共{total}集)" + await _post_menu( + api, config, group_openid, title, songs, + _track_nav(source, album_id, name, page, more), + event_id=event_id, + ) + + +async def send_board_menu( + api: BotApi, + http: ClientSession, + config: BotConfig, + group_openid: str, + source: str, + board_id: str, + board_name: str, + page: int, + msg_id: str | None = None, + event_id: str | None = None, +) -> None: + """展示某榜单某页的选歌菜单(渲染同搜索菜单,翻页携带榜单上下文)。""" + try: + data = await _get( + http, config, f"/boards/{source}/{board_id}/{page}", + limit=str(config.menu_page_size), + ) + except MusicServerError as e: + logger.warning(f"取榜单曲目失败: {e}") + await _reply(api, group_openid, _UNAVAILABLE, msg_id, event_id) + return + songs = _songs_of(data.get("list"), source) + if not songs: + await _reply(api, group_openid, "榜单暂时拿不到", msg_id, event_id) + return + # 服务器给了总数就按总数判断翻页,没给则退回"本页满员"启发式 + total = data.get("total") + more = ( + total > page * config.menu_page_size + if isinstance(total, int) + else len(songs) >= config.menu_page_size + ) + await _post_menu( + api, config, group_openid, f"榜单:{board_name}", songs, + _board_nav(source, board_id, board_name, page, more), + msg_id, event_id, + ) + + +async def panel_items(http: ClientSession, config: BotConfig) -> list[dict]: + """指令面板条目:基础指令 + 热搜/榜单(有平台支持才注册)+ 各平台 + 「<平台>下载」(search+url 齐备才注册),帮助垫底。平台表来自 + /sources(不可达退静态表),服务器增删源自动跟着变。 + + 接口长度限制按宽度算(ASCII 1、汉字 2):name ≤14、desc ≤30。 + 指令名超宽时依次换更短的别名(如 Apple Music → Apple), + 换无可换只能放弃该条;「帮助」永远垫底,不被 20 条上限挤掉。""" + table = await platforms(http, config) or _STATIC_PLATFORMS + items = [ + {"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"}, + {"name": "歌词", "type": "command", "desc": "查看歌词"}, + ] + if any(_cap(p, "hotSearch") for p in table.values()): + items.append({ + "name": "热搜", "type": "command", "desc": "大家都在搜什么,点词直接搜", + }) + if any(_cap(p, "boards") for p in table.values()): + items.append({ + "name": "榜单", "type": "command", "desc": "热门榜单,点开就是选歌菜单", + }) + for pid, platform in table.items(): + if not (_cap(platform, "search") and _cap(platform, "url")): + continue + name = platform.get("name") or _SOURCE_NAMES.get(pid, pid) + # 指令名取装得下面板宽度上限的最顺口别名(惯用名 > 短名 > 全名), + # 如 酷狗/QQ音乐/网易云/Apple + cmd = next( + (a for a in reversed(_aliases_of(pid, name)) if _width(f"{a}下载") <= 14), + None, + ) + if cmd is None: + continue + items.append({ + "name": f"{cmd}下载", "type": "command", + "desc": f"在{name}搜歌出选歌菜单", + }) + # 面板接口上限 20 条:给「帮助」留最后一位,下载指令多时截前面 + items = items[:19] + items.append({"name": "帮助", "type": "command", "desc": "查看帮助"}) + return [ + {**item, "desc": _fit_width(item["desc"], 30)} + for item in items + if _width(item["name"]) <= 14 + ] + + async def _search_source( http: ClientSession, config: BotConfig, keyword: str, source: str ) -> Song | None: """在指定源内搜索(不触发聚合兜底)。""" - data = await _get(http, config, "/search", name=keyword, source=source, limit="5") - for item in data.get("list") or []: - if song := _song_of(item): - return song - return None + data = await _get(http, config, f"/search/{source}/{_q(keyword)}", limit="5") + songs = _songs_of(data.get("list"), source) + return songs[0] if songs else None # 直链回退源的优先顺序:tx 等源直链常受 CDN 风控抓不动,kw 实测可抓。 @@ -666,9 +1286,10 @@ async def _search_source( # 先按此优先序排,其余平台追加在后,服务器增删源无需改代码。 # tx 的 url 能力位只代表「服务器能给直链」,不代表直链抓得动——它是 # 风控惯犯(回退机制的本因),绝不作为候选源,否则回退末尾几乎必然 -# 白探测一次 +# 白探测一次。qs 同理不进候选:匿名只有 30s 试听,登录态直链是 +# MusicServer 本地解密流代理(/stream/qs,QQ 服务器抓不到) _SOURCE_PREFERENCE = ("kw", "wy") -_NO_FALLBACK_SOURCES = frozenset({"tx"}) +_NO_FALLBACK_SOURCES = frozenset({"tx", "qs"}) # /sources 的进程内缓存,按 http 会话隔离(config 是 frozen 的挂不了缓存)。 # 值为 (到期时刻, 平台表);平台表为 None 表示上游不可用,用短 TTL 防止 @@ -695,6 +1316,8 @@ async def platforms(http: ClientSession, config: BotConfig) -> dict[str, dict]: return {} table = {p["id"]: p for p in data.get("platforms") or [] if p.get("id")} _platforms_cache[http] = (now + _PLATFORMS_TTL, table) + # 顺带刷新「<平台>下载」别名表:服务器增删平台/改名即自动跟进 + _apply_aliases(table) return table @@ -707,7 +1330,7 @@ async def _fallback_sources( 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 _cap(p, "search") and _cap(p, "url") ] if not capable: return [s for s in _SOURCE_PREFERENCE if s != song.source] @@ -758,8 +1381,10 @@ async def handle_button( data: str, event_id: str | None = None, ) -> bool: - """处理内嵌按钮回调:page 翻页 / pick 选歌弹卡片 / play 发语音 / dl 发文件; - 返回是否为本 bot 的按钮(非本 bot 的按钮不处理也不回应)。 + """处理内嵌按钮回调:page 翻页 / spage 平台内翻页(「<平台>下载」菜单)/ + hot 热搜词搜歌 / board 榜单翻页 / track 选集翻页 / pick 选歌(歌曲直接 + 弹卡片;节目级条目先出选集菜单)/ play 发语音 / dl 发文件;返回是否为 + 本 bot 的按钮(非本 bot 的按钮不处理也不回应)。 interaction_id 仅用于回应互动(PUT /interactions);消息被动回复的 event_id 须用事件最外层 id("INTERACTION_CREATE:"),用 d.id @@ -773,7 +1398,15 @@ async def handle_button( 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): + known = ( + (action == "page" and len(args) == 2) + or (action == "spage" and len(args) == 3) + or (action == "hot" and len(args) == 1) + or (action == "board" and len(args) == 4) + or (action == "track" and len(args) == 4) + or is_song + ) + if not known: return False # 非本 bot 的按钮 # 防抖:uuid 重复推送说明已处理并应答过,直接跳过;连点是新 uuid, @@ -804,11 +1437,44 @@ async def handle_button( api, http, config, group_openid, keyword, page, event_id=event_id, ) - elif action == "pick": - await send_card( - api, http, config, group_openid, _song_of_data(args), + elif action == "spage": + source, keyword, page = args[0], unquote(args[1]), int(args[2]) + await send_menu( + api, http, config, group_openid, keyword, page, + event_id=event_id, source=source, + ) + elif action == "hot": + await send_menu( + api, http, config, group_openid, unquote(args[0]), event_id=event_id, ) + elif action == "board": + source, board_id = args[0], args[1] + await send_board_menu( + api, http, config, group_openid, + source, board_id, unquote(args[2]), int(args[3]), + event_id=event_id, + ) + elif action == "track": + source, album_id = args[0], args[1] + await send_track_menu( + api, http, config, group_openid, + source, album_id, unquote(args[2]), int(args[3]), + event_id=event_id, + ) + elif action == "pick": + song = _song_of_data(args) + if album_id := _album_id_of(song): + # 节目级条目(猫耳广播剧 / 懒人书 / 蜻蜓频道):先出选集菜单 + await send_track_menu( + api, http, config, group_openid, + song.source, album_id, song.name, event_id=event_id, + ) + else: + await send_card( + api, http, config, group_openid, song, + event_id=event_id, + ) else: # play / dl(进门已校验 is_song) song = _song_of_data(args) try: @@ -824,7 +1490,7 @@ async def handle_button( except (MusicServerError, ValueError) as e: logger.warning(f"按钮处理失败: {e}") await _soft_reply( - api, group_openid, "音乐服务暂时不可用,稍后再试试", event_id, seq + api, group_openid, _UNAVAILABLE, event_id, seq ) except _API_FAILS as e: logger.warning(f"按钮处理失败: {e!r}") @@ -844,7 +1510,7 @@ async def _find( song = await search(http, config, keyword) except MusicServerError as e: logger.warning(f"搜索失败: {e}") - await _reply(api, group_openid, "音乐服务暂时不可用,稍后再试试", msg_id=msg_id) + await _reply(api, group_openid, _UNAVAILABLE, msg_id=msg_id) return None if song is None: await _reply(api, group_openid, f"没找到「{keyword}」", msg_id=msg_id) @@ -871,8 +1537,9 @@ async def handle_command( ) -> bool: """解析一条群消息里的音乐命令并执行;返回是否命中。 - 裸文本(点歌/歌词/帮助)、@机器人 与斜杠指令(/点歌 …)共用这里; - 选歌/播放/下载由内嵌按钮(回调)承担,这里只处理文字命令。""" + 裸文本(点歌/歌词/热搜/榜单/帮助/<平台>下载)、@机器人 与斜杠指令 + (/点歌 …)共用这里;选歌/播放/下载由内嵌按钮(回调)承担,这里只 + 处理文字命令。""" text = _normalize(content) if match := _SONG_RE.search(text): keyword = match.group(1).strip() @@ -891,6 +1558,29 @@ async def handle_command( if text == "歌词": await _reply(api, group_openid, "用法:歌词 <歌名>", msg_id=msg_id) return True + if match := _dl_re.search(text): + # 「<平台>下载 <歌名>」:在指定平台搜歌出选歌菜单(与点歌同款交互, + # 点编号弹卡片再播放/下载);别名表来自 /sources(platforms 刷新时重建) + alias, keyword = match.group(1), match.group(2).strip() + source = _alias_to_source[alias] + logger.info(f"[{group_openid}] 指定源点歌({source}):{keyword}") + await send_menu( + api, http, config, group_openid, keyword, msg_id=msg_id, source=source, + ) + return True + if text.endswith("下载") and text[:-2] in _alias_to_source: + await _reply( + api, group_openid, f"用法:{text[:-2]}下载 <歌名>", msg_id=msg_id + ) + return True + if text == "热搜": + logger.info(f"[{group_openid}] 看热搜") + await send_hot(api, http, config, group_openid, msg_id=msg_id) + return True + if text == "榜单": + logger.info(f"[{group_openid}] 看榜单") + await send_boards(api, http, config, group_openid, msg_id=msg_id) + return True if text == "帮助" or "音乐帮助" in text: logger.info(f"[{group_openid}] 查看帮助") await _send_help(api, group_openid, msg_id) @@ -905,7 +1595,8 @@ async def handle_group_message( msg: GroupAtMessage, ) -> bool: """处理一条群消息(@机器人 与全量模式共用):跳过机器人自己的消息和 - 重复推送,再交给 handle_command;返回是否命中音乐命令。 + 重复推送(同 msg_id 重推,以及全量模式下 @消息双事件的不同 msg_id), + 再交给 handle_command;返回是否命中音乐命令。 全量模式需在开放平台开通「接收所有消息」,之后群里每条消息都会推送 GROUP_MESSAGE_CREATE(不限 @机器人);未命中命令时保持沉默。该事件的 @@ -920,21 +1611,46 @@ async def handle_group_message( if _handled(msg.id): logger.debug(f"跳过重复推送的消息: {msg.id}") return False + # 同一条 @消息在双事件下 msg_id 不同,id 去重拦不住;两个事件归一化 + # (剥 @ 与 /)后文本一致,再按「群+发送人+文本」短窗去重一把 + dup_key = f"{msg.group_openid}|{msg.user_openid or ''}|{_normalize(msg.content)}" + if _seen_key(_msg_dup_keys, _MSG_DUP_TTL, _MSG_DUP_MAX, dup_key): + logger.debug(f"跳过跨事件重复的消息: {msg.id}") + return False return await handle_command( api, http, config, msg.group_openid, msg.id, msg.content ) -def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None: - """把点歌命令与按钮回调 handler 挂到 emitter(群场景);http 为 bot - 自建连接池(不受 SDK 的 TIMEOUT 约束),经闭包供 handler 使用——不能 - 用形参注入,否则拿到的是 SDK 那个 5s 超时的连接池。""" +def register(ee: EventEmitter | Bot, config: BotConfig, http: ClientSession) -> None: + """把点歌命令与按钮回调 handler 挂到事件总线(生产传 Bot 门面, + 测试传裸 EventEmitter,二者 on 语义一致);http 为 bot 自建连接池 + (不受 SDK 的 TIMEOUT 约束),经闭包供 handler 使用——不能用形参注入, + 否则拿到的是 SDK 那个 5s 超时的连接池。""" + global _file_proxy + _file_proxy = None logger.info( f"点歌已就绪:源={config.music_source or '聚合'} 音质={config.music_quality} " f"每页={config.menu_page_size} 下载={config.send_mode} " + f"代理={config.public_url or '关'} " f"自动撤回={'开' if config.withdraw else '关'}" ) + if config.file_proxy: + proxy = FileProxy( + config.public_url, + http, + lambda song, keyword: _fetchable_play(http, config, song, keyword), + port=config.proxy_port, + ) + _file_proxy = proxy + + # 跟着连接走:READY(含重连)时启动,幂等;起失败仅记日志, + # 发送时 ready=False 自动退回发原直链 + @ee.on("READY") + async def on_ready_file_proxy(_: Ready, api: BotApi) -> None: + await proxy.start() + # @机器人 的消息走前一个事件;开通「接收所有消息」后群内全部消息走 # 全量事件,两个事件字段一致,共用同一个 handler @ee.on("GROUP_AT_MESSAGE_CREATE") @@ -953,24 +1669,23 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None: act.button_data, event_id=ev.event_id or act.id, ) - # 指令面板(输入框 / 菜单)跟着连接走:每次建连(READY)都幂等同步一次, - # 重连即自愈;RESUME 续传不重发 READY,不会重复刷 - if config.command_panel: - - @ee.on("READY") - async def on_ready(_: Ready, api: BotApi) -> None: - await sync_command_panel(api) - - # 平台发现也跟着连接走:每次 READY 拉一次 /sources(TTL 缓存内直读), - # 日志报告服务器实际支持的源并校验配置源;重连即重新感知增删 + # 服务器发现 + 指令面板跟着连接走:每次建连(READY)先拉一次 /sources + # (TTL 缓存内直读),日志报告平台与登录态、校验配置源,再按平台表 + # 幂等同步指令面板(基础指令 + 热搜/榜单 + 各平台「<平台>下载」)。 + # 顺序有讲究:面板条目由平台表生成,发现必须在前;RESUME 续传不重发 + # READY,不会重复刷 @ee.on("READY") - async def on_ready_platforms(_: Ready, api: BotApi) -> None: + async def on_ready_services(_: 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} 不在上述平台里,点歌/歌词会报错" + if table: + 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} 不在上述平台里,点歌/歌词会报错" + ) + await _log_login_status(http, config) + if config.command_panel: + await sync_command_panel(api, await panel_items(http, config)) diff --git a/musicbot/panel.py b/musicbot/panel.py index e2c7fc3..1ee8f3d 100644 --- a/musicbot/panel.py +++ b/musicbot/panel.py @@ -3,7 +3,11 @@ 调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ: 启动与每次重连(READY)各同步一次——没有就创建(target_type=all 对全部群生效),内容有变就更新,一致则跳过,幂等可重入。 -失败只记日志不影响运行(如功能未对机器人开放)。 + +条目由调用方给定(music.panel_items 按 MusicServer /sources 现算: +基础指令 + 热搜/榜单 + 各平台「<平台>下载」),服务器增删源跟着变。 +接口上限(每面板 20 条;name/desc 按宽度限 14/30——汉字计 2)由条目 +生成方裁剪。失败只记日志不影响运行(如功能未对机器人开放)。 """ from typing import Any @@ -20,14 +24,6 @@ _TARGET_TYPE = "all" # remark 兼作身份标识:重连同步时按它找到自己注册过的面板 _REMARK = "音乐bot" -_PANEL_ITEMS = [ - {"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"}, - {"name": "歌词", "type": "command", "desc": "查看歌词"}, - {"name": "帮助", "type": "command", "desc": "查看帮助"}, -] - -_PANEL_BODY = {"items": _PANEL_ITEMS, "remark": _REMARK} - def _signature(items: list[dict]) -> list[tuple]: """面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。""" @@ -61,25 +57,32 @@ def _mine(records: list[Any]) -> dict | None: return None -async def sync_command_panel(api: BotApi) -> None: - """把点歌指令面板同步到 QQ(幂等)。任何失败只记日志,绝不抛出。""" +async def sync_command_panel(api: BotApi, items: list[dict]) -> None: + """把给定条目同步成 QQ 指令面板(幂等)。任何失败只记日志,绝不抛出。""" try: resp = await api.get("/v2/panels", params={"scope": _SCOPE, "limit": "20"}) records = _unwrap(resp, "records").get("records") or [] mine = _mine(records) unchanged = mine and _signature(mine["panel"].get("items") or []) == _signature( - _PANEL_ITEMS + items ) if unchanged: - logger.info("指令面板已是最新(点歌/歌词/帮助)") + logger.info(f"指令面板已是最新({len(items)} 条指令)") return if mine: - await api.put(f"/v2/panels/{mine['panel_id']}", json={"panel": _PANEL_BODY}) - logger.info("指令面板已更新(点歌/歌词/帮助)") + await api.put( + f"/v2/panels/{mine['panel_id']}", + json={"panel": {"items": items, "remark": _REMARK}}, + ) + logger.info(f"指令面板已更新({len(items)} 条指令)") else: - body = {"scope": _SCOPE, "panel": _PANEL_BODY, "target_type": _TARGET_TYPE} + body = { + "scope": _SCOPE, + "panel": {"items": items, "remark": _REMARK}, + "target_type": _TARGET_TYPE, + } await api.post("/v2/panels", json=body) - logger.info("指令面板已创建(点歌/歌词/帮助,对全部群生效)") + logger.info(f"指令面板已创建({len(items)} 条指令,对全部群生效)") except _API_FAILS as e: # 失败不影响运行;指令可改为在开放平台后台手工配置 logger.warning(f"指令面板同步失败: {e!r}") diff --git a/musicbot/proxy.py b/musicbot/proxy.py new file mode 100644 index 0000000..817e45a --- /dev/null +++ b/musicbot/proxy.py @@ -0,0 +1,143 @@ +"""文件代理:bot 自建 HTTP 服务,把播放直链换成自己的公开 URL。 + +QQ 服务器侧上传富媒体(语音 file_type=3 / 群文件 4)要现场抓取 URL 入库, +两类直链它抓不到:tx 等源的 CDN 风控直链(QQ 侧报 40093007,bot 自己的 +出口反而常能抓),以及 qs 登录态直链(MusicServer 本地解密流代理,只有 +bot 所在网络可达)。开启后 send_song 发出的 URL(上传与直链文本同源)一律 +换成 /file//<文件名>: + +- key 在发送时登记(歌曲 + 搜索词),随机不可猜、短时有效、总量封顶; +- 请求到来时经 resolve 现场重解析直链——直链本身有时效,登记时探测过的 + 那条不能复用;解析自带候选源回退(tx 风控会自动落到 kw 等源)——再从 + 上游流式转发字节; +- bot 主机需能被 QQ 服务器 / 群友访问(公网 IP 或端口映射),否则上传 + 失败、回退直链文本;服务起不来(端口被占)时 claim 不换链,退回发 + 原直链,主流程不受影响。""" + +import secrets +import time +from collections import OrderedDict +from collections.abc import Awaitable, Callable +from typing import Any +from urllib.parse import quote + +from aiohttp import ClientError, ClientSession, ClientTimeout, web +from loguru import logger + +# 现场解析一条当前可抓取的直链(music._fetchable_play,含候选源回退), +# 失败返回 None。歌曲对象对本模块透明(避免与 music 循环导入),透传即可。 +Resolver = Callable[[Any, str], Awaitable[tuple[Any, str] | None]] + +_TTL = 2 * 60 * 60.0 # 秒:直链文本可能被群友过一阵才点,登记保留久一点 +_MAX_ENTRIES = 256 # 条;超出按插入序淘汰最旧,封住内存 +_CHUNK = 64 * 1024 # 流式转发的块大小 +# 转发超时:连接要快、单次读不能卡死,但不设总时长(整首歌可以下挺久) +_STREAM_TIMEOUT = ClientTimeout(total=None, connect=10, sock_read=60) + + +class FileProxy: + """发送时登记换链、请求时解析直链并转发字节的小型文件代理。""" + + def __init__( + self, + public_base: str, + http: ClientSession, + resolve: Resolver, + port: int, + ) -> None: + self._base = public_base.rstrip("/") + self._http = http + self._resolve = resolve + self._port = port + self.port = port # 实际监听端口(port=0 时在 start 后更新为分配值) + # key -> (过期时刻, 歌曲, 搜索词, 文件名);OrderedDict 按插入序淘汰 + self._entries: OrderedDict[str, tuple[float, Any, str, str]] = OrderedDict() + self._runner: web.AppRunner | None = None + + @property + def ready(self) -> bool: + """HTTP 服务是否已在跑;没起来时调用方应放弃换链、退回原直链。""" + return self._runner is not None + + def claim(self, song: Any, keyword: str, filename: str) -> str: + """登记一首歌并返回它的公开代理 URL(尾段带文件名,供展示与扩展名)。""" + now = time.monotonic() + while self._entries: + key, (expire_at, *_) = next(iter(self._entries.items())) + if expire_at > now: + break + del self._entries[key] + key = secrets.token_urlsafe(8) + self._entries[key] = (now + _TTL, song, keyword, filename) + while len(self._entries) > _MAX_ENTRIES: + self._entries.popitem(last=False) + return f"{self._base}/file/{key}/{quote(filename)}" + + async def start(self) -> None: + """起 HTTP 服务;幂等(重连 READY 会再调),端口被占等失败仅记日志。""" + if self._runner is not None: + return + app = web.Application() + app.router.add_get("/file/{key}/{name}", self._serve) + runner = web.AppRunner(app, access_log=None) # 交付另有日志,不刷访问日志 + try: + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", self._port) + await site.start() + except OSError as e: + logger.warning(f"文件代理监听 0.0.0.0:{self._port} 失败: {e}") + await runner.cleanup() + return + if self._port == 0: + # sockets 未列入 aiohttp 类型存根,只能从私有属性取实际端口 + server = site._server + assert server is not None + self.port = server.sockets[0].getsockname()[1] # type: ignore[attr-defined] + self._runner = runner + logger.info(f"文件代理已监听 0.0.0.0:{self.port},对外地址 {self._base}") + + async def stop(self) -> None: + if self._runner is not None: + await self._runner.cleanup() + self._runner = None + + async def _serve(self, request: web.Request) -> web.StreamResponse: + key = request.match_info["key"] + entry = self._entries.get(key) + if entry is None or entry[0] < time.monotonic(): + return web.Response(status=404, text="链接已过期或不存在") + _, song, keyword, filename = entry + try: + found = await self._resolve(song, keyword) + except Exception as e: # 解析在 handler 边界,别让 500 栈漏给请求方 + logger.warning(f"文件代理解析直链异常: {e!r}") + found = None + if found is None: + return web.Response(status=502, text="取流失败,请重新点歌") + url = found[1] + resp: web.StreamResponse | None = None + try: + async with self._http.get(url, timeout=_STREAM_TIMEOUT) as upstream: + if upstream.status != 200: + raise ClientError(f"上游 HTTP {upstream.status}") + resp = web.StreamResponse(headers={ + # 上游的 Content-Type 透传(音源都是 audio/*),缺省按 mp3 + "Content-Type": upstream.headers.get("Content-Type", "audio/mpeg"), + "Content-Disposition": ( + f"attachment; filename*=UTF-8''{quote(filename)}" + ), + }) + if upstream.content_length is not None: + resp.content_length = upstream.content_length + await resp.prepare(request) + async for chunk in upstream.content.iter_chunked(_CHUNK): + await resp.write(chunk) + await resp.write_eof() + logger.info(f"文件代理已交付:{filename}") + return resp + except (ClientError, TimeoutError, OSError) as e: + logger.warning(f"文件代理转发失败({url[:80]}): {e!r}") + # 字节已发出一半时状态码改不了,就地断流让客户端感知;没开始才给 502 + if resp is not None and resp.prepared: + return resp + return web.Response(status=502, text="上游取流失败") diff --git a/pyproject.toml b/pyproject.toml index b850172..8fdc95b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "musicbot" version = "0.1.0" description = "QQ 群点歌 bot:MusicServer 搜索 + 选歌菜单 + 歌曲卡片回调按钮" requires-python = ">=3.13" -dependencies = ["qqbotsdk", "loguru"] +dependencies = ["qqbotsdk-py", "loguru"] [project.scripts] musicbot = "musicbot.main:main" @@ -18,9 +18,6 @@ module-root = "" # flat 布局:musicbot/ 在仓库根而非 src/ 下 [dependency-groups] dev = ["pyright", "pytest", "pytest-asyncio", "ruff"] -[tool.uv.sources] -qqbotsdk = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" } - [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/tests/test_music.py b/tests/test_music.py index e38aafb..825e0cb 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -1,5 +1,6 @@ """点歌业务测试:假 MusicServer + Recording BotApi,覆盖选歌菜单与分页、 -歌曲卡片、按钮回调、默认 tx 源与聚合兜底、直链候选源回退、错误路径。""" +歌曲卡片、按钮回调、默认 tx 源与聚合兜底、直链候选源回退、文件代理、 +错误路径。""" from collections.abc import AsyncIterator from dataclasses import replace @@ -12,7 +13,7 @@ from aiohttp import ClientSession, web from qqbotsdk import EventEmitter from qqbotsdk.api import ApiError, BotApi -from musicbot import music +from musicbot import music, proxy from musicbot.config import BotConfig from musicbot.music import ( handle_button, @@ -22,6 +23,7 @@ from musicbot.music import ( send_menu, ) from musicbot.panel import sync_command_panel +from musicbot.proxy import FileProxy def make_api( @@ -74,7 +76,8 @@ def make_api( class FakeMusicServer: - """模拟 MusicServer 的统一信封接口,记录搜索参数。 + """模拟 MusicServer 的统一信封接口(路径式 /search/{source}/{keyword}), + 记录搜索/榜单请求参数。 /url 返回指向自身的直链;tx_denied=True 时 tx 的直链指向 403 路由,模拟 CDN 风控(测直链探测与候选源回退)。 @@ -85,19 +88,33 @@ class FakeMusicServer: 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", self._search) + 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) @@ -115,35 +132,132 @@ class FakeMusicServer: await self.runner.cleanup() async def _search(self, request: web.Request) -> web.Response: - name = request.query["name"] - source = request.query.get("source", "") - self.queries.append({"name": name, "source": source}) + 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 name: - if "兜底" in name: - # tx 搜不到、聚合有 —— 测兜底路径 + if "空" not in keyword: + if "兜底" in keyword: + # tx 搜不到、聚合(all)有 —— 测兜底路径 items = [] if source == "tx" else [ - {"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"} + {"source": "kw", "songmid": "S1", + "name": keyword, "singer": "歌手A"}, ] elif source == "kw": items = [ - {"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"} + {"source": "kw", "songmid": "S1", + "name": keyword, "singer": "歌手A"}, ] - elif source == "tx" or not source: + 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": name, + "name": keyword, "singer": "歌手T", "albumName": "专辑T", "interval": "04:00", "img": "https://img.test/cover.jpg", }, - {"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"}, + {"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" @@ -171,6 +285,8 @@ class FakeMusicServer: "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 ( @@ -178,8 +294,10 @@ class FakeMusicServer: ("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: @@ -208,14 +326,21 @@ 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() + """菜单/卡片消息记录、消息/回调/跨事件去重表、平台缓存、下载别名与 + 文件代理是模块级状态,测试间清空/复位避免串扰。""" + 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 - music._last_msg.clear() - music._seen_messages.clear() - music._recent_clicks.clear() + reset() # 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义) @@ -244,17 +369,20 @@ 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 == [{"name": "稻香", "source": "tx"}] + 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", ""] + 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", "点歌 稻香") @@ -264,7 +392,11 @@ async def test_menu_only_selects_songs(env): assert "## 点歌:稻香" in md assert "1. 稻香 - 歌手T" in md assert "QQ音乐" not in md and "酷我" not in md # 每首歌后面不再标音源 - assert "http" 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"] @@ -323,6 +455,7 @@ async def test_menu_fallback_keeps_keyboard(env): assert body["msg_type"] == 0 assert "稻香 - 歌手T" in body["content"] assert "(点下方编号选歌)" in body["content"] + assert "![" not in body["content"] # 回退文本不带图片语法 assert "keyboard" in body @@ -362,6 +495,67 @@ async def test_card_without_img_when_source_has_none(env): 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_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 @@ -543,6 +737,161 @@ async def test_upload_timeout_falls_back_to_text(env): 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 @@ -589,7 +938,8 @@ async def test_card_play_falls_back_to_candidate_source(server): async def test_fallback_sources_follow_server_platforms(): """回退链由 /sources 动态解析:具备 search+url 能力的平台按优先序 - 排、其余追加在后,并排除原曲所在源(tx 的候选 = kw, wy, kg)。""" + 排、其余追加在后,并排除原曲所在源与不可作候选的源(tx/qs)—— + tx 的候选 = kw, wy, kg(qs 有 url 能力也被排除)。""" s = FakeMusicServer() await s.start() try: @@ -607,6 +957,10 @@ async def test_fallback_sources_follow_server_platforms(): 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() @@ -666,6 +1020,316 @@ async def test_page_button_new_menu(env): 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 关掉。""" @@ -741,10 +1405,9 @@ async def test_slash_help_sends_command_keyboard(env): assert "点歌" in body["content"] and "歌词" in body["content"] rows = buttons(body) assert len(rows) == 1 - song_btn, lyric_btn = rows[0]["buttons"] - assert song_btn["action"]["type"] == 2 - assert song_btn["action"]["data"] == "/点歌 " - assert lyric_btn["action"]["data"] == "/歌词 " + 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): @@ -822,28 +1485,32 @@ class PanelRecording(BotApi): 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) + 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"]] == ["点歌", "歌词", "帮助"] + assert [i["name"] for i in body["panel"]["items"]] == [ + "点歌", "歌词", "酷狗下载", "帮助", + ] async def test_panel_skipped_when_identical(): - items = [ - {"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"}, - {"name": "歌词", "type": "command", "desc": "查看歌词"}, - {"name": "帮助", "type": "command", "desc": "查看帮助"}, - ] - api = PanelRecording( - records=[{"panel_id": "P9", "panel": {"items": items, "remark": "音乐bot"}}] - ) - await sync_command_panel(api) + 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")] @@ -860,13 +1527,17 @@ async def test_panel_updated_when_differs(): } ] ) - await sync_command_panel(api) + 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) # 失败只记日志,绝不抛出 + await sync_command_panel(api, _PANEL_ITEMS) # 失败只记日志,绝不抛出 assert api.calls == [] @@ -884,6 +1555,33 @@ def test_config_send_mode(monkeypatch): 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 @@ -1027,8 +1725,8 @@ def test_main_child_does_not_supervise(monkeypatch): # ---- 全量模式(不 @ 也能用)与去重 ---- def make_emitter(api, http, config) -> EventEmitter: - """按 main.py 的装配方式注册 handler,手工注入 SDK 会在 run_loop 里 - 登记的 BotApi;http 走闭包(与 main.py 一致)。""" + """按 main.py 的装配方式注册 handler,手工注入 SDK 会在 bot.start() + 装配时登记的 BotApi;http 走闭包(与 main.py 一致)。""" ee = EventEmitter() register(ee, config, http) ee.services.update({BotApi: api}) @@ -1039,12 +1737,14 @@ 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) -> dict: +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": "U1", "bot": bot}, + "author": {"member_openid": user, "bot": bot}, } @@ -1086,17 +1786,42 @@ async def test_group_message_ignores_own_and_other_chat(env): async def test_duplicate_push_handled_once(env): - """同一 msg_id 重复推送、或 @消息同时命中两个事件时只处理一次。""" + """同一 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) - await push(ee, "GROUP_AT_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 diff --git a/uv.lock b/uv.lock index 6fd3f68..9817875 100644 --- a/uv.lock +++ b/uv.lock @@ -473,7 +473,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "loguru" }, - { name = "qqbotsdk" }, + { name = "qqbotsdk-py" }, ] [package.dev-dependencies] @@ -487,7 +487,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "loguru" }, - { name = "qqbotsdk", url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }, + { name = "qqbotsdk-py" }, ] [package.metadata.requires-dev] @@ -688,9 +688,9 @@ wheels = [ ] [[package]] -name = "qqbotsdk" -version = "0.1.0" -source = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" } +name = "qqbotsdk-py" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "cryptography" }, @@ -698,15 +698,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "ujson" }, ] -sdist = { hash = "sha256:162be4706f256ccee5e371232d591776ce0997cb5481f61554a9b16e1b569bf5" } - -[package.metadata] -requires-dist = [ - { name = "aiohttp", specifier = ">=3.14.3" }, - { name = "cryptography", specifier = ">=50.0.1" }, - { name = "loguru", specifier = ">=0.7.3" }, - { name = "python-dotenv", specifier = ">=1.2.3" }, - { name = "ujson", specifier = ">=6.0.0" }, +sdist = { url = "https://files.pythonhosted.org/packages/6c/a2/9f5fb85a940df97a394f996739a52b4d8bcadba77bd3883bd1292ee1bc80/qqbotsdk_py-0.2.0.tar.gz", hash = "sha256:bb5e02ff3ba05a78a95418fde01f54e5d37880abdd61e27f52db06afd854243e", size = 39584, upload-time = "2026-10-03T12:12:12.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/c7bf69fecd03ef7805b6af566ef91cafc7692adeefced4b447b60ea52063/qqbotsdk_py-0.2.0-py3-none-any.whl", hash = "sha256:84550cc53547377b13cb7d0ff78ac1a0df3cc733f81facf2783f3dcbe6801ab6", size = 52247, upload-time = "2026-10-03T12:12:10.763Z" }, ] [[package]]