Compare commits

..
2 Commits
Author SHA1 Message Date
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
10 changed files with 2163 additions and 255 deletions

No files matched your search

+13 -1
View File
@@ -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
+22 -6
View File
@@ -2,21 +2,32 @@
QQ 群点歌 bot,基于 [qqbotsdk](https://github.com/snowy-yang/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 图片语法 `![封面 #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 权限自动回退文本 - 回调按钮(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://github.com/snowy-yang/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` 已带) |
@@ -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 /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。 指令消息以 `/点歌 稻香` 形式推给 bot(走 `GROUP_AT_MESSAGE_CREATE`,指令自动 @ 机器人),与文本命令共用同一套解析——`_normalize` 会剥掉开头的 `/` 与 `@机器人` 前缀,`点歌 稻香`、`/点歌 稻香`、`@bot /点歌 稻香` 三种写法等价。只敲命令不带参数(斜杠指令参数可为空)时回复用法提示。
「帮助」消息带 `[🎤 点歌][📝 歌词]` 指令按钮(action `type=2`):点击把 `/点歌 ` 填进输入框,补上歌名发送即可;键盘无权限时自动回退纯文本。 「帮助」消息带 `[🎤 点歌][📝 歌词][🔥 热搜][🏆 榜单]` 指令按钮(action `type=2`):点击把对应斜杠指令填进输入框,补上歌名(如需要)发送即可;键盘无权限时自动回退纯文本。
## 运行 ## 运行
+25
View File
@@ -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,9 @@ 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 # 启动/重连时自动注册指令面板(输入框 / 菜单) command_panel: bool = True # 启动/重连时自动注册指令面板(输入框 / 菜单)
log_level: str = "INFO" # 控制台与文件日志级别 log_level: str = "INFO" # 控制台与文件日志级别
log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台 log_dir: Path = Path("logs") # 日志目录;写不进去时自动退化为仅控制台
@@ -44,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"),
@@ -51,6 +73,9 @@ 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), 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")),
+4 -4
View File
@@ -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:
+1008 -159
View File
File diff suppressed because it is too large. Load diff
+20 -17
View File
@@ -3,7 +3,11 @@
调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ: 调开放平台 2026-08 新增的 /v2/panels 接口,把点歌指令同步到 QQ:
启动与每次重连(READY)各同步一次——没有就创建(target_type=all 启动与每次重连(READY)各同步一次——没有就创建(target_type=all
对全部群生效),内容有变就更新,一致则跳过,幂等可重入。 对全部群生效),内容有变就更新,一致则跳过,幂等可重入。
失败只记日志不影响运行(如功能未对机器人开放)。
条目由调用方给定(music.panel_items 按 MusicServer /sources 现算:
基础指令 + 热搜/榜单 + 各平台「<平台>下载」),服务器增删源跟着变。
接口上限(每面板 20 条;name/desc 按宽度限 14/30——汉字计 2)由条目
生成方裁剪。失败只记日志不影响运行(如功能未对机器人开放)。
""" """
from typing import Any from typing import Any
@@ -20,14 +24,6 @@ _TARGET_TYPE = "all"
# remark 兼作身份标识:重连同步时按它找到自己注册过的面板 # remark 兼作身份标识:重连同步时按它找到自己注册过的面板
_REMARK = "音乐bot" _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]: def _signature(items: list[dict]) -> list[tuple]:
"""面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。""" """面板条目的比较指纹:只取本模块管理的字段,忽略其余与键序。"""
@@ -61,25 +57,32 @@ def _mine(records: list[Any]) -> dict | None:
return None return None
async def sync_command_panel(api: BotApi) -> None: async def sync_command_panel(api: BotApi, items: list[dict]) -> None:
"""把点歌指令面板同步到 QQ(幂等)。任何失败只记日志,绝不抛出。""" """把给定条目同步成 QQ 指令面板(幂等)。任何失败只记日志,绝不抛出。"""
try: try:
resp = await api.get("/v2/panels", params={"scope": _SCOPE, "limit": "20"}) resp = await api.get("/v2/panels", params={"scope": _SCOPE, "limit": "20"})
records = _unwrap(resp, "records").get("records") or [] records = _unwrap(resp, "records").get("records") or []
mine = _mine(records) mine = _mine(records)
unchanged = mine and _signature(mine["panel"].get("items") or []) == _signature( unchanged = mine and _signature(mine["panel"].get("items") or []) == _signature(
_PANEL_ITEMS items
) )
if unchanged: if unchanged:
logger.info("指令面板已是最新(点歌/歌词/帮助)") logger.info(f"指令面板已是最新({len(items)} 条指令)")
return return
if mine: if mine:
await api.put(f"/v2/panels/{mine['panel_id']}", json={"panel": _PANEL_BODY}) await api.put(
logger.info("指令面板已更新(点歌/歌词/帮助)") f"/v2/panels/{mine['panel_id']}",
json={"panel": {"items": items, "remark": _REMARK}},
)
logger.info(f"指令面板已更新({len(items)} 条指令)")
else: 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) await api.post("/v2/panels", json=body)
logger.info("指令面板已创建(点歌/歌词/帮助,对全部群生效)") logger.info(f"指令面板已创建({len(items)} 条指令,对全部群生效)")
except _API_FAILS as e: except _API_FAILS as e:
# 失败不影响运行;指令可改为在开放平台后台手工配置 # 失败不影响运行;指令可改为在开放平台后台手工配置
logger.warning(f"指令面板同步失败: {e!r}") 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="上游取流失败")
+1 -4
View File
@@ -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 = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+919 -50
View File
File diff suppressed because it is too large. Load diff
Generated
+8 -14
View File
@@ -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", url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" }, { 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 = { url = "https://github.com/snowy-yang/qqbotsdk/archive/refs/heads/main.tar.gz" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "cryptography" }, { name = "cryptography" },
@@ -698,15 +698,9 @@ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "ujson" }, { name = "ujson" },
] ]
sdist = { hash = "sha256:59960044772157383d1fba3506531e34f89102d17fcad9a14a99873566b6c526" } 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 = [
[package.metadata] { 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" },
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]] [[package]]