Compare commits

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

_song_data 给歌曲 id 一并 % 转义(_q),_song_of_data 对应 unquote;
旧按钮 id 不含 %,unquote 幂等不受影响。附 bili 往返回归测试。
2026-10-04 17:54:24 +08:00
snowy-yang c364506925 feat: 文件代理、群消息防双发、下载指令出选歌菜单与音频平台选集
代码测试 / ruff + pytest (push) Canceled after 0s
- 文件代理(PUBLIC_URL):上传与直链文本的 URL 换成 bot 公开地址,请求
  到来现场重解析直链再转发字节,绕开 QQ 抓风控直链失败(40093007)与
  qs 本地流外网不可达;PROXY_PORT 指定监听端口
- 群消息去重补第二把钥匙:全量模式下 @消息同时命中两事件且 msg_id 不同,
  只按 msg_id 拦不住,再加「群+发送人+文本」10 秒短窗(修双发)
- 「<平台>下载 <歌名>」改出该平台的选歌菜单(与 /点歌 同款交互、spage
  翻页锁定平台),不再直接下第一首
- 猫耳/懒人/蜻蜓节目级条目点编号先出选集菜单(/album):猫耳选集接口
  忽略翻页参数,一次拉 50 集本地切片;懒人/蜻蜓走服务端翻页;空选集
  (已下线/需登录)给明确提示
- 指令面板条目按 /sources 现算、按宽度裁剪(修 30013 超限);装配入口
  改用 Bot 门面;qqbotsdk 依赖更名 qqbotsdk-py(撤 GitHub archive 源)
2026-10-03 23:55:33 +08:00
snowy-yang 324fe86962 按钮回调防抖与先应答:10 秒去重、进门回应互动、播放/下载先回占位消息
- 同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;连点
  仍应答互动,关掉客户端 loading
- 播放/下载结果以 msg_seq=2 补发(同一事件相同 msg_seq 的二次回复会被
  QQ 拒绝)
- 一并入库此前未提交的 /sources 平台发现(直链回退链按服务器能力动态
  解析);uv.lock 随上游 qqbotsdk main 更新 sdist hash
2026-09-30 18:59:17 +08:00
snowy-yang 24c235c93d 修复 CI:slim 镜像没有 curl,网络操作改用 python 标准库
ci/fetch ok
ci/pip ok
ci/sync ok
ci/ruff ok
ci/pytest ok
代码测试 / ruff + pytest (push) Successful in 1m16s
首轮实跑 1s 即死,日志定位 /var/run/act/workflow/0.sh: curl: command
not found——python:3.13-slim 不带 curl/wget/git;SDK 仓库那份 ci.yml
的「curl 可用」是宿主机 probe 验证的,进了 container 不成立(且该
方案在 Gitea 上从未跑绿过,后来整体迁去了 GitHub)。

- archive 拉取与 commit status 上报改用 python3 urllib(容器里保证
  在场的只有 python),note() 参数经 argv 传入、token 走环境变量
- 去掉 pyright 及其 apt 装 node 的兜底:多一条网络依赖链,且本地
  pyright 常绿,CI 保持 ruff + pytest 即可
