Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 88e6b5a691 fix(install): bring install.sh up to Node 22 and document both Linux scripts (#165)
install.sh still installed Node 20 (dropped in #152), ran the Debian-only
NodeSource script on yum systems, and hard-coded /usr/bin/node. The
README only mentioned install.sh, not setup.sh.

install.sh now:
- installs Node 22 LTS from the right NodeSource repo per distro and
  checks the same 22.12+/24+ floor as setup.sh
- delegates npm install, mirror detection, native-binary checks and the
  build to setup.sh, so the two scripts share one install path
- stops the service and replaces dist/node_modules on re-install (data/
  is kept), copies bin/ (yt-dlp), and uses the real node path in the unit

README explains the difference between the two scripts and when to use
which. setup.sh's "Node.js not found" message no longer says 20+.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:42:59 +08:00
6 changed files with 98 additions and 143 deletions

No files matched your search

+24 -2
View File
@@ -136,14 +136,35 @@ ports:
</details> </details>
### 方式四:Linux 一键安装 ### 方式四:Linux 安装脚本
Linux 下有两个脚本,按需二选一:
| | `scripts/install.sh`(一键安装 + 系统服务) | `scripts/setup.sh`(只安装构建) |
|---|---|---|
| 适合 | 想开箱即用、开机自启的服务器 | 想自己决定怎么常驻(screen / tmux / pm2 / 自写服务)的用户,或 macOS |
| Node.js | 没有或版本过低时**自动安装 Node 22 LTS**(apt / yum / pacman) | **不会安装**,需先自行装好 Node 22.12+ |
| 系统依赖 | 自动安装构建工具(和 FFmpeg,作为内置 FFmpeg 的后备) | 不安装,只提示 |
| 安装位置 | 构建后复制到 `/opt/tsmusicbot`(重装时保留 `data/`) | 就在当前项目目录 |
| 系统服务 | 自动配置 systemd 服务 `tsmusicbot` 并开机自启 | **不配置服务**,完成后自己 `npm start` |
| 需要 root | 是(`sudo`) | 否 |
两者共用同一套安装逻辑:`install.sh` 会调用 `setup.sh` 完成依赖安装、国内网络镜像切换、原生模块校验和构建,然后再复制文件、配置服务。
**一键安装 + systemd 服务:**
```bash ```bash
chmod +x scripts/install.sh chmod +x scripts/install.sh
sudo ./scripts/install.sh sudo ./scripts/install.sh
# 之后:systemctl status|restart|stop tsmusicbot,日志:journalctl -u tsmusicbot -f
``` ```
自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。 **只安装构建(不装 Node、不配服务):**
```bash
bash scripts/setup.sh
npm start
```
## 更新升级 ## 更新升级
@@ -495,6 +516,7 @@ teamspeak-music-bot/
├── scripts/ # 部署脚本 ├── scripts/ # 部署脚本
│ ├── setup.bat # Windows 首次安装 │ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本 │ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务 │ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件 │ └── docker/ # Docker 部署文件
│ ├── Dockerfile │ ├── Dockerfile
+67 -25
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
#
# TSMusicBot Installer (Linux, systemd)
# - Installs system packages and Node.js 22 LTS
# - Runs scripts/setup.sh to install dependencies, verify native binaries and build
# - Copies the build to /opt/tsmusicbot and registers a systemd service (auto-start on boot)
#
# Only want to build and run it yourself (no Node install, no service)?
# Use scripts/setup.sh instead — see README「Linux 安装脚本」.
#
echo "╔══════════════════════════════════════╗" echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot Installer ║" echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝" echo "╚══════════════════════════════════════╝"
@@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot" INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot" SERVICE_NAME="tsmusicbot"
# Node LTS line to install when a supported Node is missing. Keep in sync with
# package.json "engines" and the floor check in setup.sh (#152).
NODE_LTS_MAJOR=22
# Verify we're in a valid project directory # Verify we're in a valid project directory
if [ ! -f "$PROJECT_DIR/package.json" ]; then if [ ! -f "$PROJECT_DIR/package.json" ]; then
@@ -28,42 +41,66 @@ else
exit 1 exit 1
fi fi
echo "[1/6] Installing system dependencies..." # Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest).
node_supported() {
command -v node &> /dev/null &&
node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'
}
echo "[1/5] Installing system dependencies..."
case $OS in case $OS in
ubuntu|debian) ubuntu|debian)
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y -qq curl build-essential python3 sudo apt-get install -y -qq curl ca-certificates build-essential python3 ffmpeg
;; ;;
centos|rhel|fedora) centos|rhel|fedora|rocky|almalinux)
sudo yum install -y curl gcc gcc-c++ make python3 sudo yum install -y curl gcc gcc-c++ make python3
;; ;;
arch|manjaro) arch|manjaro)
sudo pacman -S --noconfirm curl base-devel python sudo pacman -S --noconfirm --needed curl base-devel python ffmpeg
;; ;;
*) *)
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually." echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually."
;; ;;
esac esac
echo "[2/6] Installing Node.js 20 LTS..." echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then if node_supported; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - echo "Node.js $(node -v) already installed"
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null else
fi case $OS in
echo "Node.js $(node -v) installed" ubuntu|debian)
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
echo "[3/6] Installing dependencies..." sudo apt-get install -y -qq nodejs
cd "$PROJECT_DIR" ;;
npm install centos|rhel|fedora|rocky|almalinux)
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
(cd "$PROJECT_DIR/web" && npm install) sudo yum install -y nodejs
;;
arch|manjaro)
sudo pacman -S --noconfirm --needed nodejs npm
;;
esac
if ! node_supported; then
echo "Error: Node.js 22.12+ (or 24+) is required, found: $(node -v 2>/dev/null || echo none)."
echo "Install it from https://nodejs.org/ (or https://nodejs.cn/) and re-run this script."
exit 1
fi
echo "Node.js $(node -v) installed"
fi fi
echo "[4/6] Building project..." echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
npm run build bash "$SCRIPT_DIR/setup.sh"
echo "[5/6] Copying to $INSTALL_DIR..." echo "[4/5] Copying to $INSTALL_DIR..."
# Stop a running copy before replacing its files (re-install / upgrade).
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
sudo systemctl stop "$SERVICE_NAME"
fi
sudo mkdir -p "$INSTALL_DIR" sudo mkdir -p "$INSTALL_DIR"
# Replace build output wholesale so files removed upstream don't linger.
# data/ (config, database, cookies) is never touched.
sudo rm -rf "$INSTALL_DIR/dist" "$INSTALL_DIR/node_modules" "$INSTALL_DIR/web/dist"
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/" sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/" sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/" sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
@@ -72,13 +109,18 @@ if [ -d "$PROJECT_DIR/web/dist" ]; then
sudo mkdir -p "$INSTALL_DIR/web" sudo mkdir -p "$INSTALL_DIR/web"
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/" sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
fi fi
# yt-dlp is looked up in bin/ next to dist/ before falling back to PATH
if [ -d "$PROJECT_DIR/bin" ]; then
sudo cp -r "$PROJECT_DIR/bin" "$INSTALL_DIR/"
fi
# Copy scripts for future use # Copy scripts for future use
sudo mkdir -p "$INSTALL_DIR/scripts" sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory # Create data directory
sudo mkdir -p "$INSTALL_DIR/data" sudo mkdir -p "$INSTALL_DIR/data"
echo "[6/6] Creating systemd service..." echo "[5/5] Creating systemd service..."
NODE_BIN="$(command -v node)"
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit] [Unit]
Description=TSMusicBot - TeamSpeak Music Bot Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,7 +130,7 @@ After=network.target
Type=simple Type=simple
User=root User=root
WorkingDirectory=${INSTALL_DIR} WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=NODE_ENV=production Environment=NODE_ENV=production
@@ -99,15 +141,15 @@ EOL
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME} sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME} sudo systemctl restart ${SERVICE_NAME}
echo "" echo ""
echo "╔══════════════════════════════════════╗" echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot installed and running! ║" echo "║ TSMusicBot installed and running! ║"
echo "║ ║" echo "║ ║"
echo "║ WebUI: http://localhost:3000 ║" echo "║ WebUI: http://localhost:3000 ║"
echo "║ ║" echo "║ ║"
echo "║ Commands: ║" echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║" echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║" echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║" echo "║ systemctl stop tsmusicbot ║"
+1 -1
View File
@@ -21,7 +21,7 @@ echo ""
# ---- Check Node.js ---- # ---- Check Node.js ----
if ! command -v node &>/dev/null; then if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org" echo "[ERROR] Node.js not found. Please install Node.js 22.12+ LTS from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)." echo " or https://nodejs.cn/ (China mirror)."
exit 1 exit 1
fi fi
+1 -6
View File
@@ -456,16 +456,11 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id); this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy(); void this.refreshOccupancy();
}); });
this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => { this.tsClient.on("clientMoved", (event: { id: number }) => {
if (event.id === this.tsClient.getClientId()) { if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old // Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive. // channel even if no individual leave events arrive.
this.voiceDucking.reset(false); this.voiceDucking.reset(false);
// Carry the now-playing channel description over to the new
// channel instead of leaving it stale in the old one (#159).
this.profileManager.onChannelMoved(event.targetChannelID).catch((err) => {
this.logger.warn({ err }, "Channel description move update failed");
});
} else { } else {
this.voiceDucking.removeSpeaker(event.id); this.voiceDucking.removeSpeaker(event.id);
} }
-62
View File
@@ -202,65 +202,3 @@ describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true); expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
}); });
}); });
describe("BotProfileManager channel description follows the bot (#159)", () => {
const cfgChannelDesc = { ...cfgOff, channelDescEnabled: true };
let ts: ReturnType<typeof makeMockTs> & { cid: bigint };
let channelEdits: () => string[];
beforeEach(() => {
ts = makeMockTs() as any;
ts.cid = 5n;
(ts as any).getChannelId = () => ts.cid;
channelEdits = () =>
(ts.sendCommandNoWait as any).mock.calls
.map((c: any[]) => c[0] as string)
.filter((cmd: string) => cmd.startsWith("channeledit"));
});
it("clears the old channel and fills the new one when moved while playing", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
expect(channelEdits()).toEqual([
expect.stringMatching(/^channeledit cid=5 channel_description=\S+/),
]);
ts.cid = 9n;
await pm.onChannelMoved(9n);
const edits = channelEdits();
expect(edits[1]).toBe("channeledit cid=5 channel_description=");
expect(edits[2]).toMatch(/^channeledit cid=9 channel_description=\S+/);
});
it("stopping after a move clears the channel the bot is in now, not the old one", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
await pm.onSongChange(null);
expect(channelEdits().at(-1)).toBe("channeledit cid=9 channel_description=");
});
it("a move while idle touches no channel description", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("a move is ignored when the channel description feature is off", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onSongChange(fakeSong);
ts.cid = 9n;
await pm.onChannelMoved(9n);
expect(channelEdits()).toEqual([]);
});
it("an event for the channel the description is already in is a no-op", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgChannelDesc, "Bot");
await pm.onSongChange(fakeSong);
await pm.onChannelMoved(5n);
expect(channelEdits()).toHaveLength(1);
});
});
+5 -47
View File
@@ -32,13 +32,6 @@ export class BotProfileManager {
* pushed immediately (idle) or wait for the next stop event (playing). * pushed immediately (idle) or wait for the next stop event (playing).
*/ */
private currentSong: QueuedSong | null = null; private currentSong: QueuedSong | null = null;
/**
* Channel whose description currently holds our now-playing text, or null
* if we have not written one. Remembered so that when the bot is moved we
* can still clean up the channel it was taken out of (#159) — by then
* getChannelId() already reports the new channel.
*/
private channelDescCid: bigint | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */ /** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = { private permDenied = {
@@ -141,8 +134,6 @@ export class BotProfileManager {
onConnect(): void { onConnect(): void {
this.generation++; this.generation++;
this.currentSong = null; this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = { this.permDenied = {
avatar: false, avatar: false,
description: false, description: false,
@@ -159,31 +150,6 @@ export class BotProfileManager {
} }
} }
/**
* Called when the bot itself has been moved to another channel (#159).
* Clears the now-playing text from the channel it left and, if a song is
* playing, writes it to the channel it is in now.
*/
async onChannelMoved(newChannelId: bigint): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
const oldChannelId = this.channelDescCid;
if (oldChannelId === newChannelId) return;
try {
if (oldChannelId !== null) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${oldChannelId} channel_description=`,
);
this.channelDescCid = null;
}
} catch (err) {
this.handleFeatureError("channelDesc", err);
return;
}
if (this.currentSong) {
await this.updateChannelDescription(this.currentSong, newChannelId);
}
}
getConfig(): ProfileConfig { getConfig(): ProfileConfig {
return { ...this.config }; return { ...this.config };
} }
@@ -434,25 +400,18 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis; return str.slice(0, end) + ellipsis;
} }
private async updateChannelDescription( private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return; if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try { try {
const channelId = targetChannelId ?? this.tsClient.getChannelId(); const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) { if (!song) {
// Prefer the channel we actually wrote to, in case a move event
// was missed and the bot is somewhere else now.
const target = this.channelDescCid ?? channelId;
if (target === 0n) return; // unknown channel
await this.tsClient.sendCommandNoWait( await this.tsClient.sendCommandNoWait(
`channeledit cid=${target} channel_description=`, `channeledit cid=${channelId} channel_description=`,
); );
this.channelDescCid = null;
return; return;
} }
if (channelId === 0n) return; // unknown channel
const lines = [ const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放: `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -463,7 +422,6 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait( await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`, `channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
); );
this.channelDescCid = channelId;
} catch (err) { } catch (err) {
this.handleFeatureError("channelDesc", err); this.handleFeatureError("channelDesc", err);
} }