Compare commits
4
Commits
0145b7b01a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb60137ac7 | ||
|
|
c364506925 | ||
|
|
324fe86962 | ||
|
|
24c235c93d |
No files matched your search
+13
-1
@@ -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
|
||||
|
||||
+31
-21
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ruff + pyright + pytest
|
||||
name: ruff + pytest
|
||||
runs-on: ubuntu-latest
|
||||
# 固定官方 python 镜像:保证 python3.13 + pip 在场(runner 默认镜像
|
||||
# 工具链不明,SDK 仓库实测任务曾 7s 即死);runner 不支持 container
|
||||
@@ -16,32 +16,48 @@ jobs:
|
||||
container: python:3.13-slim
|
||||
steps:
|
||||
# 不用 actions/checkout、不用 git:runner 对 github 连通性不稳
|
||||
# (SDK 仓库 probe 实测),改走 Gitea archive 接口拉源码 tarball;
|
||||
# 每步结果写进 commit status(日志匿名不可读,status 远程可读,
|
||||
# GITEA_TOKEN 未配置时静默降级,不影响流程)
|
||||
# (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
|
||||
SERVER="${GITHUB_SERVER_URL:-https://gitea.4i.hk}"
|
||||
export 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
|
||||
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
|
||||
|
||||
curl -sL "$SERVER/api/v1/repos/$GITHUB_REPOSITORY/archive/$GITHUB_SHA.tar.gz" -o src.tgz \
|
||||
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 >/dev/null 2>&1 \
|
||||
pip install uv \
|
||||
|| { note pip "pip 安装 uv 失败" failure; exit 1; }
|
||||
note pip ok success
|
||||
|
||||
@@ -49,7 +65,7 @@ jobs:
|
||||
# runner 到 github 时通时不通,sync 失败自动重试
|
||||
sync_ok=""
|
||||
for _ in 1 2 3; do
|
||||
if uv sync >/dev/null 2>&1; then sync_ok=1; break; fi
|
||||
if uv sync; then sync_ok=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$sync_ok" ] || { note sync "uv sync 失败(已重试 3 次)" failure; exit 1; }
|
||||
@@ -59,12 +75,6 @@ jobs:
|
||||
|| { 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 \
|
||||
uv run pytest \
|
||||
|| { note pytest "pytest 未通过" failure; exit 1; }
|
||||
note pytest ok success
|
||||
@@ -2,21 +2,32 @@
|
||||
|
||||
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 无(这些源卡片就不带图)
|
||||
其余指令(都能从输入框 `/` 菜单直接触发):
|
||||
|
||||
- **`/<平台>下载 <歌名>`**(如 `/酷狗下载 稻香`):在指定平台搜歌、出该平台的选歌菜单(与 `/点歌` 同款交互:点编号弹卡片、再点播放/下载;翻页锁定该平台);平台与指令名按 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 权限自动回退文本
|
||||
- **下载发送方式**(`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://github.com/snowy-yang/qqbotsdk),
|
||||
| `MENU_PAGE_SIZE` | `10` | 选歌菜单每页候选数,1~20(内嵌键盘 5 行所限) |
|
||||
| `WITHDRAW` | `on` | 自动撤回旧菜单/卡片;`off` 则全部保留 |
|
||||
| `SEND_MODE` | `link` | 下载按钮:`link` 直接发直链 / `file` 先传群文件(需群主/管理员) |
|
||||
| `PUBLIC_URL` | 空 | 文件代理对外地址(如 `http://1.2.3.4:16689`);配置后文件代理开启 |
|
||||
| `FILE_PROXY` | `on` | 文件代理开关,仅在 `PUBLIC_URL` 已配置时生效 |
|
||||
| `PROXY_PORT` | 跟 `PUBLIC_URL` | 文件代理监听端口(无端口则 `16689`);https 反代时须显式配置 |
|
||||
| `LOG_LEVEL` | `INFO` | 日志级别 TRACE/DEBUG/INFO/SUCCESS/WARNING/ERROR/CRITICAL |
|
||||
| `LOG_DIR` | `logs` | 日志目录(自动按天轮转、保留 7 天) |
|
||||
| `RELOAD` | `off` | 热重载;`on` 等价于命令行 `--reload`(`start.bat` 已带) |
|
||||
@@ -38,11 +52,13 @@ QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/qqbotsdk),
|
||||
|
||||
## 斜杠指令(输入框 `/` 菜单)
|
||||
|
||||
群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时把 `点歌`/`歌词`/`帮助` 三条指令同步上去——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。
|
||||
群里输入框敲 `/` 唤出的「指令面板」由 bot **调接口自动注册**(开放平台 2026-08 新增的 `/v2/panels` 系列接口):每次建连(READY)时同步一次——没有就创建(`target_type=all` 对全部群生效)、内容有变就更新、一致则跳过,重连即自愈;失败只记日志不影响运行。`COMMAND_PANEL=off` 可关闭,改在开放平台后台「功能配置 → 指令」手工配置。
|
||||
|
||||
面板条目按 MusicServer `/sources` **现算**:`点歌`/`歌词` + `热搜`/`榜单`(有平台支持才注册)+ 各平台「`<平台>下载`」(search+url 能力齐备才注册,指令名取惯用/短名:酷狗、QQ音乐、网易云……)+ `帮助` 垫底。当前 9 平台下共 14 条(面板接口上限 20 条,条目生成时按 name ≤14 字符、desc ≤30 字符裁剪)。
|
||||
|
||||
指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。
|
||||
|
||||
「帮助」消息带 `[🎤 点歌][📝 歌词]` 指令按钮(action `type=2`):点击把 `/点歌 ` 填进输入框,补上歌名发送即可;键盘无权限时自动回退纯文本。
|
||||
「帮助」消息带 `[🎤 点歌][📝 歌词][🔥 热搜][🏆 榜单]` 指令按钮(action `type=2`):点击把对应斜杠指令填进输入框,补上歌名(如需要)发送即可;键盘无权限时自动回退纯文本。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# 内嵌键盘上限 5 行 × 每行 5 个;歌曲占行 + 1 行翻页 → 每页最多 20 首
|
||||
_MAX_MENU_PAGE_SIZE = 20
|
||||
|
||||
# PUBLIC_URL 不带端口时文件代理的监听端口(MusicServer 默认在 16688)
|
||||
_DEFAULT_PROXY_PORT = 16689
|
||||
|
||||
_LOG_LEVELS = ("TRACE", "DEBUG", "INFO", "SUCCESS", "WARNING", "ERROR", "CRITICAL")
|
||||
|
||||
|
||||
@@ -28,6 +32,20 @@ def _send_mode() -> str:
|
||||
return raw if raw in ("file", "link") else "link"
|
||||
|
||||
|
||||
def _proxy_port(public_url: str) -> int:
|
||||
"""PROXY_PORT:文件代理的监听端口。
|
||||
|
||||
缺省跟随 PUBLIC_URL 里的显式端口(如 http://1.2.3.4:16689 → 16689),
|
||||
没写端口用默认(https 域名走反向代理时必须显式配置,bot 本身不收 TLS)。"""
|
||||
raw = os.getenv("PROXY_PORT", "").strip()
|
||||
if raw.isdigit():
|
||||
return int(raw)
|
||||
try:
|
||||
return urlsplit(public_url).port or _DEFAULT_PROXY_PORT
|
||||
except ValueError: # URL 端口段写坏了
|
||||
return _DEFAULT_PROXY_PORT
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BotConfig:
|
||||
music_server_url: str
|
||||
@@ -36,6 +54,9 @@ class BotConfig:
|
||||
menu_page_size: int = 10 # 选歌菜单每页候选数(1~20)
|
||||
withdraw: bool = True # 自动撤回旧菜单/卡片是否开启
|
||||
send_mode: str = "link" # 下载发送方式:"file" 先传文件 / "link" 直接发直链
|
||||
file_proxy: bool = False # 文件代理开关(FILE_PROXY=on 且配了 PUBLIC_URL 才开)
|
||||
public_url: str = "" # 文件代理对外基址(PUBLIC_URL,如 http://1.2.3.4:16689)
|
||||
proxy_port: int = _DEFAULT_PROXY_PORT # 文件代理监听端口(缺省跟 PUBLIC_URL)
|
||||
command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单)
|
||||
log_level: str = "INFO" # 控制台与文件日志级别
|
||||
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
|
||||
@@ -44,6 +65,7 @@ class BotConfig:
|
||||
@classmethod
|
||||
def load(cls) -> "BotConfig":
|
||||
page_size = int(os.getenv("MENU_PAGE_SIZE", "10"))
|
||||
public_url = os.getenv("PUBLIC_URL", "").strip().rstrip("/")
|
||||
return cls(
|
||||
music_server_url=os.getenv("MUSIC_SERVER_URL", "http://127.0.0.1:16688"),
|
||||
music_quality=os.getenv("MUSIC_QUALITY", "128k"),
|
||||
@@ -51,6 +73,9 @@ class BotConfig:
|
||||
menu_page_size=min(max(1, page_size), _MAX_MENU_PAGE_SIZE),
|
||||
withdraw=_flag("WITHDRAW", True),
|
||||
send_mode=_send_mode(),
|
||||
file_proxy=_flag("FILE_PROXY", True) and bool(public_url),
|
||||
public_url=public_url,
|
||||
proxy_port=_proxy_port(public_url),
|
||||
command_panel=_flag("COMMAND_PANEL", True),
|
||||
log_level=_log_level(),
|
||||
log_dir=Path(os.getenv("LOG_DIR", "logs")),
|
||||
|
||||
+4
-4
@@ -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:
|
||||
|
||||
+1016
-164
File diff suppressed because it is too large.
Load diff
+20
-17
@@ -3,7 +3,11 @@
|
||||
调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ:
|
||||
启动与每次重连(READY)各同步一次——没有就创建(target_type=all
|
||||
对全部群生效),内容有变就更新,一致则跳过,幂等可重入。
|
||||
失败只记日志不影响运行(如功能未对机器人开放)。
|
||||
|
||||
条目由调用方给定(music.panel_items 按 MusicServer /sources 现算:
|
||||
基础指令 + 热搜/榜单 + 各平台「<平台>下载」),服务器增删源跟着变。
|
||||
接口上限(每面板 20 条;name/desc 按宽度限 14/30——汉字计 2)由条目
|
||||
生成方裁剪。失败只记日志不影响运行(如功能未对机器人开放)。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -20,14 +24,6 @@ _TARGET_TYPE = "all"
|
||||
# remark 兼作身份标识:重连同步时按它找到自己注册过的面板
|
||||
_REMARK = "音乐bot"
|
||||
|
||||
_PANEL_ITEMS = [
|
||||
{"name": "点歌", "type": "command", "desc": "出选歌菜单,点编号选歌"},
|
||||
{"name": "歌词", "type": "command", "desc": "查看歌词"},
|
||||
{"name": "帮助", "type": "command", "desc": "查看帮助"},
|
||||
]
|
||||
|
||||
_PANEL_BODY = {"items": _PANEL_ITEMS, "remark": _REMARK}
|
||||
|
||||
|
||||
def _signature(items: list[dict]) -> list[tuple]:
|
||||
"""面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。"""
|
||||
@@ -61,25 +57,32 @@ def _mine(records: list[Any]) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
async def sync_command_panel(api: BotApi) -> None:
|
||||
"""把点歌指令面板同步到 QQ(幂等)。任何失败只记日志,绝不抛出。"""
|
||||
async def sync_command_panel(api: BotApi, items: list[dict]) -> None:
|
||||
"""把给定条目同步成 QQ 指令面板(幂等)。任何失败只记日志,绝不抛出。"""
|
||||
try:
|
||||
resp = await api.get("/v2/panels", params={"scope": _SCOPE, "limit": "20"})
|
||||
records = _unwrap(resp, "records").get("records") or []
|
||||
mine = _mine(records)
|
||||
unchanged = mine and _signature(mine["panel"].get("items") or []) == _signature(
|
||||
_PANEL_ITEMS
|
||||
items
|
||||
)
|
||||
if unchanged:
|
||||
logger.info("指令面板已是最新(点歌/歌词/帮助)")
|
||||
logger.info(f"指令面板已是最新({len(items)} 条指令)")
|
||||
return
|
||||
if mine:
|
||||
await api.put(f"/v2/panels/{mine['panel_id']}", json={"panel": _PANEL_BODY})
|
||||
logger.info("指令面板已更新(点歌/歌词/帮助)")
|
||||
await api.put(
|
||||
f"/v2/panels/{mine['panel_id']}",
|
||||
json={"panel": {"items": items, "remark": _REMARK}},
|
||||
)
|
||||
logger.info(f"指令面板已更新({len(items)} 条指令)")
|
||||
else:
|
||||
body = {"scope": _SCOPE, "panel": _PANEL_BODY, "target_type": _TARGET_TYPE}
|
||||
body = {
|
||||
"scope": _SCOPE,
|
||||
"panel": {"items": items, "remark": _REMARK},
|
||||
"target_type": _TARGET_TYPE,
|
||||
}
|
||||
await api.post("/v2/panels", json=body)
|
||||
logger.info("指令面板已创建(点歌/歌词/帮助,对全部群生效)")
|
||||
logger.info(f"指令面板已创建({len(items)} 条指令,对全部群生效)")
|
||||
except _API_FAILS as e:
|
||||
# 失败不影响运行;指令可改为在开放平台后台手工配置
|
||||
logger.warning(f"指令面板同步失败: {e!r}")
|
||||
@@ -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="上游取流失败")
|
||||
+1
-4
@@ -3,7 +3,7 @@ name = "musicbot"
|
||||
version = "0.1.0"
|
||||
description = "QQ 群点歌 bot:MusicServer 搜索 + 选歌菜单 + 歌曲卡片回调按钮"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["qqbotsdk", "loguru"]
|
||||
dependencies = ["qqbotsdk-py", "loguru"]
|
||||
|
||||
[project.scripts]
|
||||
musicbot = "musicbot.main:main"
|
||||
@@ -18,9 +18,6 @@ module-root = "" # flat 布局:musicbot/ 在仓库根而非 src/ 下
|
||||
[dependency-groups]
|
||||
dev = ["pyright", "pytest", "pytest-asyncio", "ruff"]
|
||||
|
||||
[tool.uv.sources]
|
||||
qqbotsdk = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
|
||||
+938
-50
File diff suppressed because it is too large.
Load diff
@@ -473,7 +473,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "loguru" },
|
||||
{ name = "qqbotsdk" },
|
||||
{ name = "qqbotsdk-py" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -487,7 +487,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "loguru" },
|
||||
{ name = "qqbotsdk", url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" },
|
||||
{ name = "qqbotsdk-py" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -688,9 +688,9 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qqbotsdk"
|
||||
version = "0.1.0"
|
||||
source = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }
|
||||
name = "qqbotsdk-py"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "cryptography" },
|
||||
@@ -698,15 +698,9 @@ dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "ujson" },
|
||||
]
|
||||
sdist = { hash = "sha256: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" },
|
||||
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]]
|
||||
|
||||
Reference in new issue
Block a user