- 各步骤不再吞输出,日志可直接定位
2026-09-24 22:00:07 +08:00
snowy-yang 0145b7b01a 新增 Gitea Actions 代码测试流水线
代码测试 / ruff + pyright + pytest (push) Failing after 2s
沿用 SDK 仓库实跑验证过的形态:container 锁 python:3.13-slim(runner
默认镜像工具链不明,曾 7s 即死)、不走 actions/checkout 改从 Gitea
archive 接口拉源码 tarball(runner 对 github 连通性不稳)、关键步骤
成败上报 commit status(日志匿名不可读,status 远程可读;GITEA_TOKEN
未配置时静默降级)。检查项 ruff + pyright + pytest(本地全绿),
uv sync 针对依赖里的 GitHub archive 源失败自动重试 3 次。
2026-09-24 21:55:48 +08:00
snowy-yang 508604610f qqbotsdk 依赖改走 GitHub archive 源
GitHub(snowy-yang/qqbotsdk)已是 SDK 主仓库,Gitea 反为其镜像。
[tool.uv.sources] 从 Gitea git 源切到 GitHub archive tarball 的 url 源:
安装不再需要 git,uv 按 sdist 构建、lock 记录内容 hash;升级依赖用
uv lock --upgrade-package qqbotsdk 重取 main 最新。README 的 SDK 链接
同步指向 GitHub。
2026-09-24 21:55:41 +08:00
snowy-yang 94ee3298b7 新增斜杠指令面板:/v2/panels 自动注册,帮助消息带指令按钮
- panel.py 调开放平台 2026-08 新增的 /v2/panels 接口,把 点歌/歌词/帮助
  三条指令同步到输入框 / 菜单:READY 时幂等同步(没有则创建、内容有变
  则更新、一致则跳过,重连自愈),target_type=all 对全部群生效;失败
  只记日志不影响运行;COMMAND_PANEL=off 可关闭
- 斜杠指令与 @机器人 消息归一解析:_normalize 剥开头 / 与 @ 前缀,
  与裸文本命令共用 handle_command(/点歌 稻香、@bot 点歌 稻香、
  点歌 稻香 三种写法等价)
- 只敲命令不带参数(斜杠指令参数可为空)时回复用法提示
- 帮助消息带 [🎤 点歌][📝 歌词] 指令按钮(action type=2,点击把
  /点歌 填进输入框),键盘无权限自动回退纯文本;帮助文案改以 / 指令呈现
2026-09-24 21:55:32 +08:00
snowy-yang d71809d144 热重载监督者优雅响应 Ctrl+C:先等子进程收尾再回收
- run_supervisor 捕获 KeyboardInterrupt:不打 traceback、退出码归零,
  给子进程 _GRACE_SECONDS=5s 自行收尾窗口(打退出日志、关连接),
  超时才 taskkill /F /T;文件变更重启仍立即强杀(子进程不知情不会自己退)
- main() 兜底监督循环间隙的 Ctrl+C
- start.bat 改为发射器:start 新开独立控制台跑 cmd /c 命令行(非批
  处理),Ctrl+C 直接停进程,规避「终止批处理操作吗(Y/N)」,发射器
  即退;bat 保持纯 ASCII 防 GBK 乱码
2026-09-24 21:55:17 +08:00
13 changed files with 2556 additions and 214 deletions

No files matched your search

