Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 faf6ac09ec fix(profile): move the now-playing channel description with the bot (#159)
When the bot was moved to another channel, the channel it left kept the
now-playing description forever: updateChannelDescription always targeted
getChannelId(), which by then already reported the new channel.

Remember which channel we last wrote to. On a self clientMoved event,
clear that channel and, if a song is playing, write the description to
the new one. Stop now clears the channel we actually wrote to, so a
missed move event can't leave a stale description behind either.

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

No files matched your search

+2 -24
View File
@@ -136,35 +136,14 @@ 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、不配服务):** 自动安装 Node.js 和依赖,配置 systemd 服务,支持开机自启。
```bash
bash scripts/setup.sh
npm start
```
## 更新升级 ## 更新升级
@@ -516,7 +495,6 @@ 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
+23 -65
View File
@@ -1,16 +1,6 @@
#!/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 "╚══════════════════════════════════════╝"
@@ -21,9 +11,6 @@ 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
@@ -41,66 +28,42 @@ else
exit 1 exit 1
fi fi
# Supported: 22.12+ or 24+ (odd majors are excluded by better-sqlite3 and vitest). echo "[1/6] Installing system dependencies..."
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 ca-certificates build-essential python3 ffmpeg sudo apt-get install -y -qq curl build-essential python3
;; ;;
centos|rhel|fedora|rocky|almalinux) centos|rhel|fedora)
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 --needed curl base-devel python ffmpeg sudo pacman -S --noconfirm curl base-devel python
;; ;;
*) *)
echo "Unsupported OS: $OS. Please install Node.js ${NODE_LTS_MAJOR}.12+ and build tools manually." echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
;; ;;
esac esac
echo "[2/5] Installing Node.js ${NODE_LTS_MAJOR} LTS..." echo "[2/6] Installing Node.js 20 LTS..."
if node_supported; then if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
echo "Node.js $(node -v) already installed" curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
else sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
case $OS in fi
ubuntu|debian) echo "Node.js $(node -v) installed"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo -E bash -
sudo apt-get install -y -qq nodejs echo "[3/6] Installing dependencies..."
;; cd "$PROJECT_DIR"
centos|rhel|fedora|rocky|almalinux) npm install
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x" | sudo bash - if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
sudo yum install -y nodejs (cd "$PROJECT_DIR/web" && npm install)
;;
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 "[3/5] Installing dependencies and building (scripts/setup.sh)..." echo "[4/6] Building project..."
bash "$SCRIPT_DIR/setup.sh" npm run build
echo "[4/5] Copying to $INSTALL_DIR..." echo "[5/6] 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/"
@@ -109,18 +72,13 @@ 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 "[5/5] Creating systemd service..." echo "[6/6] 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
@@ -130,7 +88,7 @@ After=network.target
Type=simple Type=simple
User=root User=root
WorkingDirectory=${INSTALL_DIR} WorkingDirectory=${INSTALL_DIR}
ExecStart=${NODE_BIN} ${INSTALL_DIR}/dist/index.js ExecStart=/usr/bin/node ${INSTALL_DIR}/dist/index.js
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=NODE_ENV=production Environment=NODE_ENV=production
@@ -141,7 +99,7 @@ EOL
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable ${SERVICE_NAME} sudo systemctl enable ${SERVICE_NAME}
sudo systemctl restart ${SERVICE_NAME} sudo systemctl start ${SERVICE_NAME}
echo "" echo ""
echo "╔══════════════════════════════════════╗" echo "╔══════════════════════════════════════╗"
+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 22.12+ LTS from https://nodejs.org" echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)." echo " or https://nodejs.cn/ (China mirror)."
exit 1 exit 1
fi fi
+6 -1
View File
@@ -456,11 +456,16 @@ 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 }) => { this.tsClient.on("clientMoved", (event: { id: number; targetChannelID: bigint }) => {
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,3 +202,65 @@ 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);
});
});
+47 -5
View File
@@ -32,6 +32,13 @@ 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 = {
@@ -134,6 +141,8 @@ 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,
@@ -150,6 +159,31 @@ 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 };
} }
@@ -400,18 +434,25 @@ export class BotProfileManager {
return str.slice(0, end) + ellipsis; return str.slice(0, end) + ellipsis;
} }
private async updateChannelDescription(song: QueuedSong | null): Promise<void> { private async updateChannelDescription(
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 = this.tsClient.getChannelId(); const channelId = targetChannelId ?? 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=${channelId} channel_description=`, `channeledit cid=${target} 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}`, // ♪ 正在播放:
@@ -422,6 +463,7 @@ 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);
} }