Compare commits
8
Commits
6b6492dc8d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb60137ac7 | ||
|
|
c364506925 | ||
|
|
324fe86962 | ||
|
|
24c235c93d | ||
|
|
0145b7b01a | ||
|
|
508604610f | ||
|
|
94ee3298b7 | ||
|
|
d71809d144 |
No files matched your search
+13
-1
@@ -3,7 +3,8 @@ APPID=你的AppID
|
|||||||
APPSECRET=你的AppSecret
|
APPSECRET=你的AppSecret
|
||||||
CONNECTER=websocket
|
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_SERVER_URL=http://127.0.0.1:16688
|
||||||
MUSIC_QUALITY=128k
|
MUSIC_QUALITY=128k
|
||||||
MUSIC_SOURCE=tx # 默认 QQ 音乐,留空聚合全源
|
MUSIC_SOURCE=tx # 默认 QQ 音乐,留空聚合全源
|
||||||
@@ -21,6 +22,17 @@ WITHDRAW=on
|
|||||||
# 播放按钮不受影响,始终发语音。非法值按 link 处理。
|
# 播放按钮不受影响,始终发语音。非法值按 link 处理。
|
||||||
SEND_MODE=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);
|
# 日志:级别可选 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL(默认 INFO);
|
||||||
# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
|
# 日志同时输出到控制台与 LOG_DIR/musicbot.log(按天轮转、保留 7 天)
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
# musicbot
|
# 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. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
|
2. **歌曲卡片**——点编号弹出卡片:专辑封面图 + 歌名/歌手/来源 + `[▶ 播放][⬇ 下载]` 回调按钮,再点才执行
|
||||||
|
|
||||||
- **专辑封面**:卡片用 Markdown 图片语法 `` 显示封面(QQ 后台会代理并缓存图片到客户端,不落库);封面来自 MusicServer 的 `img` 字段,tx/wy 有值,kw/kg 无(这些源卡片就不带图)
|
其余指令(都能从输入框 `/` 菜单直接触发):
|
||||||
|
|
||||||
|
- **`/<平台>下载 <歌名>`**(如 `/酷狗下载 稻香`):在指定平台搜歌、出该平台的选歌菜单(与 `/点歌` 同款交互:点编号弹卡片、再点播放/下载;翻页锁定该平台);平台与指令名按 MusicServer `/sources` 动态生成(服务器加源即自动出现新指令),全名/短名/惯用名都可(`酷狗下载`、`酷狗音乐下载`;`网易云下载`、`网易音乐下载`)
|
||||||
|
- **`/热搜`**:拉取热搜词(编号列表 + 词按钮),点词直接出该词的选歌菜单
|
||||||
|
- **音频平台选集菜单**:猫耳FM/懒人/蜻蜓的搜索结果是节目级条目(一个节目多集音频,猫耳为 `d:` 前缀、懒人是书、蜻蜓是频道,直接取链会失败或只拿到一集),点编号先出**选集菜单**(`/album` 接口,标题带总集数、可翻页),选集再点编号才弹卡片播放/下载;猫耳的选集接口不支持翻页参数,一次拉 50 集本地切片分页(超过 50 集的节目被截断),懒人/蜻蜓走服务端翻页;选集为空(已下线/需登录)给明确提示。喜马拉雅搜索返回单集级条目(带真实时长),不受影响
|
||||||
|
- **`/榜单`**:拉取榜单列表,点榜单名直接出该榜单的选歌菜单(可翻页,按服务器返回的总数判断)
|
||||||
|
- **`/歌词 <歌名>`**:歌词按源搜索,无歌词能力的平台(喜马拉雅等音频源)给明确提示
|
||||||
|
|
||||||
|
- **专辑封面**:卡片与选歌菜单都用 Markdown 图片语法 `` 显示封面(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 权限自动回退文本
|
- 回调按钮(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),取直链时附带歌名/歌手/时长(原源取流失败 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);关闭后所有消息都保留
|
- **自动撤回**(`WITHDRAW`,默认开):翻页撤旧菜单、选歌后撤菜单、下载/播放点击后撤卡片(无论发送成败都撤,卡片是一次性 UI);关闭后所有消息都保留
|
||||||
|
- **回调防抖与先应答**:按钮回调进门先回应互动(`PUT /interactions`,几秒内不应答 QQ 客户端就报超时),同一互动 uuid(平台重复推送)或同群同按钮 10 秒内只处理一次;播放/下载先回「正在获取」占位消息再取直链,结果以递增的 `msg_seq` 补发(同一事件相同 `msg_seq` 的第二次回复会被 QQ 拒绝)
|
||||||
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
|
- **日志**:控制台 + `LOG_DIR/musicbot.log`(按天轮转、保留 7 天),级别由 `LOG_LEVEL` 控制;目录不可写时自动退化为仅控制台
|
||||||
- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启
|
- **热重载(开发用)**:监控 `musicbot/` 下的 `.py` 与 `intents.toml`/`.env`,变更后重启 bot 子进程(重连一次 websocket)。默认关,`uv run musicbot --reload`(`start.bat` 已带)或 `RELOAD=on` 开启
|
||||||
- 按钮回调需 `intents.toml` 开启 `INTERACTION`(本仓库已开启)
|
- 按钮回调需 `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 行所限) |
|
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
|
||||||
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
|
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
|
||||||
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
|
| `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_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
|
||||||
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
|
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
|
||||||
| `RELOAD` | `off` | 热重载;`on` 等价于命令行 `--reload`(`start.bat` 已带) |
|
| `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`。
|
两个事件共用同一个 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
|
```bash
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
||||||
_MAX_MENU_PAGE_SIZE = 20
|
_MAX_MENU_PAGE_SIZE = 20
|
||||||
|
|
||||||
|
# PUBLIC_URL 不带端口时文件代理的监听端口(MusicServer 默认在 16688)
|
||||||
|
_DEFAULT_PROXY_PORT = 16689
|
||||||
|
|
||||||
_LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL")
|
_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"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BotConfig:
|
class BotConfig:
|
||||||
music_server_url: str
|
music_server_url: str
|
||||||
@@ -36,6 +54,10 @@ 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" 直接发直链
|
||||||
|
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_level: str = "INFO" # 控制台与文件日志级别
|
||||||
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
||||||
reload: bool = False # 热重载(开发用;--reload 亦可开启)
|
reload: bool = False # 热重载(开发用;--reload 亦可开启)
|
||||||
@@ -43,6 +65,7 @@ class BotConfig:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def load(cls) -> "BotConfig":
|
def load(cls) -> "BotConfig":
|
||||||
page_size = int(os.getenv("MENU_PAGE_SIZE", "10"))
|
page_size = int(os.getenv("MENU_PAGE_SIZE", "10"))
|
||||||
|
public_url = os.getenv("PUBLIC_URL", "").strip().rstrip("/")
|
||||||
return cls(
|
return cls(
|
||||||
music_server_url=os.getenv("MUSIC_SERVER_URL", "http://127.0.0.1:16688"),
|
music_server_url=os.getenv("MUSIC_SERVER_URL", "http://127.0.0.1:16688"),
|
||||||
music_quality=os.getenv("MUSIC_QUALITY", "128k"),
|
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),
|
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(),
|
||||||
|
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_level=_log_level(),
|
||||||
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
||||||
reload=_flag("RELOAD", False),
|
reload=_flag("RELOAD", False),
|
||||||
|
|||||||
+9
-5
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from qqbotsdk import EventEmitter, run_loop
|
from qqbotsdk import Bot
|
||||||
|
|
||||||
from . import reload
|
from . import reload
|
||||||
from .config import BotConfig
|
from .config import BotConfig
|
||||||
@@ -48,11 +48,11 @@ async def amain() -> None:
|
|||||||
config = BotConfig.load()
|
config = BotConfig.load()
|
||||||
setup_logging(config)
|
setup_logging(config)
|
||||||
logger.info(f"musicbot 启动,日志级别 {config.log_level}")
|
logger.info(f"musicbot 启动,日志级别 {config.log_level}")
|
||||||
ee = EventEmitter() # 队列由 emitter 自建,run_loop 复用同一实例
|
bot = Bot() # 构造即读 .env 并校验 SDK 配置(APPID/APPSECRET/CONNECTER)
|
||||||
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
|
# bot 自建连接池:直链探测不受 SDK 的 TIMEOUT 约束
|
||||||
async with ClientSession() as http:
|
async with ClientSession() as http:
|
||||||
register(ee, config, http)
|
register(bot, config, http)
|
||||||
await run_loop(ee)
|
await bot.start()
|
||||||
|
|
||||||
|
|
||||||
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
@@ -82,7 +82,11 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
# 保证「监控的树」与「实际加载的配置」一致
|
# 保证「监控的树」与「实际加载的配置」一致
|
||||||
root = _project_root()
|
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)
|
sys.exit(code)
|
||||||
try:
|
try:
|
||||||
asyncio.run(amain())
|
asyncio.run(amain())
|
||||||
|
|||||||
+1084
-155
File diff suppressed because it is too large.
Load diff
@@ -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}")
|
||||||
@@ -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
@@ -23,6 +23,10 @@ CHILD_ENV = "_MUSICBOT_RELOAD_CHILD"
|
|||||||
# 轮询间隔(秒):够快地感知保存动作,又不至于空耗 CPU
|
# 轮询间隔(秒):够快地感知保存动作,又不至于空耗 CPU
|
||||||
_POLL_INTERVAL = 1.0
|
_POLL_INTERVAL = 1.0
|
||||||
|
|
||||||
|
# Ctrl+C 时给子进程的自行退出窗口(秒):子进程收到同一信号正在收尾
|
||||||
|
# (打退出日志、关连接),等它退完,超时才强杀
|
||||||
|
_GRACE_SECONDS = 5.0
|
||||||
|
|
||||||
# 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启)
|
# 监控的文件类型(源码与订阅/环境配置——两者都在启动时读取,改了需重启)
|
||||||
_WATCH_SUFFIXES = (".py", ".toml")
|
_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"})
|
return subprocess.Popen(cmd, cwd=root, env={**os.environ, CHILD_ENV: "1"})
|
||||||
|
|
||||||
|
|
||||||
def _terminate(proc: subprocess.Popen) -> None:
|
def _terminate(proc: subprocess.Popen, grace: float = 0) -> None:
|
||||||
"""结束子进程及其后代(bot 可能再拉起 Python 子进程)。"""
|
"""结束子进程及其后代(bot 可能再拉起 Python 子进程)。
|
||||||
|
|
||||||
|
grace > 0 时先等子进程自行退出,超时才强杀——用于 Ctrl+C 收尾场景;
|
||||||
|
文件变更重启时子进程并不知情、不会自己退,必须保持立即强杀。
|
||||||
|
"""
|
||||||
if proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
return
|
return
|
||||||
|
if grace:
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=grace)
|
||||||
|
return
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning(f"子进程未在 {grace:g}s 内自行退出,强制结束")
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
# terminate() 只结束直接子进程,/T 连整棵进程树一起收掉
|
# terminate() 只结束直接子进程,/T 连整棵进程树一起收掉
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -103,6 +117,7 @@ def run_supervisor(cmd: list[str], root: Path) -> int:
|
|||||||
while True:
|
while True:
|
||||||
logger.info("启动 bot 子进程")
|
logger.info("启动 bot 子进程")
|
||||||
proc = _spawn(cmd, root)
|
proc = _spawn(cmd, root)
|
||||||
|
grace = 0.0
|
||||||
try:
|
try:
|
||||||
before = snapshot(root)
|
before = snapshot(root)
|
||||||
while proc.poll() is None:
|
while proc.poll() is None:
|
||||||
@@ -117,6 +132,11 @@ def run_supervisor(cmd: list[str], root: Path) -> int:
|
|||||||
else:
|
else:
|
||||||
# 子进程自行退出(含 Ctrl-C 透传):不重启,原样返回退出码
|
# 子进程自行退出(含 Ctrl-C 透传):不重启,原样返回退出码
|
||||||
return proc.returncode or 0
|
return proc.returncode or 0
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# Ctrl+C 同时送达子进程与此监督循环:吞掉异常不打印 traceback,
|
||||||
|
# 退出码归零,子进程的收尾交给下方带宽限的回收
|
||||||
|
grace = _GRACE_SECONDS
|
||||||
|
return 0
|
||||||
finally:
|
finally:
|
||||||
# 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿
|
# 正常退出时是空操作;重启与 Ctrl-C 中断时回收子进程,避免孤儿
|
||||||
_terminate(proc)
|
_terminate(proc, grace)
|
||||||
+1
-4
@@ -3,7 +3,7 @@ name = "musicbot"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "QQ 群点歌 bot:MusicServer 搜索 + 选歌菜单 + 歌曲卡片回调按钮"
|
description = "QQ 群点歌 bot:MusicServer 搜索 + 选歌菜单 + 歌曲卡片回调按钮"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = ["qqbotsdk", "loguru"]
|
dependencies = ["qqbotsdk-py", "loguru"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
musicbot = "musicbot.main:main"
|
musicbot = "musicbot.main:main"
|
||||||
@@ -18,9 +18,6 @@ module-root = "" # flat 布局:musicbot/ 在仓库根而非 src/ 下
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pyright", "pytest", "pytest-asyncio", "ruff"]
|
dev = ["pyright", "pytest", "pytest-asyncio", "ruff"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
qqbotsdk = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" }
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
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"
|
||||||
+1045
-35
File diff suppressed because it is too large.
Load diff
@@ -473,7 +473,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
{ name = "qqbotsdk" },
|
{ name = "qqbotsdk-py" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -487,7 +487,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
{ name = "qqbotsdk", git = "https://gitea.4i.hk/hhhge/qqbotsdk.git" },
|
{ name = "qqbotsdk-py" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -688,9 +688,9 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qqbotsdk"
|
name = "qqbotsdk-py"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { git = "https://gitea.4i.hk/hhhge/qqbotsdk.git#b37a53f6e0dec360a3e684fbafd7140ec00a2711" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
@@ -698,6 +698,10 @@ dependencies = [
|
|||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "ujson" },
|
{ 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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
|
|||||||
Reference in new issue
Block a user