From 03ffd09d36c7b332f1b5dae5144116dab77b2710 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Sun, 9 Aug 2026 15:09:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(setup):=20=E6=8C=89=20Node=20ABI=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=B9=B6=E8=87=AA=E5=8A=A8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 换过 Node 大版本之后安装就废了,而且安装脚本还会报告成功。原生模块只能在 编译它的那个 Node ABI 上加载(Node 20 = 115、22 = 127、24 = 137),而 better-sqlite3 的 .node 放在与 ABI 无关的固定路径下,旧的 download-binaries 只检查「文件存在且大于 500KB」,于是给 Node 24 编译的 1.9MB 文件在 Node 22 下原样保留,跳过重新下载,机器人启动时死在 NODE_MODULE_VERSION 上。 (@discordjs/opus 的目录名里带 ABI,反而歪打正着没这个问题。) download-binaries.mjs 现在不看文件大小,而是在子进程里真的把每个包 load 一遍 ——子进程是必须的,Windows 上父进程加载过的 .node 会一直被映射,系统随后拒绝 删除或覆盖它。注意 better-sqlite3 的 addon 是在 Database 构造函数里惰性加载的, 所以光 require 这个包探测不出问题,得真的开一个内存库。 失败就按当前 ABI 重新安装,整个替换过程是先把旧文件挪到 node_modules/ .tsmusicbot-backup、下载解压到暂存目录、原子 rename 就位、再探测一次,任何 一步失败都把原文件还原回去——删掉不匹配的二进制却下载不下来,比原来的版本 更糟。备份特意放在包的 build/ 之外,因为源码编译回退会调 node-gyp 把 build/ 清空。被中断(比如下载到一半 Ctrl+C)遗留的备份,下一次运行会自动认领回来。 其他一并修掉的问题: - 版本号原本硬编码 12.8.0,实际锁的是 12.11.1,一旦真的触发下载就会 404; 改为从 node_modules 里读。 - 三个模块原本用 Promise.all 并发。源码编译走的是 execSync,会把事件循环整个 卡住几分钟,而 download() 的 120 秒超时是挂在同一个循环上的 socket 静默计时 器——循环一恢复,还在传输中的连接就会被判超时。这不是小概率竞态:npmmirror 上没有 ABI 137 的 opus,也没有 ABI 115 的 better-sqlite3,所以在 Node 24 和 Node 20 上必定有一个模块在 100ms 内 404 并开始编译,而 ffmpeg 的 80MB 下载 正在进行。ffmpeg 是可选模块,于是它被误杀后只记一条 WARN,脚本照样 exit 0, setup 打印「Setup Complete」,用户装完却没有 ffmpeg,放什么都放不出来。 改成严格串行执行。 - 必需模块(opus / better-sqlite3)失败才返回非零;ffmpeg 有系统 ffmpeg 兜底, 只警告。setup.bat 里原本形同虚设的 FAILED 标志接上了,必需模块失败会中止安装, 不再是「装完才发现」。 - 4b 步骤原本把全部输出重定向进 setup.log,用户盯着不动的窗口以为卡死;现在 进度走 stderr 实时显示,完整记录仍进日志。 新增 scripts/check-native.mjs:启动前预检,直接说清楚哪个模块对不上、分别是哪 个 ABI、怎么修,而不是抛一串 NODE_MODULE_VERSION 堆栈。scripts\start.bat、根目 录 start.bat(现在改为委托给前者,并且会先切到项目目录)和 npm start 的 prestart 都会跑它。Docker 运行镜像也补上这个文件,否则容器里执行 npm start 会因为找不到 脚本而失败。 Node 版本要求改为按依赖的真实下限判断(@honeybbq/teamspeak-client 要 >=20.19、 @sansenjian/qq-music-api 要 >=20.17/22.9,21 和 23 被 better-sqlite3 与 vitest 排除),package.json 补上对应的 engines;比 20/22 LTS 更新的大版本不阻止,只提 示可能要源码编译。README 相应更新,并补一条 NODE_MODULE_VERSION 的常见问题。 注意:批处理里新增的行全部保持纯 ASCII —— cmd.exe 在括号块里遇到多字节 UTF-8 会算错文件偏移,开始吃掉后续行的 echo 前缀,中文提示一律交给 Node 脚本输出。 Closes #140 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 26 +- package.json | 5 + scripts/check-native.mjs | 150 +++++++ scripts/docker/Dockerfile | 5 + scripts/download-binaries.mjs | 751 +++++++++++++++++++++++++++++----- scripts/setup.bat | 72 +++- scripts/setup.sh | 47 ++- scripts/start.bat | 18 +- start.bat | 29 +- 9 files changed, 968 insertions(+), 135 deletions(-) create mode 100644 scripts/check-native.mjs diff --git a/README.md b/README.md index 65c4b0a..f4d9a3b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + @@ -62,20 +62,25 @@ ### 方式一:Windows 一键部署(最简单) -只需电脑有网络连接,其他一切自动安装。 +先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。 ``` -1. 下载或 clone 本项目 -2. 双击 scripts\setup.bat (首次安装,自动安装 Node.js 和所有依赖) -3. 双击 scripts\start.bat (启动机器人) -4. 浏览器打开 http://localhost:3000 +1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/) +2. 下载或 clone 本项目 +3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身) +4. 双击 scripts\start.bat (启动机器人) +5. 浏览器打开 http://localhost:3000 ``` -> `setup.bat` 会自动通过 winget 安装 Node.js(如果未安装),运行 `npm install` 安装所有依赖(包括内置 FFmpeg),最后构建项目。之后每次只需双击 `start.bat` 启动。 +> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。 +> +> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。 +> +> 更新的 Node 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。 ### 方式二:手动安装(所有系统) -**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。 +**前置条件:** [Node.js 20 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。 FFmpeg **已自动内置**,无需手动安装。 ```bash @@ -505,7 +510,7 @@ teamspeak-music-bot/ | 层级 | 技术 | |------|------| -| **运行时** | Node.js 20+, TypeScript 5 | +| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 | | **后端框架** | Express 4, WebSocket (ws) | | **数据库** | better-sqlite3 (SQLite) | | **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus | @@ -797,6 +802,9 @@ A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测 **Q:机器人连接了但 TeamSpeak 中听不到音乐?** A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。 +**Q:启动报 `NODE_MODULE_VERSION 137 ... requires 127`,或提示找不到 `opus.node`?** +A:换过 Node 大版本了。原生模块(`@discordjs/opus`、`better-sqlite3`)编译时绑定了一个 Node ABI(Node 20 = 115、22 = 127、24 = 137),换版本后旧的 `.node` 就再也加载不了。**重新运行一次 `scripts\setup.bat`(Linux/macOS 是 `bash scripts/setup.sh`)即可**——安装脚本会实际加载一遍每个原生模块,发现和当前 Node 不匹配就自动重新下载/编译,替换过程中失败也会把原来的文件还原回去。`start.bat` 和 `npm start` 在启动前也会先做这个检查,直接告诉你哪个模块对不上、分别是哪个 ABI,而不是抛一串看不懂的堆栈。想彻底重来就删掉 `node_modules` 和 `web\node_modules` 再跑一次 `setup.bat`。 + **Q:提示"无法获取播放链接"?** A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。 diff --git a/package.json b/package.json index 231a5f2..1cdd85a 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,15 @@ "version": "0.1.0", "description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support", "type": "module", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc && npm run build:web", "build:web": "cd web && npm run build", + "check:native": "node scripts/check-native.mjs", + "prestart": "node scripts/check-native.mjs", "start": "node dist/index.js", "play": "node dist/index.js", "test": "vitest run", diff --git a/scripts/check-native.mjs b/scripts/check-native.mjs new file mode 100644 index 0000000..4393628 --- /dev/null +++ b/scripts/check-native.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +/** + * Preflight: can THIS Node build actually load the native modules that are + * sitting in node_modules? + * + * A compiled addon is tied to one Node ABI (process.versions.modules: + * Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major, + * launch under another, and the bot dies deep inside startup with a + * `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it. + * This script turns that into one actionable sentence, before anything starts. + * + * Exit code: + * 0 every required native module loads (or is simply not installed yet — + * that is npm install's problem, not an ABI problem) + * 1 a required native module definitively fails to load; the bot could not + * have started anyway, so there is no false-positive risk here. + * + * Usage: node scripts/check-native.mjs + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const NODE_MODULES = join(ROOT, "node_modules"); +const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi"); +const NODE_ABI = process.versions.modules; + +/** Only the modules the bot cannot start without. ffmpeg-static is optional + * (a system ffmpeg on PATH works too), so it is not checked here. */ +const REQUIRED = ["@discordjs/opus", "better-sqlite3"]; + +function pkgDirOf(spec) { + return join(NODE_MODULES, ...spec.split("/")); +} + +function summarizeError(text) { + const lines = String(text || "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l)); + return (interesting || lines[0] || "unknown error").slice(0, 300); +} + +/** + * The snippet that actually forces each package's addon to be dlopen()ed. + * NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor, + * so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI + * binary. Opening an in-memory database is the cheapest way to really load it. + */ +const PROBE_EXPR = { + "@discordjs/opus": "require('@discordjs/opus')", + "better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()", +}; + +/** + * Load-probe in a throwaway child process. Child process on purpose: requiring + * an addon in this process would keep the DLL mapped, and Windows then refuses + * to let setup.bat replace the file we just told the user to replace. + */ +function probeRequire(spec) { + const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`; + try { + execFileSync(process.execPath, ["-e", expr], { + cwd: ROOT, + stdio: "pipe", + timeout: 120000, + windowsHide: true, + }); + return { ok: true }; + } catch (err) { + const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n"); + // "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js + // requires NODE_MODULE_VERSION 127..." -> first number is the build target. + const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]); + return { + ok: false, + abiMismatch: abis.length >= 2, + compiledAbi: abis.length >= 2 ? abis[0] : null, + error: summarizeError(text), + }; + } +} + +function readStamp() { + try { + return JSON.parse(readFileSync(STAMP_FILE, "utf8")); + } catch { + return null; + } +} + +const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh"; + +const broken = []; +for (const spec of REQUIRED) { + if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job + const probe = probeRequire(spec); + if (!probe.ok) broken.push({ spec, ...probe }); +} + +function report() { + const stamp = readStamp(); + const mismatch = broken.find((b) => b.abiMismatch); + const out = (line) => process.stderr.write(`${line}\n`); + + out(""); + out("============================================================"); + if (mismatch) { + out(" [ERROR] 原生模块与当前 Node 版本不匹配"); + out(" Native modules do not match this Node version"); + } else { + out(" [ERROR] 原生模块无法加载 / native module failed to load"); + } + out("============================================================"); + out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`); + if (stamp && stamp.abi) { + out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`); + out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`); + } + out(""); + for (const b of broken) { + if (b.abiMismatch) { + out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`); + out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`); + out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`); + } else { + out(` x ${b.spec}: ${b.error}`); + } + } + out(""); + out(" 怎么修 / How to fix:"); + out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`); + out(" (它会自动为当前 Node 版本重新安装原生模块)"); + out(" (setup now repairs the native modules for whatever Node you run)"); + out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version"); + out(" you installed with, then start again."); + out("============================================================"); + out(""); +} + +if (broken.length > 0) { + report(); + // exitCode rather than exit(): lets the message flush when stderr is piped. + process.exitCode = 1; +} diff --git a/scripts/docker/Dockerfile b/scripts/docker/Dockerfile index eee9a67..735bb33 100644 --- a/scripts/docker/Dockerfile +++ b/scripts/docker/Dockerfile @@ -46,6 +46,11 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/web/dist ./web/dist COPY --from=builder /app/package*.json ./ COPY --from=builder /app/node_modules ./node_modules +# package.json declares a `prestart` preflight, so `npm start` inside the +# container needs this file. The image's own CMD calls node directly and never +# goes through npm, but an interactive `docker exec ... npm start` would +# otherwise die on a missing script rather than starting the bot. +COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs # Data directory for database, cookies, logs RUN mkdir -p /app/data diff --git a/scripts/download-binaries.mjs b/scripts/download-binaries.mjs index 18b2623..2403f14 100755 --- a/scripts/download-binaries.mjs +++ b/scripts/download-binaries.mjs @@ -1,161 +1,718 @@ #!/usr/bin/env node /** - * Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN. - * Called by setup.bat after npm install --ignore-scripts. + * Verify / download / repair the native binaries used by TSMusicBot + * (ffmpeg-static + @discordjs/opus + better-sqlite3), preferring the + * npmmirror CDN so China users never have to reach GitHub. + * + * Called by setup.bat / setup.sh after `npm install --ignore-scripts`. + * + * WHY THIS IS NOT JUST A DOWNLOADER + * --------------------------------- + * A compiled addon only loads into the exact Node ABI it was built for + * (process.versions.modules: Node 20 = 115, Node 22 = 127, Node 24 = 137). + * better-sqlite3 stores its addon at an ABI-agnostic path + * (build/Release/better_sqlite3.node), so a "file exists and is big enough" + * check happily keeps a binary built for a *different* Node major around and + * the bot then dies with `NODE_MODULE_VERSION 137 ... requires 127`. + * So we validate by actually LOADING each package — in a short-lived child + * process, because on Windows a loaded .node stays mapped and the OS then + * refuses to delete or overwrite it. + * + * Every repair is staged and swapped in atomically: if a download fails we put + * the previous file back, so a failed run can never leave the install in a + * worse state than it started. * * Usage: node scripts/download-binaries.mjs [cdn_base_url] + * Env: TSMB_BINARY_LOG_STDOUT=1 also echo progress to stdout + * (setup.bat uses this to show progress live on stderr while stdout + * is redirected into setup.log) */ -import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs"; +import { + chmodSync, + createWriteStream, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join, dirname } from "node:path"; +import { basename, dirname, join } from "node:path"; import { createGunzip } from "node:zlib"; import { pipeline } from "node:stream/promises"; -import { createWriteStream } from "node:fs"; import { get } from "node:https"; import { Readable } from "node:stream"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const NODE_MODULES = join(ROOT, "node_modules"); +const BACKUP_DIR = join(NODE_MODULES, ".tsmusicbot-backup"); +const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi"); + const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries"; const PLATFORM = process.platform; const ARCH = process.arch; const NODE_ABI = process.versions.modules; -function download(url) { +/** Modules the bot cannot start without. ffmpeg-static is optional: a system + * ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */ +const REQUIRED = new Set(["@discordjs/opus", "better-sqlite3"]); + +/** ffmpeg-static ships ~40-90 MB depending on platform; anything under this is + * certainly a truncated download, not a real build. */ +const FFMPEG_MIN_BYTES = 20 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// logging +// --------------------------------------------------------------------------- + +// Progress goes to stderr so setup.bat can show it live while stdout is being +// appended to setup.log. TSMB_BINARY_LOG_STDOUT=1 mirrors it into stdout so the +// log keeps the full transcript too. +const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1"; + +function log(msg) { + const line = msg === "" ? "" : ` [binary] ${msg}`; + process.stderr.write(`${line}\n`); + if (ECHO_STDOUT) process.stdout.write(`${line}\n`); +} + +// --------------------------------------------------------------------------- +// small helpers +// --------------------------------------------------------------------------- + +function sizeOf(filePath) { + try { + return statSync(filePath).size; + } catch { + return 0; + } +} + +function humanSize(filePath) { + const bytes = sizeOf(filePath); + if (!bytes) return "unknown size"; + return bytes >= 1024 * 1024 + ? `${(bytes / 1024 / 1024).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(0)} KB`; +} + +function ensureExecutable(filePath) { + if (PLATFORM === "win32") return; + try { + chmodSync(filePath, 0o755); + } catch { + /* best effort */ + } +} + +/** Read the version actually present in node_modules (never hardcode it: the + * lockfile can be far ahead of whatever version this script was written for, + * and a wrong version means a 404 on the CDN). */ +function readInstalledVersion(spec) { + try { + const pkgJson = join(NODE_MODULES, ...spec.split("/"), "package.json"); + const version = JSON.parse(readFileSync(pkgJson, "utf8")).version; + return typeof version === "string" && version ? version : null; + } catch { + return null; + } +} + +function summarizeError(text) { + const lines = String(text || "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l)); + return (interesting || lines[0] || "unknown error").slice(0, 300); +} + +// --------------------------------------------------------------------------- +// download +// --------------------------------------------------------------------------- + +function download(url, redirects = 0) { return new Promise((resolve, reject) => { const req = get(url, { timeout: 120000 }, (res) => { - if (res.statusCode < 200 || res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode}: ${url}`)); + const { statusCode, headers } = res; + if (statusCode >= 300 && statusCode < 400 && headers.location) { + res.resume(); + if (redirects >= 5) { + reject(new Error(`too many redirects: ${url}`)); + return; + } + resolve(download(new URL(headers.location, url).toString(), redirects + 1)); + return; + } + if (statusCode < 200 || statusCode >= 300) { + res.resume(); + reject(new Error(`HTTP ${statusCode}: ${url}`)); return; } const chunks = []; res.on("data", (c) => chunks.push(c)); + res.on("error", reject); res.on("end", () => resolve(Buffer.concat(chunks))); }); req.on("error", reject); - req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); }); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`timeout: ${url}`)); + }); }); } -function log(msg) { - console.log(` [binary] ${msg}`); +let tarModule = null; + +/** `tar` is not a declared dependency — it only resolves transitively through + * prebuild-install / @discordjs/node-pre-gyp. Fail with a sentence a user can + * act on instead of a raw MODULE_NOT_FOUND stack. */ +function loadTar() { + if (tarModule) return tarModule; + try { + tarModule = createRequire(import.meta.url)("tar"); + } catch { + throw new Error( + "'tar' module not available / 找不到 tar 模块 — run `npm install tar` in the project root and retry", + ); + } + return tarModule; } -function isValidSize(filePath, minBytes) { - try { return statSync(filePath).size >= minBytes; } catch { return false; } +async function extractTarGz(buf, cwd) { + const tar = loadTar(); + const tmpFile = join(tmpdir(), `tsmb-${process.pid}-${Date.now()}.tar.gz`); + writeFileSync(tmpFile, buf); + try { + await tar.extract({ cwd, file: tmpFile }); + } finally { + try { + rmSync(tmpFile, { force: true }); + } catch { + /* ignore */ + } + } } -async function downloadFfmpeg() { - const ffDir = join(ROOT, "node_modules", "ffmpeg-static"); +// --------------------------------------------------------------------------- +// load probe (the whole point of this rewrite) +// --------------------------------------------------------------------------- + +/** + * The snippet that actually forces each package's addon to be dlopen()ed. + * NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor + * (lib/database.js: `DEFAULT_ADDON || (DEFAULT_ADDON = require('bindings')(...))`), + * so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI binary. + * Opening an in-memory database is the cheapest way to really load it. + */ +const PROBE_EXPR = { + "@discordjs/opus": "require('@discordjs/opus')", + "better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()", +}; + +/** + * Try to load a package in a throwaway child process. + * Child process on purpose: loading an addon here would keep the DLL mapped and + * Windows would then refuse to rename/delete the file we are about to replace. + */ +function probeRequire(spec) { + const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`; + try { + execFileSync(process.execPath, ["-e", expr], { + cwd: ROOT, + stdio: "pipe", + timeout: 120000, + windowsHide: true, + }); + return { ok: true }; + } catch (err) { + const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n"); + // "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js + // requires NODE_MODULE_VERSION 127..." -> first number is what it was built for. + const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]); + return { + ok: false, + abiMismatch: abis.length >= 2, + compiledAbi: abis.length >= 2 ? abis[0] : null, + error: summarizeError(text), + }; + } +} + +function describeProbe(probe) { + if (probe.abiMismatch) { + return `built for Node ABI ${probe.compiledAbi}, but this Node needs ABI ${NODE_ABI}`; + } + return probe.error; +} + +function probeFfmpegBinary(bin) { + try { + const out = execFileSync(bin, ["-version"], { + stdio: "pipe", + timeout: 30000, + windowsHide: true, + }).toString(); + return { ok: true, version: (out.split(/\r?\n/)[0] || "").slice(0, 60) }; + } catch (err) { + const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n"); + return { ok: false, error: summarizeError(text) }; + } +} + +// --------------------------------------------------------------------------- +// atomic swap helpers +// --------------------------------------------------------------------------- + +let stashCounter = 0; + +/** + * Move `target` (file or directory) out of the way into node_modules/.tsmusicbot-backup. + * Same volume as node_modules, so the rename is atomic, and outside the package's + * build/ tree so that `npm rebuild` / `node-gyp clean` cannot wipe the backup. + * Returns { commit, restore } — call exactly one of them. + */ +/** Windows likes to hold a brief lock on a freshly written .node (antivirus, + * indexer), and rmSync does not retry by default. */ +const RM_OPTS = { recursive: true, force: true, maxRetries: 5, retryDelay: 150 }; + +function stash(target) { + if (!existsSync(target)) { + return { commit() {}, restore() {} }; + } + mkdirSync(BACKUP_DIR, { recursive: true }); + const backup = join(BACKUP_DIR, `${basename(target)}.${process.pid}.${stashCounter++}.bak`); + rmSync(backup, RM_OPTS); + renameSync(target, backup); + // The backup filename alone cannot say where the artifact came from, and a + // run that is killed (Ctrl+C during a slow download) never reaches commit or + // restore. Record the target so the next run can put it back — see + // recoverOrphanedBackups(). + const manifest = `${backup}.json`; + try { + writeFileSync(manifest, `${JSON.stringify({ target })}\n`); + } catch { + /* recovery is best-effort; the swap itself still works */ + } + + let settled = false; + const dropManifest = () => { + try { + rmSync(manifest, RM_OPTS); + } catch { + /* ignore */ + } + }; + return { + commit() { + if (settled) return; + settled = true; + try { + rmSync(backup, RM_OPTS); + } catch { + /* leftover backup is harmless */ + } + dropManifest(); + }, + restore() { + if (settled) return; + settled = true; + try { + rmSync(target, RM_OPTS); + mkdirSync(dirname(target), { recursive: true }); + renameSync(backup, target); + dropManifest(); + log(`restored the previous ${basename(target)} — nothing was made worse`); + } catch (err) { + // Leave the backup AND its manifest in place: recoverOrphanedBackups() + // on the next run is the second chance. + log(`WARN: could not restore ${target} from ${backup}: ${err.message}`); + log(`WARN: the previous file is still at ${backup} — the next run will try again`); + } + }, + }; +} + +/** + * Put back anything a previous run stashed but never restored — a run killed + * mid-download, or one whose restore() itself failed. Only acts when the target + * is currently absent, so it can never clobber a good binary. + */ +function recoverOrphanedBackups() { + if (!existsSync(BACKUP_DIR)) return; + let entries; + try { + entries = readdirSync(BACKUP_DIR); + } catch { + return; + } + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const manifest = join(BACKUP_DIR, entry); + const backup = manifest.slice(0, -".json".length); + try { + const { target } = JSON.parse(readFileSync(manifest, "utf8")); + if (!target || !existsSync(backup)) { + rmSync(manifest, RM_OPTS); + continue; + } + if (existsSync(target)) continue; // a good file is already there — leave it alone + mkdirSync(dirname(target), { recursive: true }); + renameSync(backup, target); + rmSync(manifest, RM_OPTS); + log(`recovered ${basename(target)} left behind by an interrupted run`); + } catch (err) { + log(`WARN: could not process leftover backup ${entry}: ${err.message}`); + } + } +} + +function cleanupBackupDir() { + try { + if (existsSync(BACKUP_DIR) && readdirSync(BACKUP_DIR).length === 0) { + rmSync(BACKUP_DIR, { recursive: true, force: true }); + } + } catch { + /* ignore */ + } +} + +// --------------------------------------------------------------------------- +// per-module results +// --------------------------------------------------------------------------- + +/** status: "ok" | "repaired" | "failed" | "missing" */ +function makeResult(name, status, detail) { + return { name, required: REQUIRED.has(name), status, detail }; +} + +function buildFromSource(command) { + // stdout -> inherited (setup.bat sends it to the log), stderr -> inherited so + // compiler progress stays visible; npm's own output is far too noisy to buffer. + execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] }); +} + +function buildToolsHint() { + log("Install build tools first:"); + log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)"); + log(" Ubuntu/Debian: sudo apt install build-essential python3"); + log(" CentOS/RHEL: sudo yum groupinstall 'Development Tools'"); +} + +// --------------------------------------------------------------------------- +// ffmpeg-static (OPTIONAL — a system ffmpeg is a documented fallback) +// --------------------------------------------------------------------------- + +async function ensureFfmpeg() { + const name = "ffmpeg-static"; + const ffDir = join(NODE_MODULES, name); const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg"; const ffDest = join(ffDir, ffName); - if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; } - if (existsSync(ffDest)) { - if (isValidSize(ffDest, 50 * 1024 * 1024)) { - log("ffmpeg already exists, skipping"); - return true; + if (!existsSync(ffDir)) { + log(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`); + return makeResult(name, "missing", "package not installed"); + } + + if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) { + ensureExecutable(ffDest); + const probe = probeFfmpegBinary(ffDest); + if (probe.ok) { + log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`); + return makeResult(name, "ok", humanSize(ffDest)); } - log("ffmpeg exists but seems corrupted (too small), re-downloading..."); + // Deliberately NOT re-downloading here: ffmpeg is a plain executable with no + // ABI to mismatch, and forcing an ~80 MB re-download because `-version` + // could not be spawned would hurt exactly the slow-network users this + // script exists for. + log(`${name}: present (${humanSize(ffDest)}) but could not be executed: ${probe.error}`); + return makeResult(name, "ok", "present, not verified"); + } + + if (existsSync(ffDest)) { + log(`${name}: existing ffmpeg looks truncated (${humanSize(ffDest)}), re-downloading...`); + } else { + log(`${name}: ffmpeg binary missing, downloading...`); } const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`; - log("Downloading ffmpeg..."); - const buf = await download(url); - await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest)); - try { execSync(`chmod +x "${ffDest}"`); } catch {} - const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1); - log(`ffmpeg OK (${size} MB)`); - return true; -} - -async function downloadOpus() { - const opusDir = join(ROOT, "node_modules", "@discordjs", "opus"); - const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`; - const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node"); - - if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; } - if (existsSync(opusDest)) { - if (isValidSize(opusDest, 100 * 1024)) { - log("@discordjs/opus already exists, skipping"); - return true; - } - log("@discordjs/opus exists but seems corrupted (too small), re-downloading..."); - } - - const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`; - log("Downloading @discordjs/opus..."); + const backup = stash(ffDest); + const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`; try { + log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`); const buf = await download(url); - mkdirSync(dirname(opusDest), { recursive: true }); - const require = createRequire(import.meta.url); - const tar = require("tar"); - const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`); - writeFileSync(tmpFile, buf); - await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile }); - log("@discordjs/opus OK"); - return true; + await pipeline(Readable.from(buf), createGunzip(), createWriteStream(tmpDest)); + ensureExecutable(tmpDest); + if (sizeOf(tmpDest) < FFMPEG_MIN_BYTES) { + throw new Error(`downloaded ffmpeg is only ${humanSize(tmpDest)} — truncated`); + } + renameSync(tmpDest, ffDest); // atomic swap, same directory + backup.commit(); + log(`${name}: OK (${humanSize(ffDest)})`); + return makeResult(name, "repaired", humanSize(ffDest)); } catch (err) { - log(`CDN download failed (${err.message}), trying to build from source...`); try { - execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" }); - if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) { - log("@discordjs/opus built from source OK"); - return true; + rmSync(tmpDest, { force: true }); + } catch { + /* ignore */ + } + backup.restore(); + log(`${name}: download failed — ${err.message}`); + log(`${name}: not fatal — install ffmpeg system-wide and put it on PATH instead`); + return makeResult(name, "failed", err.message); + } +} + +// --------------------------------------------------------------------------- +// @discordjs/opus (REQUIRED) +// --------------------------------------------------------------------------- + +async function ensureOpus() { + const name = "@discordjs/opus"; + const pkgDir = join(NODE_MODULES, "@discordjs", "opus"); + const prebuildRoot = join(pkgDir, "prebuild"); + // node-pre-gyp resolves this directory from the *running* Node's ABI, so a + // stale build for another ABI simply sits at another path and is ignored. + const prebuildDirName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`; + const destDir = join(prebuildRoot, prebuildDirName); + + if (!existsSync(pkgDir)) { + log(`${name}: package not installed — run 'npm install' first`); + return makeResult(name, "missing", "package not installed"); + } + + const before = probeRequire(name); + if (before.ok) { + log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`); + return makeResult(name, "ok", `ABI ${NODE_ABI}`); + } + log(`${name}: unusable — ${describeProbe(before)}`); + log(`${name}: installing a build for ABI ${NODE_ABI}...`); + + const version = readInstalledVersion(name) || "0.10.0"; + const url = + `${CDN}/@discordjs/opus/v${version}/opus-v${version}` + + `-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`; + + const backup = stash(destDir); + let staging = null; + try { + try { + log(`${name}: GET ${url}`); + const buf = await download(url); + staging = mkdtempSync(join(pkgDir, ".tsmb-staging-")); + await extractTarGz(buf, staging); + const staged = join(staging, prebuildDirName); + if (!existsSync(join(staged, "opus.node"))) { + throw new Error(`tarball did not contain ${prebuildDirName}/opus.node`); + } + mkdirSync(prebuildRoot, { recursive: true }); + rmSync(destDir, { recursive: true, force: true }); + renameSync(staged, destDir); // atomic swap, same volume + log(`${name}: prebuilt binary installed`); + } catch (cdnErr) { + log(`${name}: CDN install failed (${cdnErr.message})`); + log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`); + buildFromSource(`npm rebuild ${name}`); + } + + const after = probeRequire(name); + if (!after.ok) throw new Error(describeProbe(after)); + backup.commit(); + log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI})`); + return makeResult(name, "repaired", `ABI ${NODE_ABI}`); + } catch (err) { + backup.restore(); + log(`${name}: FAILED — ${err.message}`); + buildToolsHint(); + return makeResult(name, "failed", err.message); + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + /* ignore */ } - log("Source build completed but .node file not found"); - return false; - } catch (buildErr) { - log(`Source build failed: ${buildErr.message}`); - log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)"); - log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"); - return false; } } } -async function downloadBetterSqlite3() { - const pkgDir = join(ROOT, "node_modules", "better-sqlite3"); +// --------------------------------------------------------------------------- +// better-sqlite3 (REQUIRED) — the module the ABI bug actually bites +// --------------------------------------------------------------------------- + +async function ensureBetterSqlite3() { + const name = "better-sqlite3"; + const pkgDir = join(NODE_MODULES, name); const dest = join(pkgDir, "build", "Release", "better_sqlite3.node"); - if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; } - if (existsSync(dest)) { - if (isValidSize(dest, 500 * 1024)) { - log("better-sqlite3 already exists, skipping"); - return true; - } - log("better-sqlite3 exists but seems corrupted (too small), re-downloading..."); + if (!existsSync(pkgDir)) { + log(`${name}: package not installed — run 'npm install' first`); + return makeResult(name, "missing", "package not installed"); } - const version = "12.8.0"; - const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`; - log("Downloading better-sqlite3..."); - const buf = await download(url); - const require = createRequire(import.meta.url); - const tar = require("tar"); - const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`); - writeFileSync(tmpFile, buf); - mkdirSync(dirname(dest), { recursive: true }); - await tar.extract({ cwd: pkgDir, file: tmpFile }); - if (existsSync(dest)) { - log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`); - return true; + const before = probeRequire(name); + if (before.ok) { + log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`); + return makeResult(name, "ok", `ABI ${NODE_ABI}`); + } + // This is the case the old size check could not see: the file is there, it is + // ~1.9 MB, and it is completely useless because it targets another ABI. + log(`${name}: unusable — ${describeProbe(before)}`); + log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`); + + const version = readInstalledVersion(name) || "12.11.1"; + const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`; + + const backup = stash(dest); + let staging = null; + try { + try { + log(`${name}: GET ${url}`); + const buf = await download(url); + staging = mkdtempSync(join(pkgDir, ".tsmb-staging-")); + await extractTarGz(buf, staging); + const staged = join(staging, "build", "Release", "better_sqlite3.node"); + if (!existsSync(staged)) { + throw new Error("tarball did not contain build/Release/better_sqlite3.node"); + } + mkdirSync(dirname(dest), { recursive: true }); + rmSync(dest, { force: true }); + renameSync(staged, dest); // atomic swap, same volume + log(`${name}: prebuilt binary installed (${humanSize(dest)})`); + } catch (cdnErr) { + log(`${name}: CDN install failed (${cdnErr.message})`); + log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`); + buildFromSource(`npm rebuild ${name} --build-from-source`); + } + + const after = probeRequire(name); + if (!after.ok) throw new Error(describeProbe(after)); + backup.commit(); + log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI}, ${humanSize(dest)})`); + return makeResult(name, "repaired", `ABI ${NODE_ABI}`); + } catch (err) { + backup.restore(); + log(`${name}: FAILED — ${err.message}`); + buildToolsHint(); + return makeResult(name, "failed", err.message); + } finally { + if (staging) { + try { + rmSync(staging, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } } - log("better-sqlite3 extracted but .node file not found at expected path"); - return false; } +// --------------------------------------------------------------------------- +// stamp +// --------------------------------------------------------------------------- + +/** Record which ABI this install was built for. Lives inside node_modules so it + * dies together with the thing it describes. check-native.mjs reads it. */ +function writeStamp(results) { + if (!existsSync(NODE_MODULES)) return; + const stamp = { + abi: NODE_ABI, + nodeVersion: process.version, + platform: PLATFORM, + arch: ARCH, + updatedAt: new Date().toISOString(), + modules: Object.fromEntries(results.map((r) => [r.name, r.status])), + }; + try { + writeFileSync(STAMP_FILE, `${JSON.stringify(stamp, null, 2)}\n`); + log(`ABI stamp written: node_modules/.tsmusicbot-abi (Node ${process.version}, ABI ${NODE_ABI})`); + } catch (err) { + log(`WARN: could not write ABI stamp: ${err.message}`); + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +const STEPS = [ + ["ffmpeg-static", ensureFfmpeg], + ["@discordjs/opus", ensureOpus], + ["better-sqlite3", ensureBetterSqlite3], +]; + try { - const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]); - if (results.some(Boolean)) { - console.log(" [binary] All downloads complete"); + log(`Node ${process.version} (ABI ${NODE_ABI}), ${PLATFORM}-${ARCH}, CDN ${CDN}`); + + recoverOrphanedBackups(); + + // STRICTLY SEQUENTIAL, and it has to stay that way. The source-build fallback + // shells out through execSync, which parks the event loop for minutes; the + // 120s timeout that download() arms is a socket-INACTIVITY timer sitting on + // that same loop. Run these concurrently and the first module to fall back to + // a source build kills every download still in flight — the connection is + // healthy, the timer just never got a chance to be reset. That is not a rare + // race: npmmirror has no opus prebuild for ABI 137 (Node 24) and no + // better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node + // versions this project supports, one module 404s within ~100ms and starts + // building while ffmpeg's ~80MB download is still going. ffmpeg is optional, + // so the spurious failure used to be swallowed as a WARN and setup still + // reported success — leaving the user with no ffmpeg and no working playback. + // Nothing here benefits from overlap anyway: every probe is execFileSync. + const results = []; + for (const [name, run] of STEPS) { + try { + results.push(await run()); + } catch (err) { + results.push(makeResult(name, "failed", err?.message ?? String(err))); + } + } + + cleanupBackupDir(); + + log(""); + log(`Summary — Node ${process.version} / ABI ${NODE_ABI} / ${PLATFORM}-${ARCH}:`); + for (const r of results) { + const tag = + r.status === "ok" + ? "OK" + : r.status === "repaired" + ? "REPAIRED" + : r.required + ? "FAILED" + : "WARN (optional)"; + log(` - ${r.name.padEnd(17)} ${tag}${r.detail ? ` ${r.detail}` : ""}`); + } + + const broken = results.filter( + (r) => r.required && r.status !== "ok" && r.status !== "repaired", + ); + // Only stamp a build that actually succeeded. The stamp says "node_modules is + // built for ABI X"; writing it after a failed repair would have check-native + // print a reassuring "built with ABI 137" right above its own "this module is + // built for ABI 127" complaint. + if (broken.length === 0) writeStamp(results); + // process.exitCode rather than process.exit(): setup.bat redirects stdout to + // setup.log, and process.exit() can drop output that has not flushed yet. + if (broken.length > 0) { + log(""); + log(`ERROR: required native module(s) unusable: ${broken.map((r) => r.name).join(", ")}`); + log("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。"); + process.exitCode = 1; + } else { + log("All required native modules are ready."); + process.exitCode = 0; } } catch (e) { - console.error(` [binary] ERROR: ${e.message}`); - process.exit(1); + log(`ERROR: ${e.stack || e.message}`); + process.exitCode = 1; } - diff --git a/scripts/setup.bat b/scripts/setup.bat index e43435c..e1fb536 100644 --- a/scripts/setup.bat +++ b/scripts/setup.bat @@ -10,8 +10,11 @@ title TSMusicBot Setup :: - 自动修复 PowerShell 环境变量 :: ============================================================ -set "SCRIPT_VERSION=2.1" +set "SCRIPT_VERSION=2.2" set "MIN_NODE_MAJOR=20" +:: Newest Node major this project is regularly tested against. Anything above +:: still works, it just may have no prebuilt addons and fall back to a source build. +set "TESTED_NODE_MAJOR=22" set "LOG_FILE=%~dp0..\setup.log" set "FAILED=0" @@ -64,13 +67,38 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a" call :log "Node.js version: %NODE_VER%" echo [OK] Node.js found: %NODE_VER% -if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% ( - call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%." +:: The supported floor is not just a major version, so let node decide: +:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs +:: >=20.17 / >=22.9, and the odd majors (21 / 23) are excluded by +:: better-sqlite3 and vitest. Keep this in sync with "engines" in package.json. +node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)" +if errorlevel 1 ( + call :error "Node.js %NODE_VER% is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS." + echo Download: https://nodejs.org/ or https://nodejs.cn/ pause exit /b 1 ) + +:: Not fatal: setup now rebuilds the native modules for whatever ABI you run, +:: so newer Node majors work - they are just slower to install. +:: NOTE: keep every line inside these parenthesised blocks pure ASCII. +:: cmd.exe mis-tracks its file offset when a block contains multi-byte UTF-8 +:: characters and starts eating the "echo " prefix of following lines. +:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably. +if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% ( + echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22. + echo Newer Node majors may have no prebuilt opus / better-sqlite3, + echo so setup falls back to a source build - slower, needs C++ build tools. + echo Recommended: Node 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/ + echo This is only a warning; setup still builds the binaries for %NODE_VER%. + call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%" +) echo. +:: Native addons are tied to one Node ABI. If node_modules was built by a +:: different Node major, step 4b below detects it and repairs it. +call :log "Node ABI for this install: see node_modules\.tsmusicbot-abi after step 4b" + :: ============================================================ :: Step 2: Check npm :: ============================================================ @@ -143,16 +171,40 @@ echo [OK] Backend dependencies installed. echo. :: ============================================================ -:: Step 4b: Download native binaries from CDN +:: Step 4b: Verify / download / repair native binaries (ABI aware) :: ============================================================ -call :step "4b/7" "Downloading native binaries" +call :step "4b/7" "Checking native binaries" -node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1 -if errorlevel 1 ( - echo [WARN] Binary download had issues. Check %LOG_FILE% for details. -) else ( - echo [OK] Native binaries installed. +echo Verifying native modules for %NODE_VER% and downloading whatever is missing. +echo Progress is shown below; the full transcript goes to the log file. +echo. + +:: The .mjs writes progress to stderr and - with TSMB_BINARY_LOG_STDOUT=1 - the +:: same lines to stdout. Redirecting only stdout therefore keeps the log complete +:: while the user still sees live progress instead of a frozen window. +set "TSMB_BINARY_LOG_STDOUT=1" +node scripts\download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" +set "BIN_RESULT=!errorlevel!" +set "TSMB_BINARY_LOG_STDOUT=" + +:: ASCII only inside these blocks - see the note near the Node version check. +if not "!BIN_RESULT!"=="0" ( + set "FAILED=1" + call :error "A required native module is unusable - see the [binary] lines above." + echo Required: @discordjs/opus and better-sqlite3. + echo Full log: %LOG_FILE% ) + +if "!FAILED!"=="1" ( + echo. + echo Setup aborted. Fix the problem above and run this script again. + call :log "Setup aborted at step 4b" + pause + exit /b 1 +) + +echo [OK] Native binaries ready for %NODE_VER%. +echo ABI recorded in node_modules\.tsmusicbot-abi echo. :: ============================================================ diff --git a/scripts/setup.sh b/scripts/setup.sh index dcd0bde..d54bf44 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -27,6 +27,26 @@ if ! command -v node &>/dev/null; then fi echo "[OK] Node.js $(node -v)" +# Newest Node major this project is regularly tested against. Anything above +# still works, it just may have no prebuilt addons and fall back to a source build. +TESTED_NODE_MAJOR=22 +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')" + +# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client +# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors +# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines". +if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then + echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS." + echo " https://nodejs.org/ | https://nodejs.cn/" + exit 1 +fi +if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then + echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 20 / Node 22)." + echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包," + echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。" + echo " This is only a warning - setup builds the binaries for $(node -v) either way." +fi + if ! command -v npm &>/dev/null; then echo "[ERROR] npm not found." exit 1 @@ -73,15 +93,30 @@ npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_F echo "[OK] Dependencies installed." echo "" -# ---- Step 2: Download native binaries from CDN ---- -echo "---- 2/5: Downloading native binaries ----" +# ---- Step 2: Verify / download / repair native binaries (ABI aware) ---- +echo "---- 2/5: Checking native binaries ----" echo "" -if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then - echo "[OK] Native binaries installed." -else - echo "[WARN] Some native binaries had issues (will try source build as fallback)." +# The old `if node ... | tee ...` only printed a [WARN] and carried on, so a +# broken native module still produced a "Setup Complete!" banner. It also read +# the *pipeline's* status: `set -o pipefail` above happens to surface node's +# failure, but a failing `tee` (unwritable log) was indistinguishable from a +# failing node. PIPESTATUS[0] is exactly node's own exit code, nothing else. +set +e +node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE" +BIN_STATUS=${PIPESTATUS[0]} +set -e + +if [ "$BIN_STATUS" -ne 0 ]; then + echo "" + echo "[ERROR] A required native module (@discordjs/opus / better-sqlite3) is unusable." + echo " 必需的原生模块不可用,安装中止。原因见上面的 [binary] 输出。" + echo " Log: $LOG_FILE" + exit 1 fi +# ffmpeg-static failures are only a WARN inside the script above (a system +# ffmpeg on PATH is a supported fallback), so reaching here means we are good. +echo "[OK] Native binaries ready for $(node -v)." echo "" # ---- Step 3: Install web panel dependencies ---- diff --git a/scripts/start.bat b/scripts/start.bat index 26fc9c7..3307b72 100644 --- a/scripts/start.bat +++ b/scripts/start.bat @@ -29,6 +29,19 @@ if not exist "dist" ( exit /b 1 ) +:: Preflight: do the compiled native modules match THIS Node version? +:: Switching Node majors after setup leaves node_modules built for the old ABI; +:: without this check the bot dies mid-startup with a NODE_MODULE_VERSION stack. +:: check-native.mjs prints the bilingual explanation itself; keep the lines in +:: this block pure ASCII (cmd.exe garbles multi-byte text inside blocks). +node scripts\check-native.mjs +if errorlevel 1 ( + echo. + echo Please run scripts\setup.bat to rebuild the native modules. + pause + exit /b 1 +) + :: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems) where powershell >nul 2>&1 if errorlevel 1 ( @@ -38,7 +51,10 @@ if errorlevel 1 ( ) :: Start the application +echo WebUI: http://localhost:3000 +echo Press Ctrl+C to stop. +echo. node dist/index.js pause - + diff --git a/start.bat b/start.bat index dd63003..446d458 100644 --- a/start.bat +++ b/start.bat @@ -1,19 +1,24 @@ @echo off -title TSMusicBot +:: ============================================================ +:: TSMusicBot - convenience launcher at the repo root. +:: Everything real lives in scripts\start.bat; this file only makes sure we +:: run from the project directory and then delegates, so both entry points +:: behave identically (same node/dist checks, same native-module preflight). +:: ============================================================ -:: Check node -where node >nul 2>&1 -if errorlevel 1 ( - echo Node.js not found. Run scripts\setup.bat first. +cd /d "%~dp0" || ( + echo [FATAL] Cannot change to the project directory. pause exit /b 1 ) -echo Starting TSMusicBot... -echo WebUI: http://localhost:3000 -echo Press Ctrl+C to stop. -echo. +:: Keep lines inside parenthesised blocks pure ASCII: cmd.exe mis-tracks its +:: file offset when a block contains multi-byte UTF-8 and eats the "echo " prefix. +if not exist "scripts\start.bat" ( + echo scripts\start.bat not found - is this the TSMusicBot project folder? + pause + exit /b 1 +) -node dist\index.js - -pause \ No newline at end of file +call "scripts\start.bat" +exit /b %errorlevel%