新增日志支持、卡片显示专辑封面、菜单去掉音源标注
- 日志:控制台 + logs/musicbot.log(按天轮转、保留 7 天), LOG_LEVEL/LOG_DIR 可配;目录不可写时退化为仅控制台; diagnose=False 避免把凭证写进日志 - 卡片用 Markdown 图片语法显示专辑封面(), 封面取自 MusicServer 的 img 字段(tx/wy 有、kw/kg 无) - 按钮 data 加第 5 段(封面 URL),解码兼容旧 4 段,已发出的按钮不失效 - 菜单去掉每首歌后的(音源)标注;卡片保留"来源:" - 测试补:封面渲染、无封面源、旧 data 兼容、LOG_LEVEL、日志落盘
This commit is contained in:
1 parent
7a8adb4571
commit
9bcdad7aa1
7 files changed
+162
-24
No files matched your search
@@ -20,3 +20,8 @@ WITHDRAW=on
|
|||||||
# file 先上传群文件、失败回退直链(需机器人在群里是群主/管理员)
|
# file 先上传群文件、失败回退直链(需机器人在群里是群主/管理员)
|
||||||
# 播放按钮不受影响,始终发语音。非法值按 link 处理。
|
# 播放按钮不受影响,始终发语音。非法值按 link 处理。
|
||||||
SEND_MODE=link
|
SEND_MODE=link
|
||||||
|
|
||||||
|
# 日志:级别可选 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL(默认 INFO);
|
||||||
|
# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_DIR=logs
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# 密钥与本地数据
|
# 密钥与本地数据
|
||||||
.env
|
.env
|
||||||
downloads/
|
downloads/
|
||||||
|
logs/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌:
|
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌:
|
||||||
|
|
||||||
1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手/来源,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20)
|
1. **选歌菜单**——`点歌 <歌名 [歌手]>` 只负责选歌:Markdown 歌单(编号 + 歌名/歌手,默认 QQ 音乐源,搜不到自动聚合兜底),下方每行 5 个编号回调按钮 + `[上一页][下一页]`(每页 `MENU_PAGE_SIZE` 首,默认 10,上限 20)
|
||||||
2. **歌曲卡片**——点编号弹出卡片:歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
|
2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
|
||||||
|
|
||||||
|
- **专辑封面**:卡片用 Markdown 图片语法 `` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
|
||||||
- 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本
|
- 回调按钮(type=1)点一下直接生效,无需再按发送;播放发语音、下载发文件或直链(见 `SEND_MODE`),失败回退直链文本;无 Markdown 权限自动回退文本
|
||||||
- **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
|
- **下载发送方式**(`SEND_MODE`,默认 `link`):群文件上传仅限群主/管理员(40093005),故默认直接发直链;`file` 则先尝试上传群文件、失败(含无权限)回退直链。播放始终发语音
|
||||||
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
|
- **直链候选源回退**:tx 等源直链常受 CDN 风控抓不动(bot 抓不动时 QQ 服务器侧上传同样报 40093007),上传前先探测直链可抓性,不可抓则按 kw/wy/mg 逐个搜索换源
|
||||||
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
|
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
|
||||||
|
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
|
||||||
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
|
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
@@ -22,6 +24,8 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
|
|||||||
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
|
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
|
||||||
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
|
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
|
||||||
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
|
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
|
||||||
|
| `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
|
||||||
|
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
|
||||||
|
|
||||||
### 不 @ 机器人也能点歌
|
### 不 @ 机器人也能点歌
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
||||||
_MAX_MENU_PAGE_SIZE = 20
|
_MAX_MENU_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
_LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL")
|
||||||
|
|
||||||
|
|
||||||
def _flag(name: str, default: bool) -> bool:
|
def _flag(name: str, default: bool) -> bool:
|
||||||
"""读布尔环境变量:on/true/1/yes 为真,off/false/0/no 为假。"""
|
"""读布尔环境变量:on/true/1/yes 为真,off/false/0/no 为假。"""
|
||||||
@@ -33,6 +36,8 @@ class BotConfig:
|
|||||||
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
|
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
|
||||||
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
|
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
|
||||||
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
|
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
|
||||||
|
log_level: str = "INFO" # 控制台与文件日志级别
|
||||||
|
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> "BotConfig":
|
def load(cls) -> "BotConfig":
|
||||||
@@ -44,4 +49,12 @@ class BotConfig:
|
|||||||
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
|
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
|
||||||
withdraw=_flag("WITHDRAW", True),
|
withdraw=_flag("WITHDRAW", True),
|
||||||
send_mode=_send_mode(),
|
send_mode=_send_mode(),
|
||||||
|
log_level=_log_level(),
|
||||||
|
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_level() -> str:
|
||||||
|
"""LOG_LEVEL:日志级别,非法值按 INFO。"""
|
||||||
|
raw = os.getenv("LOG_LEVEL", "").strip().upper()
|
||||||
|
return raw if raw in _LOG_LEVELS else "INFO"
|
||||||
+34
-1
@@ -1,6 +1,7 @@
|
|||||||
"""装配入口:注册命令并运行 bot。"""
|
"""装配入口:配置日志、注册命令并运行 bot。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -9,9 +10,41 @@ from qqbotsdk import EventEmitter, run_loop
|
|||||||
from .config import BotConfig
|
from .config import BotConfig
|
||||||
from .music import register
|
from .music import register
|
||||||
|
|
||||||
|
# 日志文件按天轮转、保留 7 天;目录写不进去时退化为仅控制台
|
||||||
|
_LOG_RETENTION = "7 days"
|
||||||
|
_LOG_FORMAT = (
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||||
|
"<level>{level: <8}</level> | "
|
||||||
|
"<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(config: BotConfig) -> None:
|
||||||
|
"""日志同时输出到控制台与 logs/musicbot.log(按天轮转)。"""
|
||||||
|
logger.remove() # 去掉 loguru 默认的 stderr handler,统一由这里接管
|
||||||
|
logger.add(sys.stderr, level=config.log_level, format=_LOG_FORMAT)
|
||||||
|
try:
|
||||||
|
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.add(
|
||||||
|
config.log_dir / "musicbot.log",
|
||||||
|
level=config.log_level,
|
||||||
|
format=_LOG_FORMAT,
|
||||||
|
rotation="00:00", # 每天零点切分
|
||||||
|
retention=_LOG_RETENTION,
|
||||||
|
encoding="utf-8",
|
||||||
|
enqueue=True, # 多任务写入串行化,避免并发写坏文件
|
||||||
|
backtrace=True,
|
||||||
|
diagnose=False, # 不回显变量值,避免把凭证写进日志
|
||||||
|
)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"日志目录不可写({config.log_dir}),仅输出到控制台: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def amain() -> None:
|
async def amain() -> None:
|
||||||
config = BotConfig.load()
|
config = BotConfig.load()
|
||||||
|
setup_logging(config)
|
||||||
|
logger.info(f"musicbot 启动,日志级别 {config.log_level}")
|
||||||
ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例
|
ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例
|
||||||
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
|
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
|
||||||
async with ClientSession() as http:
|
async with ClientSession() as http:
|
||||||
|
|||||||
+20
-10
@@ -62,6 +62,7 @@ class Song:
|
|||||||
album: str | None = None
|
album: str | None = None
|
||||||
interval: str | None = None
|
interval: str | None = None
|
||||||
album_id: str | None = None
|
album_id: str | None = None
|
||||||
|
img: str | None = None # 专辑封面直链(部分源无此字段,如 kw/kg)
|
||||||
|
|
||||||
|
|
||||||
# 每个群当前"可撤回"的 bot 消息 id(菜单或卡片):发新消息前撤回上一张,
|
# 每个群当前"可撤回"的 bot 消息 id(菜单或卡片):发新消息前撤回上一张,
|
||||||
@@ -126,6 +127,7 @@ def _song_of(item: dict[str, Any]) -> Song | None:
|
|||||||
album=item.get("albumName"),
|
album=item.get("albumName"),
|
||||||
interval=item.get("interval"),
|
interval=item.get("interval"),
|
||||||
album_id=item.get("albumId"),
|
album_id=item.get("albumId"),
|
||||||
|
img=item.get("img") or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,22 +280,26 @@ async def _swap_message(
|
|||||||
|
|
||||||
|
|
||||||
def _song_data(action: str, song: Song) -> str:
|
def _song_data(action: str, song: Song) -> str:
|
||||||
"""回调按钮携带的歌曲数据:自包含(歌名/歌手 % 转义,避免撞上分隔符),
|
"""回调按钮携带的歌曲数据:自包含(歌名/歌手/封面 % 转义,避免撞上
|
||||||
bot 收到回调直接还原 Song,无需记忆菜单快照。"""
|
分隔符),bot 收到回调直接还原 Song,无需记忆菜单快照。"""
|
||||||
return (
|
return (
|
||||||
f"音乐|{action}|{song.source}|{song.song_id}"
|
f"音乐|{action}|{song.source}|{song.song_id}"
|
||||||
f"|{_q(song.name)}|{_q(song.singer)}"
|
f"|{_q(song.name)}|{_q(song.singer)}|{_q(song.img or '')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _song_of_data(args: list[str]) -> Song:
|
def _song_of_data(args: list[str]) -> Song:
|
||||||
"""从回调 data 的 [source, song_id, name, singer] 还原 Song。"""
|
"""从回调 data 的 [source, song_id, name, singer, img?] 还原 Song。
|
||||||
source, song_id, name, singer = args
|
|
||||||
|
img 段是后加的:旧按钮的 data 只有 4 段,缺省按无封面处理。"""
|
||||||
|
source, song_id, name, singer = args[:4]
|
||||||
|
img = unquote(args[4]) if len(args) > 4 and args[4] else None
|
||||||
return Song(
|
return Song(
|
||||||
source=source,
|
source=source,
|
||||||
song_id=song_id,
|
song_id=song_id,
|
||||||
name=unquote(name),
|
name=unquote(name),
|
||||||
singer=unquote(singer),
|
singer=unquote(singer),
|
||||||
|
img=img,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -374,8 +380,7 @@ async def send_menu(
|
|||||||
lines = [f"## 点歌:{keyword}"]
|
lines = [f"## 点歌:{keyword}"]
|
||||||
for i, song in enumerate(songs, 1):
|
for i, song in enumerate(songs, 1):
|
||||||
title = f"{song.name} - {song.singer}".strip(" -")
|
title = f"{song.name} - {song.singer}".strip(" -")
|
||||||
source = _SOURCE_NAMES.get(song.source, song.source)
|
lines.append(f"{i}. {title}")
|
||||||
lines.append(f"{i}. {title}({source})")
|
|
||||||
keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size)
|
keyboard = _menu_keyboard(songs, keyword, page, config.menu_page_size)
|
||||||
try:
|
try:
|
||||||
resp = await api.post_group_message(
|
resp = await api.post_group_message(
|
||||||
@@ -410,10 +415,13 @@ async def send_card(
|
|||||||
msg_id: str | None = None,
|
msg_id: str | None = None,
|
||||||
event_id: str | None = None,
|
event_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""选中歌曲后弹出卡片:Markdown 歌名/歌手/来源/专辑/时长
|
"""选中歌曲后弹出卡片:Markdown 封面图 + 歌名/歌手/来源/专辑/时长
|
||||||
+ [▶ 播放][⬇ 下载] 回调按钮;Markdown 失败回退纯文本(按钮保留)。"""
|
+ [▶ 播放][⬇ 下载] 回调按钮;Markdown 失败回退纯文本(按钮保留)。"""
|
||||||
title = f"{song.name} - {song.singer}".strip(" -")
|
title = f"{song.name} - {song.singer}".strip(" -")
|
||||||
lines = [f"## {title}"]
|
lines = [f"## {title}"]
|
||||||
|
if song.img:
|
||||||
|
# QQ 后台会代理并缓存图片到客户端(不落库),宽高按 #宽px #高px 指定
|
||||||
|
lines.append(f"")
|
||||||
details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"]
|
details = [f"来源:{_SOURCE_NAMES.get(song.source, song.source)}"]
|
||||||
if song.album:
|
if song.album:
|
||||||
details.append(f"专辑:{song.album}")
|
details.append(f"专辑:{song.album}")
|
||||||
@@ -603,6 +611,8 @@ async def handle_button(
|
|||||||
裸 uuid 会被 QQ 判为无效(40034025)。"""
|
裸 uuid 会被 QQ 判为无效(40034025)。"""
|
||||||
parts = data.split("|")
|
parts = data.split("|")
|
||||||
action, args = (parts[1] if len(parts) > 1 else ""), parts[2:]
|
action, args = (parts[1] if len(parts) > 1 else ""), parts[2:]
|
||||||
|
# 歌曲按钮的 args 为 [source, song_id, name, singer](+ img,后加故兼容 4 段)
|
||||||
|
is_song = action in ("pick", "play", "dl") and len(args) in (4, 5)
|
||||||
try:
|
try:
|
||||||
if action == "page" and len(args) == 2:
|
if action == "page" and len(args) == 2:
|
||||||
keyword, page = unquote(args[0]), int(args[1])
|
keyword, page = unquote(args[0]), int(args[1])
|
||||||
@@ -610,12 +620,12 @@ async def handle_button(
|
|||||||
api, http, config, group_openid, keyword, page,
|
api, http, config, group_openid, keyword, page,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
)
|
)
|
||||||
elif action == "pick" and len(args) == 4:
|
elif action == "pick" and is_song:
|
||||||
await send_card(
|
await send_card(
|
||||||
api, http, config, group_openid, _song_of_data(args),
|
api, http, config, group_openid, _song_of_data(args),
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
)
|
)
|
||||||
elif action in ("play", "dl") and len(args) == 4:
|
elif action in ("play", "dl") and is_song:
|
||||||
song = _song_of_data(args)
|
song = _song_of_data(args)
|
||||||
try:
|
try:
|
||||||
await send_song(
|
await send_song(
|
||||||
|
|||||||
+83
-11
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ class FakeMusicServer:
|
|||||||
"singer": "歌手T",
|
"singer": "歌手T",
|
||||||
"albumName": "专辑T",
|
"albumName": "专辑T",
|
||||||
"interval": "04:00",
|
"interval": "04:00",
|
||||||
|
"img": "https://img.test/cover.jpg",
|
||||||
},
|
},
|
||||||
{"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"},
|
{"source": "kw", "songmid": "S1", "name": name, "singer": "歌手A"},
|
||||||
]
|
]
|
||||||
@@ -181,10 +183,13 @@ def _reset_menu_state():
|
|||||||
music._seen_messages.clear()
|
music._seen_messages.clear()
|
||||||
|
|
||||||
|
|
||||||
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手(歌名/歌手 % 转义)
|
# 回调按钮 data:音乐|动作|源|song_id|歌名|歌手|封面(歌名/歌手/封面 % 转义)
|
||||||
PICK_1 = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
|
IMG_URL = "https://img.test/cover.jpg"
|
||||||
PLAY_TX = "音乐|play|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
|
PICK_1 = music._song_data("pick", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
|
||||||
DL_TX = "音乐|dl|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
|
PLAY_TX = music._song_data("play", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
|
||||||
|
DL_TX = music._song_data("dl", music.Song("tx", "T1", "稻香", "歌手T", img=IMG_URL))
|
||||||
|
# 旧版按钮 data(无封面段)—— 兼容性用例
|
||||||
|
PICK_1_LEGACY = "音乐|pick|tx|T1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BT"
|
||||||
|
|
||||||
|
|
||||||
def posts(calls: list[dict]) -> list[dict]:
|
def posts(calls: list[dict]) -> list[dict]:
|
||||||
@@ -214,7 +219,7 @@ async def test_search_prefers_tx_and_falls_back(server, env):
|
|||||||
|
|
||||||
|
|
||||||
async def test_menu_only_selects_songs(env):
|
async def test_menu_only_selects_songs(env):
|
||||||
"""搜索菜单只负责选歌:编号回调按钮,不放播放/下载、也不放任何链接。"""
|
"""搜索菜单只负责选歌:编号回调按钮,不放播放/下载、不放链接、不带音源。"""
|
||||||
http, config = env
|
http, config = env
|
||||||
api, calls = make_api()
|
api, calls = make_api()
|
||||||
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
|
assert await handle_command(api, http, config, "G1", "m1", "点歌 稻香")
|
||||||
@@ -222,10 +227,10 @@ async def test_menu_only_selects_songs(env):
|
|||||||
assert body["msg_type"] == 2
|
assert body["msg_type"] == 2
|
||||||
md = body["markdown"]["content"]
|
md = body["markdown"]["content"]
|
||||||
assert "## 点歌:稻香" in md
|
assert "## 点歌:稻香" in md
|
||||||
assert "1. 稻香 - 歌手T(QQ音乐)" in md
|
assert "1. 稻香 - 歌手T" in md
|
||||||
assert "(QQ音乐)" in md and "(酷我)" in md
|
assert "QQ音乐" not in md and "酷我" not in md # 每首歌后面不再标音源
|
||||||
assert "http" not in md # 菜单里没有任何链接
|
assert "http" not in md # 菜单里没有任何链接
|
||||||
# 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲
|
# 编号回调按钮(type=1):点一下弹卡片,data 自包含歌曲(含封面)
|
||||||
rows = buttons(body)
|
rows = buttons(body)
|
||||||
assert [b["render_data"]["label"] for b in rows[0]["buttons"]] == ["1", "2"]
|
assert [b["render_data"]["label"] for b in rows[0]["buttons"]] == ["1", "2"]
|
||||||
assert [b["action"]["type"] for b in rows[0]["buttons"]] == [1, 1]
|
assert [b["action"]["type"] for b in rows[0]["buttons"]] == [1, 1]
|
||||||
@@ -287,9 +292,9 @@ async def test_menu_fallback_keeps_keyboard(env):
|
|||||||
|
|
||||||
|
|
||||||
async def test_pick_button_sends_card(env):
|
async def test_pick_button_sends_card(env):
|
||||||
"""点编号 → 弹歌曲卡片:歌名/来源 + [▶播放][⬇下载] 回调按钮。
|
"""点编号 → 弹歌曲卡片:封面图 + 歌名/来源 + [▶播放][⬇下载] 回调按钮。
|
||||||
|
|
||||||
卡片由回调 data 还原(自包含 4 字段),故不含专辑/时长等菜单外信息。"""
|
卡片由回调 data 还原(自包含 song 字段),故不含专辑/时长等菜单外信息。"""
|
||||||
http, config = env
|
http, config = env
|
||||||
api, calls = make_api()
|
api, calls = make_api()
|
||||||
await handle_button(
|
await handle_button(
|
||||||
@@ -299,7 +304,8 @@ async def test_pick_button_sends_card(env):
|
|||||||
assert card["msg_type"] == 2
|
assert card["msg_type"] == 2
|
||||||
md = card["markdown"]["content"]
|
md = card["markdown"]["content"]
|
||||||
assert "## 稻香 - 歌手T" in md
|
assert "## 稻香 - 歌手T" in md
|
||||||
assert "来源:QQ音乐" in md
|
assert "" in md # 专辑封面
|
||||||
|
assert "来源:QQ音乐" in md # 卡片保留音源
|
||||||
assert labels(card) == [["▶ 播放", "⬇ 下载"]]
|
assert labels(card) == [["▶ 播放", "⬇ 下载"]]
|
||||||
assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1]
|
assert [b["action"]["type"] for b in buttons(card)[0]["buttons"]] == [1, 1]
|
||||||
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
|
assert [b["action"]["data"] for b in buttons(card)[0]["buttons"]] == [
|
||||||
@@ -309,6 +315,27 @@ async def test_pick_button_sends_card(env):
|
|||||||
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1")
|
assert (calls[-1]["method"], calls[-1]["path"]) == ("PUT", "/interactions/I1")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_card_without_img_when_source_has_none(env):
|
||||||
|
"""无封面源的歌曲(如 kw):卡片不含图片,其余照常。"""
|
||||||
|
http, config = env
|
||||||
|
api, calls = make_api()
|
||||||
|
no_img = "音乐|pick|kw|S1|%E7%A8%BB%E9%A6%99|%E6%AD%8C%E6%89%8BA|"
|
||||||
|
await handle_button(api, http, config, "G1", "I1", no_img)
|
||||||
|
md = posts(calls)[0]["json"]["markdown"]["content"]
|
||||||
|
assert "![封面" not in md
|
||||||
|
assert "## 稻香 - 歌手A" in md
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_button_data_without_img(env):
|
||||||
|
"""旧版 4 段 data 仍能处理(封面缺省),保证已发出的菜单按钮不失效。"""
|
||||||
|
http, config = env
|
||||||
|
api, calls = make_api()
|
||||||
|
assert await handle_button(api, http, config, "G1", "I1", PICK_1_LEGACY)
|
||||||
|
md = posts(calls)[0]["json"]["markdown"]["content"]
|
||||||
|
assert "## 稻香 - 歌手T" in md
|
||||||
|
assert "![封面" not in md
|
||||||
|
|
||||||
|
|
||||||
async def test_card_play_button_sends_voice(env):
|
async def test_card_play_button_sends_voice(env):
|
||||||
"""卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。"""
|
"""卡片上的 [▶ 播放] 是回调按钮:点一下直接发语音。"""
|
||||||
http, config = env
|
http, config = env
|
||||||
@@ -591,6 +618,51 @@ def test_config_send_mode(monkeypatch):
|
|||||||
assert BotConfig.load().send_mode == "link" # 非法值不报错,按默认处理
|
assert BotConfig.load().send_mode == "link" # 非法值不报错,按默认处理
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_log_level(monkeypatch):
|
||||||
|
"""LOG_LEVEL 默认 INFO,可显式设置(大小写不敏感),非法值回落 INFO。"""
|
||||||
|
from musicbot.config import BotConfig
|
||||||
|
|
||||||
|
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
||||||
|
assert BotConfig.load().log_level == "INFO"
|
||||||
|
|
||||||
|
monkeypatch.setenv("LOG_LEVEL", "debug")
|
||||||
|
assert BotConfig.load().log_level == "DEBUG"
|
||||||
|
|
||||||
|
monkeypatch.setenv("LOG_LEVEL", "乱写")
|
||||||
|
assert BotConfig.load().log_level == "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_logging_writes_file(tmp_path: Path):
|
||||||
|
"""setup_logging 同时写控制台与文件;写不进去时退化为仅控制台不抛错。"""
|
||||||
|
from loguru import logger as lg
|
||||||
|
|
||||||
|
from musicbot.config import BotConfig
|
||||||
|
from musicbot.main import setup_logging
|
||||||
|
|
||||||
|
cfg = BotConfig(
|
||||||
|
music_server_url="http://127.0.0.1:1",
|
||||||
|
music_quality="128k",
|
||||||
|
music_source="tx",
|
||||||
|
log_level="INFO",
|
||||||
|
log_dir=tmp_path,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
setup_logging(cfg)
|
||||||
|
lg.info("测试日志落盘")
|
||||||
|
lg.complete() # 等 enqueue 队列刷完
|
||||||
|
logfile = tmp_path / "musicbot.log"
|
||||||
|
assert logfile.is_file()
|
||||||
|
assert "测试日志落盘" in logfile.read_text(encoding="utf-8")
|
||||||
|
finally:
|
||||||
|
lg.remove() # 清掉本用例加的 handler,避免影响其他测试
|
||||||
|
|
||||||
|
# 目录不可写(用一个文件当目录)→ 不抛错,仅告警
|
||||||
|
blocked = tmp_path / "not-a-dir"
|
||||||
|
blocked.write_text("x", encoding="utf-8")
|
||||||
|
setup_logging(replace(cfg, log_dir=blocked))
|
||||||
|
lg.remove()
|
||||||
|
|
||||||
|
|
||||||
# ---- 全量模式(不 @ 也能用)与去重 ----
|
# ---- 全量模式(不 @ 也能用)与去重 ----
|
||||||
|
|
||||||
def make_emitter(api, http, config) -> EventEmitter:
|
def make_emitter(api, http, config) -> EventEmitter:
|
||||||
|
|||||||
Reference in new issue
Block a user