+13 -1
View File
@@ -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
+80
View File
@@ -0,0 +1,80 @@
name: 代码测试
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
test:
name: ruff + 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。
# slim 镜像没有 curl/wget/git,网络操作一律用容器里保证在场的
# python 标准库;每步结果写进 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
export SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
note() {
python3 -c '
import sys, os, json, urllib.request
ctx, desc, state = sys.argv[1:4]
req = urllib.request.Request(
os.environ["SERVER"] + "/api/v1/repos/" + os.environ["GITHUB_REPOSITORY"]
+ "/statuses/" + os.environ["GITHUB_SHA"],
data=json.dumps(
{"state": state, "context": "ci/" + ctx, "description": desc}
).encode(),
headers={
"Content-Type": "application/json",
"Authorization": "token " + os.environ["TOKEN"],
},
)
try:
urllib.request.urlopen(req, timeout=10)
except Exception:
pass # status 只是旁路:token 未配置或网络抖动都不能影响主流程
' "$1" "$2" "$3"
}
trap 'note error "步骤意外失败,详见 Actions 日志" failure' ERR
python3 -c "import urllib.request; urllib.request.urlretrieve(
'$SERVER/api/v1/repos/$GITHUB_REPOSITORY/archive/$GITHUB_SHA.tar.gz',
'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 \
|| { 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; 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
uv run pytest \
|| { note pytest "pytest 未通过" failure; exit 1; }
note pytest ok success
+29 -5
View File
@@ -1,22 +1,33 @@
# 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 图片语法 `![封面 #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 换成 `<PUBLIC_URL>/file/<key>/<歌名>.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` 控制;目录不可写时自动退化为仅控制台
- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
## 配置
复制 `.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 启动时拉取并记日志),服务器增删源后「`<平台>下载`」指令与直链候选源自动跟着变。
点歌相关开关:
@@ -25,6 +36,9 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://gitea.4i.hk/hhhge/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` 已带) |
@@ -36,6 +50,16 @@ 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` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。
面板条目按 MusicServer `/sources` **现算**:`点歌`/`歌词` + `热搜`/`榜单`(有平台支持才注册)+ 各平台「`<平台>下载`」(search+url 能力齐备才注册,指令名取惯用/短名:酷狗、QQ音乐、网易云……)+ `帮助` 垫底。当前 9 平台下共 14 条(面板接口上限 20 条,条目生成时按 name ≤14 字符、desc ≤30 字符裁剪)。
指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。
「帮助」消息带 `[🎤 点歌][📝 歌词][🔥 热搜][🏆 榜单]` 指令按钮(action `type=2`):点击把对应斜杠指令填进输入框,补上歌名(如需要)发送即可;键盘无权限时自动回退纯文本。
## 运行
```bash
+27
View File
@@ -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,10 @@ 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") # 日志目录;写不进去时自动退化为仅控制台
reload: bool = False # 热重载(开发用;--reload 亦可开启)
@@ -43,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"),
@@ -50,6 +73,10 @@ 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")),
reload=_flag("RELOAD", False),
+9 -5
View File
@@ -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:
@@ -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())
+1084 -155
View File
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
"""指令面板(群里输入框敲 / 唤出的指令菜单)自动注册。
调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ:
启动与每次重连(READY)各同步一次——没有就创建(target_type=all
对全部群生效),内容有变就更新,一致则跳过,幂等可重入。
条目由调用方给定(music.panel_items 按 MusicServer /sources 现算:
基础指令 + 热搜/榜单 + 各平台「<平台>下载」),服务器增删源跟着变。
接口上限(每面板 20 条;name/desc 按宽度限 14/30——汉字计 2)由条目
生成方裁剪。失败只记日志不影响运行(如功能未对机器人开放)。
"""
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"
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, 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(
items
)
if unchanged:
logger.info(f"指令面板已是最新({len(items)} 条指令)")
return
if mine:
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": {"items": items, "remark": _REMARK},
"target_type": _TARGET_TYPE,
}
await api.post("/v2/panels", json=body)
logger.info(f"指令面板已创建({len(items)} 条指令,对全部群生效)")
except _API_FAILS as e:
# 失败不影响运行;指令可改为在开放平台后台手工配置
logger.warning(f"指令面板同步失败: {e!r}")
+143
View File
@@ -0,0 +1,143 @@
"""文件代理:bot 自建 HTTP 服务,把播放直链换成自己的公开 URL。
QQ 服务器侧上传富媒体(语音 file_type=3 / 群文件 4)要现场抓取 URL 入库,
两类直链它抓不到:tx 等源的 CDN 风控直链(QQ 侧报 40093007,bot 自己的
出口反而常能抓),以及 qs 登录态直链(MusicServer 本地解密流代理,只有
bot 所在网络可达)。开启后 send_song 发出的 URL(上传与直链文本同源)一律
换成 <PUBLIC_URL>/file/<key>/<文件名>:
- 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="上游取流失败")
+23 -3
View File
@@ -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 -4
View File
@@ -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 = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" }
[tool.pytest.ini_options]
asyncio_mode = "auto"
+5 -1
View File
@@ -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"
+1045 -35
View File
File diff suppressed because it is too large. Load diff
Generated
+9 -5
View File
@@ -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", git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" },
{ name = "qqbotsdk-py" },
]
[package.metadata.requires-dev]
@@ -688,9 +688,9 @@ wheels = [
]
[[package]]
name = "qqbotsdk"
version = "0.1.0"
source = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git#b37a53f6e0dec360a3e684fbafd7140ec00a2711" }
name = "qqbotsdk-py"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "cryptography" },
@@ -698,6 +698,10 @@ dependencies = [
{ name = "python-dotenv" },
{ name = "ujson" },
]
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]]
name = "ruff"