feat(bot): !play id <id> 与其他命令语法保持一致

按 id 精确播放原本要写 `!play id:<id>`,冒号在一堆 `!<命令> <子命令> <参数>`
的命令里显得很突兀。现在空格写法 `!play id <id>` 也可以,`!add` / `!playnext`
共用同一个解析器,一起生效。

`id:<id>` 继续支持,不做废弃:用户的聊天记录、旧文档和 !search 输出里都是
这个写法。

冒号是个明确的标记,所以 `id:<任意内容>` 一律当 id。空格不是——「ID 4」和
「ID Bruno」都是真实存在的歌名,而且 `id <链接>` 原本会落到 URL 分支正常解析。
所以空格写法只认「长得像 id」的 token(纯数字 / BV 号 / 11 位以上的 id 字符),
其余照旧继续走 URL 识别,最后落到普通搜索,不会把搜索词误当成 id。

同步更新 !search 输出的提示、三条 Usage、!help 和 README。

Closes #139

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 5 committed 2026-08-09 15:08:19 +08:00
1 parent 4e4354282d
commit 74ea8d26d4
4 files changed
+100 -20

No files matched your search

+33 -6
View File
@@ -18,9 +18,22 @@ export interface SongRef {
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Could this token plausibly BE an id on a supported platform?
* - NetEase / Kugou numeric ids → all digits
* - BiliBili → BV + 8-12 alphanumerics
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
* → 11+ chars from the id alphabet
* Deliberately conservative: anything rejected here just stays an ordinary
* search term, which is what it almost certainly was.
*/
function looksLikeSongId(token: string): boolean {
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - `id <id>` / `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
@@ -30,11 +43,25 @@ export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// Explicit id — platform decided by the command's flags/default. The
// separator is a colon or plain whitespace, so `id <id>` matches the
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
// from a chat paste ("id:12345." / "id:12345)") — no supported id
// (numeric / BVID / mid) ends in those.
//
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
// has to keep resolving as a URL. So the space form only claims tokens that
// could actually be an id; anything else falls through to the URL branches
// below and ultimately to a plain search.
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
if (idPrefix) {
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
return { id, platform: null };
}
}
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).