Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0145b7b01a | ||
|
|
508604610f | ||
|
|
94ee3298b7 | ||
|
|
d71809d144 |
No files matched your search
@@ -0,0 +1,70 @@
|
||||
name: 代码测试
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ruff + pyright + pytest
|
||||
runs-on: ubuntu-latest
|
||||
# 固定官方 python 镜像:保证 python3.13 + pip 在场(runner 默认镜像
|
||||
# 工具链不明,SDK 仓库实测任务曾 7s 即死);runner 不支持 container
|
||||
# 时回落宿主机 python,此时 uv 经下方镜像变量自行下载 CPython
|
||||
container: python:3.13-slim
|
||||
steps:
|
||||
# 不用 actions/checkout、不用 git:runner 对 github 连通性不稳
|
||||
# (SDK 仓库 probe 实测),改走 Gitea archive 接口拉源码 tarball;
|
||||
# 每步结果写进 commit status(日志匿名不可读,status 远程可读,
|
||||
# GITEA_TOKEN 未配置时静默降级,不影响流程)
|
||||
- name: 拉源码、装依赖并执行测试
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
UV_PYTHON_INSTALL_MIRROR: https://registry.npmmirror.com/-/binary/python-build-standalone
|
||||
run: |
|
||||
set -e
|
||||
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
|
||||
note() {
|
||||
curl -s -m 10 -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"state\":\"$3\",\"context\":\"ci/$1\",\"description\":\"$2\"}" \
|
||||
"$SERVER/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \
|
||||
-o /dev/null
|
||||
}
|
||||
trap 'note error "步骤意外失败,详见 Actions 日志" failure' ERR
|
||||
|
||||
curl -sL "$SERVER/api/v1/repos/$GITHUB_REPOSITORY/archive/$GITHUB_SHA.tar.gz" -o src.tgz \
|
||||
&& mkdir src && tar -xzf src.tgz -C src --strip-components=1 && cd src \
|
||||
|| { note fetch "archive 拉取失败" failure; exit 1; }
|
||||
note fetch ok success
|
||||
|
||||
pip install uv >/dev/null 2>&1 \
|
||||
|| { note pip "pip 安装 uv 失败" failure; exit 1; }
|
||||
note pip ok success
|
||||
|
||||
# qqbotsdk 依赖解析自 GitHub archive(uv.lock 锁定内容 hash),
|
||||
# runner 到 github 时通时不通,sync 失败自动重试
|
||||
sync_ok=""
|
||||
for _ in 1 2 3; do
|
||||
if uv sync >/dev/null 2>&1; then sync_ok=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$sync_ok" ] || { note sync "uv sync 失败(已重试 3 次)" failure; exit 1; }
|
||||
note sync ok success
|
||||
|
||||
uv run ruff check musicbot tests \
|
||||
|| { note ruff "ruff check 未通过" failure; exit 1; }
|
||||
note ruff ok success
|
||||
|
||||
# pyright 需要 node:镜像里没有就装 Debian 的(保底 pyright 自带下载)
|
||||
command -v node >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq nodejs; } || true
|
||||
uv run pyright \
|
||||
|| { note pyright "pyright 未通过" failure; exit 1; }
|
||||
note pyright ok success
|
||||
|
||||
uv run pytest -q \
|
||||
|| { note pytest "pytest 未通过" failure; exit 1; }
|
||||
note pytest ok success
|
||||
@@ -1,8 +1,8 @@
|
||||
# musicbot
|
||||
|
||||
QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两步点歌:
|
||||
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 图片语法 `` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
|
||||
@@ -36,6 +36,14 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/qqbotsdk),两
|
||||
|
||||
两个事件共用同一个 dataclass 与 handler,被动回复都用该消息的 `msg_id`(群聊 5 分钟内有效、每条最多回复 5 次)。全量事件**不支持 `event_id`**——官方 event_id 支持列表只有 `INTERACTION_CREATE`、`GROUP_ADD_ROBOT`、`GROUP_MSG_RECEIVE`。
|
||||
|
||||
## 斜杠指令(输入框 `/` 菜单)
|
||||
|
||||
群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时把 `点歌`/`歌词`/`帮助` 三条指令同步上去——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。
|
||||
|
||||
指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。
|
||||
|
||||
「帮助」消息带 `[🎤 点歌][📝 歌词]` 指令按钮(action `type=2`):点击把 `/点歌 ` 填进输入框,补上歌名发送即可;键盘无权限时自动回退纯文本。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,6 +36,7 @@ class BotConfig:
|
||||
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
|
||||
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
|
||||
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
|
||||
command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单)
|
||||
log_level: str = "INFO" # 控制台与文件日志级别
|
||||
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
||||
reload: bool = False # 热重载(开发用;--reload 亦可开启)
|
||||
@@ -50,6 +51,7 @@ class BotConfig:
|
||||
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
|
||||
withdraw=_flag("WITHDRAW", True),
|
||||
send_mode=_send_mode(),
|
||||
command_panel=_flag("COMMAND_PANEL", True),
|
||||
log_level=_log_level(),
|
||||
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
||||
reload=_flag("RELOAD", False),
|
||||
|
||||
+5
-1
@@ -82,7 +82,11 @@ def main(argv: list[str] | None = None) -> None:
|
||||
# 保证「监控的树」与「实际加载的配置」一致
|
||||
root = _project_root()
|
||||
# 监督者不配置日志:子进程自己会配,避免两边抢写同一个日志文件
|
||||
code = reload.run_supervisor([sys.executable, "-m", "musicbot"], root)
|
||||
try:
|
||||
code = reload.run_supervisor([sys.executable, "-m", "musicbot"], root)
|
||||
except KeyboardInterrupt:
|
||||
# 兜底 run_supervisor 循环间隙的 Ctrl+C,同样不打印 traceback
|
||||
code = 0
|
||||
sys.exit(code)
|
||||
try:
|
||||
asyncio.run(amain())
|
||||
|
||||
+84
-7
@@ -18,12 +18,15 @@ from loguru import logger
|
||||
from qqbotsdk import EventEmitter
|
||||
from qqbotsdk.api import ApiError, BotApi
|
||||
from qqbotsdk.events import Event
|
||||
from qqbotsdk.payloads import GroupAtMessage, Interaction
|
||||
from qqbotsdk.payloads import GroupAtMessage, Interaction, Ready
|
||||
|
||||
from .config import BotConfig
|
||||
from .panel import sync_command_panel
|
||||
|
||||
_SONG_RE = re.compile(r"点歌\s+(\S.*)")
|
||||
_LYRIC_RE = re.compile(r"歌词\s+(\S.*)")
|
||||
# @机器人 前缀("<@!12345> 点歌 稻香");开头的 "/"(斜杠指令)在 _normalize 剥
|
||||
_MENTION_RE = re.compile(r"<@!?\w+>")
|
||||
_LRC_TS_RE = re.compile(r"\[\d+:\d+(?:\.\d+)?\]")
|
||||
_LRC_TAG_RE = re.compile(r"(?:\[[^\]]*\])+")
|
||||
_LYRIC_LIMIT = 600
|
||||
@@ -37,7 +40,12 @@ _UPLOAD_TIMEOUT = ClientTimeout(total=120)
|
||||
# 群文件(file_type=4)上传仅限群主/管理员,语音(3)不受限;无权限时改发直链
|
||||
_NO_FILE_PERMISSION = 40093005
|
||||
|
||||
_HELP = "♪ 音乐 bot\n点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n歌词 <歌名>:查看歌词"
|
||||
_HELP = (
|
||||
"♪ 音乐 bot\n"
|
||||
"/点歌 <歌名 [歌手]>:出选歌菜单,点编号选歌\n"
|
||||
"/歌词 <歌名>:查看歌词\n"
|
||||
"输入框敲 / 也能唤出这些指令"
|
||||
)
|
||||
|
||||
_SOURCE_NAMES = {
|
||||
"tx": "QQ音乐", "kw": "酷我", "kg": "酷狗", "wy": "网易云", "mg": "咪咕",
|
||||
@@ -224,6 +232,16 @@ def _q(s: str) -> str:
|
||||
return quote(s, safe="")
|
||||
|
||||
|
||||
def _cmd_button(bid: str, label: str, data: str) -> dict:
|
||||
"""指令按钮(type=2):点击把 data 填进聊天输入框,用户补上参数再发送,
|
||||
等价于手敲斜杠指令——不产生回调,bot 收到的是普通消息。"""
|
||||
return {
|
||||
"id": bid,
|
||||
"render_data": {"label": label, "visited_label": label, "style": 1},
|
||||
"action": {"type": 2, "permission": {"type": 2}, "data": data},
|
||||
}
|
||||
|
||||
|
||||
async def _reply(
|
||||
api: BotApi,
|
||||
group_openid: str,
|
||||
@@ -555,6 +573,39 @@ 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:
|
||||
resp = await api.post_group_message(
|
||||
group_openid, content=_HELP, msg_id=msg_id, keyboard=_help_keyboard()
|
||||
)
|
||||
except _API_FAILS as e:
|
||||
logger.warning(f"带指令按钮的帮助发送失败,回退纯文本: {e!r}")
|
||||
try:
|
||||
resp = await api.post_group_message(
|
||||
group_openid, content=_HELP, msg_id=msg_id
|
||||
)
|
||||
except _API_FAILS as e:
|
||||
logger.warning(f"帮助发送失败: {e!r}")
|
||||
return
|
||||
if sent := _sent_id(resp):
|
||||
_mark(sent)
|
||||
|
||||
|
||||
async def _search_source(
|
||||
http: ClientSession, config: BotConfig, keyword: str, source: str
|
||||
) -> Song | None:
|
||||
@@ -680,6 +731,16 @@ async def _find(
|
||||
return song
|
||||
|
||||
|
||||
def _normalize(content: str) -> str:
|
||||
"""统一命令文本:剥掉 @机器人 前缀与开头的斜杠。
|
||||
|
||||
斜杠指令(开放平台「指令管理」配置后,群里输入框 / 唤出菜单触发)
|
||||
推来的 content 形如 "/点歌 稻香";@机器人是 "<@!bot> 点歌 稻香";
|
||||
全量模式是裸文本——三者归一后共用同一套命令解析。"""
|
||||
text = _MENTION_RE.sub("", content, count=1).strip()
|
||||
return text[1:].strip() if text.startswith("/") else text
|
||||
|
||||
|
||||
async def handle_command(
|
||||
api: BotApi,
|
||||
http: ClientSession,
|
||||
@@ -690,21 +751,29 @@ async def handle_command(
|
||||
) -> bool:
|
||||
"""解析一条群消息里的音乐命令并执行;返回是否命中。
|
||||
|
||||
选歌/播放/下载现在都由内嵌按钮(回调)承担,这里只处理文字命令。"""
|
||||
if match := _SONG_RE.search(content):
|
||||
裸文本(点歌/歌词/帮助)、@机器人 与斜杠指令(/点歌 …)共用这里;
|
||||
选歌/播放/下载由内嵌按钮(回调)承担,这里只处理文字命令。"""
|
||||
text = _normalize(content)
|
||||
if match := _SONG_RE.search(text):
|
||||
keyword = match.group(1).strip()
|
||||
logger.info(f"[{group_openid}] 点歌:{keyword}")
|
||||
await send_menu(api, http, config, group_openid, keyword, msg_id=msg_id)
|
||||
return True
|
||||
if match := _LYRIC_RE.search(content):
|
||||
if text == "点歌":
|
||||
await _reply(api, group_openid, "用法:点歌 <歌名 [歌手]>", msg_id=msg_id)
|
||||
return True
|
||||
if match := _LYRIC_RE.search(text):
|
||||
keyword = match.group(1).strip()
|
||||
logger.info(f"[{group_openid}] 查歌词:{keyword}")
|
||||
if song := await _find(api, http, config, group_openid, msg_id, keyword):
|
||||
await send_lyric(api, http, config, group_openid, song, msg_id=msg_id)
|
||||
return True
|
||||
if "音乐帮助" in content:
|
||||
if text == "歌词":
|
||||
await _reply(api, group_openid, "用法:歌词 <歌名>", msg_id=msg_id)
|
||||
return True
|
||||
if text == "帮助" or "音乐帮助" in text:
|
||||
logger.info(f"[{group_openid}] 查看帮助")
|
||||
await _reply(api, group_openid, _HELP, msg_id=msg_id)
|
||||
await _send_help(api, group_openid, msg_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -763,3 +832,11 @@ def register(ee: EventEmitter, config: BotConfig, http: ClientSession) -> None:
|
||||
api, http, config, act.group_openid, act.id,
|
||||
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)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""指令面板(群里输入框敲 / 唤出的指令菜单)自动注册。
|
||||
|
||||
调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ:
|
||||
启动与每次重连(READY)各同步一次——没有就创建(target_type=all
|
||||
对全部群生效),内容有变就更新,一致则跳过,幂等可重入。
|
||||
失败只记日志不影响运行(如功能未对机器人开放)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from loguru import logger
|
||||
from qqbotsdk.api import ApiError, BotApi
|
||||
|
||||
_API_FAILS = (ApiError, TimeoutError, ClientError)
|
||||
|
||||
# 面板挂在群聊场景,对所有群生效
|
||||
_SCOPE = "group"
|
||||
_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]:
|
||||
"""面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。"""
|
||||
return [
|
||||
(
|
||||
i.get("name"),
|
||||
i.get("type"),
|
||||
i.get("desc"),
|
||||
i.get("only_admin", False),
|
||||
i.get("link"),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
|
||||
|
||||
def _unwrap(body: Any, key: str) -> Any:
|
||||
"""响应可能整体包一层 {"data": ...},也可能直接是数据本体,两者都认。"""
|
||||
if not isinstance(body, dict):
|
||||
return body
|
||||
inner = body.get("data")
|
||||
return inner if key not in body and isinstance(inner, dict) else body
|
||||
|
||||
|
||||
def _mine(records: list[Any]) -> dict | None:
|
||||
"""在面板列表里找本 bot 注册过的那张(按 remark 识别)。"""
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
if (record.get("panel") or {}).get("remark") == _REMARK:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
async def sync_command_panel(api: BotApi) -> 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
|
||||
)
|
||||
if unchanged:
|
||||
logger.info("指令面板已是最新(点歌/歌词/帮助)")
|
||||
return
|
||||
if mine:
|
||||
await api.put(f"/v2/panels/{mine['panel_id']}", json={"panel": _PANEL_BODY})
|
||||
logger.info("指令面板已更新(点歌/歌词/帮助)")
|
||||
else:
|
||||
body = {"scope": _SCOPE, "panel": _PANEL_BODY, "target_type": _TARGET_TYPE}
|
||||
await api.post("/v2/panels", json=body)
|
||||
logger.info("指令面板已创建(点歌/歌词/帮助,对全部群生效)")
|
||||
except _API_FAILS as e:
|
||||
# 失败不影响运行;指令可改为在开放平台后台手工配置
|
||||
logger.warning(f"指令面板同步失败: {e!r}")
|
||||
+23
-3
@@ -23,6 +23,10 @@ CHILD_ENV = "_MUSICBOT_RELOAD_CHILD"
|
||||
# 轮询间隔(秒):够快地感知保存动作,又不至于空耗 CPU
|
||||
_POLL_INTERVAL = 1.0
|
||||
|
||||
# Ctrl+C 时给子进程的自行退出窗口(秒):子进程收到同一信号正在收尾
|
||||
# (打退出日志、关连接),等它退完,超时才强杀
|
||||
_GRACE_SECONDS = 5.0
|
||||
|
||||
# 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启)
|
||||
_WATCH_SUFFIXES = (".py", ".toml")
|
||||
|
||||
@@ -72,10 +76,20 @@ def _spawn(cmd: list[str], root: Path) -> subprocess.Popen:
|
||||
return subprocess.Popen(cmd, cwd=root, env={**os.environ, CHILD_ENV: "1"})
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen) -> None:
|
||||
"""结束子进程及其后代(bot 可能再拉起 Python 子进程)。"""
|
||||
def _terminate(proc: subprocess.Popen, grace: float = 0) -> None:
|
||||
"""结束子进程及其后代(bot 可能再拉起 Python 子进程)。
|
||||
|
||||
grace > 0 时先等子进程自行退出,超时才强杀——用于 Ctrl+C 收尾场景;
|
||||
文件变更重启时子进程并不知情、不会自己退,必须保持立即强杀。
|
||||
"""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
if grace:
|
||||
try:
|
||||
proc.wait(timeout=grace)
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(f"子进程未在 {grace:g}s 内自行退出,强制结束")
|
||||
if sys.platform == "win32":
|
||||
# terminate() 只结束直接子进程,/T 连整棵进程树一起收掉
|
||||
subprocess.run(
|
||||
@@ -103,6 +117,7 @@ def run_supervisor(cmd: list[str], root: Path) -> int:
|
||||
while True:
|
||||
logger.info("启动 bot 子进程")
|
||||
proc = _spawn(cmd, root)
|
||||
grace = 0.0
|
||||
try:
|
||||
before = snapshot(root)
|
||||
while proc.poll() is None:
|
||||
@@ -117,6 +132,11 @@ def run_supervisor(cmd: list[str], root: Path) -> int:
|
||||
else:
|
||||
# 子进程自行退出(含 Ctrl-C 透传):不重启,原样返回退出码
|
||||
return proc.returncode or 0
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl+C 同时送达子进程与此监督循环:吞掉异常不打印 traceback,
|
||||
# 退出码归零,子进程的收尾交给下方带宽限的回收
|
||||
grace = _GRACE_SECONDS
|
||||
return 0
|
||||
finally:
|
||||
# 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿
|
||||
_terminate(proc)
|
||||
_terminate(proc, grace)
|
||||
+1
-1
@@ -19,7 +19,7 @@ module-root = "" # flat 布局:musicbot/ 在仓库根而非 src/ 下
|
||||
dev = ["pyright", "pytest", "pytest-asyncio", "ruff"]
|
||||
|
||||
[tool.uv.sources]
|
||||
qqbotsdk = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" }
|
||||
qqbotsdk = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
uv run musicbot --reload
|
||||
@echo off
|
||||
rem Run the bot in its own console: cmd /c executes a command line, not a
|
||||
rem batch, so Ctrl+C shuts it down cleanly without cmd's
|
||||
rem "Terminate batch job (Y/N)?" prompt. This launcher exits immediately.
|
||||
start "musicbot" /d "%~dp0" cmd /c "uv run musicbot --reload"
|
||||
+123
-1
@@ -21,6 +21,7 @@ from musicbot.music import (
|
||||
search,
|
||||
send_menu,
|
||||
)
|
||||
from musicbot.panel import sync_command_panel
|
||||
|
||||
|
||||
def make_api(
|
||||
@@ -29,12 +30,14 @@ def make_api(
|
||||
fail_event: bool = False,
|
||||
fail_upload: bool = False,
|
||||
fail_file_perm: bool = False,
|
||||
fail_keyboard: bool = False,
|
||||
):
|
||||
"""替换 upload_group_file / Markdown 消息以模拟失败,request 记录其余调用。
|
||||
|
||||
fail_event=True 时拒绝一切带 event_id 的被动回复(模拟互动回执失效);
|
||||
fail_upload=True 时上传一律抛 TimeoutError(模拟 QQ 侧抓流超过请求超时);
|
||||
fail_file_perm=True 时 file_type=4(文件)返回 40093005(仅群主/管理员可传)。
|
||||
fail_file_perm=True 时 file_type=4(文件)返回 40093005(仅群主/管理员可传);
|
||||
fail_keyboard=True 时拒绝一切带 keyboard 的消息(模拟键盘无权限)。
|
||||
"""
|
||||
calls: list[dict] = []
|
||||
|
||||
@@ -43,6 +46,8 @@ def make_api(
|
||||
body = kwargs.get("json", {})
|
||||
if fail_md and body.get("msg_type") == 2:
|
||||
raise ApiError(400, 304036, "无 Markdown 权限")
|
||||
if fail_keyboard and "keyboard" in body:
|
||||
raise ApiError(400, 304003, "无键盘权限")
|
||||
if fail_event and "event_id" in body:
|
||||
raise ApiError(400, 40034025, "请求参数event_id无效")
|
||||
if path.endswith("/files"):
|
||||
@@ -568,6 +573,55 @@ async def test_page_button_new_menu(env):
|
||||
assert calls == [] # 非音乐按钮不处理也不回应
|
||||
|
||||
|
||||
async def test_slash_commands_share_text_parsing(env):
|
||||
"""斜杠指令与裸文本命令同一套解析:剥掉开头的 / 与 @机器人 前缀。"""
|
||||
http, config = env
|
||||
for content in ("点歌 稻香", "/点歌 稻香", "<@!BOT123> /点歌 稻香"):
|
||||
api, calls = make_api()
|
||||
assert await handle_command(api, http, config, "G1", "m1", content)
|
||||
body = posts(calls)[0]["json"]
|
||||
assert body["msg_type"] == 2
|
||||
assert "点歌:稻香" in body["markdown"]["content"]
|
||||
|
||||
api, calls = make_api()
|
||||
assert await handle_command(api, http, config, "G1", "m1", "/歌词 稻香")
|
||||
assert posts(calls)[0]["json"]["content"] == "第一行\n第二行"
|
||||
|
||||
|
||||
async def test_slash_help_sends_command_keyboard(env):
|
||||
"""/帮助 出帮助正文 + type=2 指令按钮(点一下往输入框填斜杠指令)。"""
|
||||
http, config = env
|
||||
api, calls = make_api()
|
||||
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
|
||||
body = posts(calls)[0]["json"]
|
||||
assert "点歌" in body["content"] and "歌词" in body["content"]
|
||||
rows = buttons(body)
|
||||
assert len(rows) == 1
|
||||
song_btn, lyric_btn = rows[0]["buttons"]
|
||||
assert song_btn["action"]["type"] == 2
|
||||
assert song_btn["action"]["data"] == "/点歌 "
|
||||
assert lyric_btn["action"]["data"] == "/歌词 "
|
||||
|
||||
|
||||
async def test_slash_help_falls_back_to_plain_text(env):
|
||||
http, config = env
|
||||
api, calls = make_api(fail_keyboard=True)
|
||||
assert await handle_command(api, http, config, "G1", "m1", "/帮助")
|
||||
sent = posts(calls)
|
||||
assert len(sent) == 1 # 键盘被拒,回退为一条纯文本
|
||||
assert "keyboard" not in sent[0]["json"]
|
||||
assert "点歌" in sent[0]["json"]["content"]
|
||||
|
||||
|
||||
async def test_bare_command_replies_usage(env):
|
||||
"""斜杠菜单的参数可选时可能不带参发出来:只敲命令给用法提示。"""
|
||||
http, config = env
|
||||
for content, hint in (("点歌", "用法:点歌"), ("/歌词", "用法:歌词")):
|
||||
api, calls = make_api()
|
||||
assert await handle_command(api, http, config, "G1", "m1", content)
|
||||
assert posts(calls)[0]["json"]["content"].startswith(hint)
|
||||
|
||||
|
||||
async def test_lyric_miss_help_and_no_hit(env):
|
||||
http, config = env
|
||||
api, calls = make_api()
|
||||
@@ -604,6 +658,74 @@ def test_trim_strips_tags_and_truncates():
|
||||
assert len(trimmed) <= 100 + len("\n……(歌词过长已截断)")
|
||||
|
||||
|
||||
class PanelRecording(BotApi):
|
||||
"""预设 /v2/panels 响应并记录调用:fail_get=True 时查询抛 403。"""
|
||||
|
||||
def __init__(self, records: list | None = None, fail_get: bool = False) -> None:
|
||||
self.records = records or []
|
||||
self.fail_get = fail_get
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
||||
if self.fail_get and path == "/v2/panels":
|
||||
raise ApiError(403, 11253, "无权限")
|
||||
self.calls.append({"method": method, "path": path, **kwargs})
|
||||
if method == "GET":
|
||||
# 响应整体包一层 {"data": ...},顺带覆盖 _unwrap 的拆包路径
|
||||
return {"data": {"records": self.records, "is_end": True}}
|
||||
if method == "POST":
|
||||
return {"panel_id": "P1"}
|
||||
return {"version": 2}
|
||||
|
||||
|
||||
async def test_panel_created_when_missing():
|
||||
api = PanelRecording()
|
||||
await sync_command_panel(api)
|
||||
assert [(c["method"], c["path"]) for c in api.calls] == [
|
||||
("GET", "/v2/panels"),
|
||||
("POST", "/v2/panels"),
|
||||
]
|
||||
body = api.calls[1]["json"]
|
||||
assert body["scope"] == "group" and body["target_type"] == "all"
|
||||
assert [i["name"] for i in body["panel"]["items"]] == ["点歌", "歌词", "帮助"]
|
||||
|
||||
|
||||
async def test_panel_skipped_when_identical():
|
||||
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)
|
||||
assert [(c["method"], c["path"]) for c in api.calls] == [("GET", "/v2/panels")]
|
||||
|
||||
|
||||
async def test_panel_updated_when_differs():
|
||||
# 这条用响应不包 {"data"} 外层的形态,覆盖 _unwrap 的另一分支
|
||||
api = PanelRecording(
|
||||
records=[
|
||||
{
|
||||
"panel_id": "P9",
|
||||
"panel": {
|
||||
"items": [{"name": "点歌", "type": "command", "desc": "旧"}],
|
||||
"remark": "音乐bot",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
await sync_command_panel(api)
|
||||
assert (api.calls[1]["method"], api.calls[1]["path"]) == ("PUT", "/v2/panels/P9")
|
||||
|
||||
|
||||
async def test_panel_failure_swallowed():
|
||||
api = PanelRecording(fail_get=True)
|
||||
await sync_command_panel(api) # 失败只记日志,绝不抛出
|
||||
assert api.calls == []
|
||||
|
||||
|
||||
def test_config_send_mode(monkeypatch):
|
||||
"""SEND_MODE 默认 link(避开文件上传权限),file 可显式开启,非法值回落 link。"""
|
||||
from musicbot.config import BotConfig
|
||||
|
||||
@@ -487,7 +487,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "loguru" },
|
||||
{ name = "qqbotsdk", git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" },
|
||||
{ name = "qqbotsdk", url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -690,7 +690,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "qqbotsdk"
|
||||
version = "0.1.0"
|
||||
source = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git#b37a53f6e0dec360a3e684fbafd7140ec00a2711" }
|
||||
source = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "cryptography" },
|
||||
@@ -698,6 +698,16 @@ dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "ujson" },
|
||||
]
|
||||
sdist = { hash = "sha256:59960044772157383d1fba3506531e34f89102d17fcad9a14a99873566b6c526" }
|
||||
|
||||
[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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
|
||||
Reference in new issue
Block a user