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>
### 方式四: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
chmod +x 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/ # 部署脚本
│ ├── setup.bat # Windows 首次安装
│ ├── start.bat # Windows 启动脚本
│ ├── setup.sh # Linux/macOS 首次安装(只安装构建)
│ ├── install.sh # Linux 一键安装 + systemd 服务
│ └── docker/ # Docker 部署文件
│ ├── Dockerfile
+67 -25
View File
@@ -1,6 +1,16 @@
#!/usr/bin/env bash
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 "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
@@ -11,6 +21,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/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
if [ ! -f "$PROJECT_DIR/package.json" ]; then
@@ -28,42 +41,66 @@ else
exit 1
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
ubuntu|debian)
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
;;
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
echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/6] Installing dependencies..."
cd "$PROJECT_DIR"
npm install
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
(cd "$PROJECT_DIR/web" && npm install)
echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..."
if node_supported; then
echo "Node.js $(node -v) already installed"
else
case $OS in
ubuntu|debian)
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
sudo apt-get install -y -qq nodejs
;;
centos|rhel|fedora|rocky|almalinux)
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash -
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
echo "[4/6] Building project..."
npm run build
echo "[3/5] Installing dependencies and building (scripts/setup.sh)..."
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"
# 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/node_modules" "$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 cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
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
sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory
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
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,7 +130,7 @@ After=network.target
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
@@ -99,15 +141,15 @@ EOL
sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
sudo systemctl restart ${SERVICE_NAME}
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ TSMusicBot installed and running! ║"
echo "║ ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ WebUI: http://localhost:3000 ║"
echo "║ ║"
echo "║ Commands: ║"
echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
+1 -1
View File
@@ -21,7 +21,7 @@ echo ""
# ---- Check Node.js ----
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)."
exit 1
fi
+1 -6
View File
@@ -456,16 +456,11 @@ export class BotInstance extends EventEmitter {
this.voiceDucking.removeSpeaker(event.id);
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()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
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 {
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);
});
});
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).
*/
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. */
private permDenied = {
@@ -141,8 +134,6 @@ export class BotProfileManager {
onConnect(): void {
this.generation++;
this.currentSong = null;
// Channel ids are per-server; never carry one across a (re)connect.
this.channelDescCid = null;
this.permDenied = {
avatar: 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 {
return { ...this.config };
}
@@ -434,25 +400,18 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(
song: QueuedSong | null,
targetChannelId?: bigint,
): Promise<void> {
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = targetChannelId ?? this.tsClient.getChannelId();
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
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(
`channeledit cid=${target} channel_description=`,
`channeledit cid=${channelId} channel_description=`,
);
this.channelDescCid = null;
return;
}
if (channelId === 0n) return; // unknown channel
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
@@ -463,7 +422,6 @@ export class BotProfileManager {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
this.channelDescCid = channelId;
} catch (err) {
this.handleFeatureError("channelDesc", err);
}