Fix Docker restart crash: respect autoStart flag and persist identity

Root cause: on Docker restart, loadSavedBots() unconditionally connected
ALL saved bots regardless of autoStart flag, causing a rapid
connect/disconnect loop. Additionally, bot identities were regenerated
on every restart, causing TS server conflicts with stale sessions.

Changes:
- loadSavedBots() now only auto-connects bots with autoStart=true
- startBot/stopBot persist autoStart state so restart behavior matches
  user intent
- Bot TS3 identity is persisted to database and reused across restarts
- Database schema migrated to include identity column
- TS3Client.connect() cleans up existing connection before reconnecting
- Stagger bot connections by 1s to avoid overwhelming the TS server

https://claude.ai/code/session_01L2kEV2M1QFWMCyPtLU5LgC
This commit is contained in:
Claude committed 2026-04-07 12:15:39 +00:00
1 parent b97f4a1d86
commit 2559701e55
4 files changed
+82 -15

No files matched your search

+4
View File
@@ -553,4 +553,8 @@ export class BotInstance extends EventEmitter {
isConnected(): boolean { isConnected(): boolean {
return this.connected; return this.connected;
} }
getIdentityExport(): string | undefined {
return this.tsClient.getIdentityExport();
}
} }
+42 -9
View File
@@ -131,12 +131,25 @@ export class BotManager {
const bot = this.bots.get(id); const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`); if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect(); await bot.connect();
// Mark as autoStart so it reconnects on Docker restart, and persist identity
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
}
} }
stopBot(id: string): void { stopBot(id: string): void {
const bot = this.bots.get(id); const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`); if (!bot) throw new Error(`Bot ${id} not found`);
bot.disconnect(); bot.disconnect();
// Mark as not autoStart so it stays stopped on Docker restart
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
this.database.saveBotInstance({ ...saved, autoStart: false });
}
} }
async loadSavedBots(): Promise<void> { async loadSavedBots(): Promise<void> {
@@ -150,6 +163,7 @@ export class BotManager {
port: saved.serverPort, port: saved.serverPort,
queryPort: 10011, queryPort: 10011,
nickname: saved.nickname, nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined, defaultChannel: saved.defaultChannel || undefined,
channelPassword: saved.channelPassword || undefined, channelPassword: saved.channelPassword || undefined,
}, },
@@ -163,18 +177,30 @@ export class BotManager {
this.bots.set(saved.id, bot); this.bots.set(saved.id, bot);
// Auto-connect in background (non-blocking, won't affect other bots) // Only auto-connect bots that have autoStart enabled
bot.connect().then(() => { if (saved.autoStart) {
bot.connect().then(() => {
// Persist identity after successful connection for future restarts
this.persistBotIdentity(saved, bot);
this.logger.info(
{ botId: saved.id, name: saved.name },
"Auto-connected saved bot"
);
}).catch((err) => {
this.logger.error(
{ err, botId: saved.id, name: saved.name },
"Failed to auto-connect bot (start manually from Settings)"
);
});
// Stagger connections to avoid overwhelming the TS server
await new Promise((resolve) => setTimeout(resolve, 1000));
} else {
this.logger.info( this.logger.info(
{ botId: saved.id, name: saved.name }, { botId: saved.id, name: saved.name },
"Auto-connected saved bot" "Loaded bot (autoStart disabled, not connecting)"
); );
}).catch((err) => { }
this.logger.error(
{ err, botId: saved.id, name: saved.name },
"Failed to auto-connect bot (start manually from Settings)"
);
});
} }
this.logger.info( this.logger.info(
@@ -183,6 +209,13 @@ export class BotManager {
); );
} }
private persistBotIdentity(saved: import("../data/database.js").BotInstance, bot: BotInstance): void {
const identity = bot.getIdentityExport();
if (identity && identity !== saved.identity) {
this.database.saveBotInstance({ ...saved, identity });
}
}
shutdown(): void { shutdown(): void {
for (const bot of this.bots.values()) { for (const bot of this.bots.values()) {
bot.disconnect(); bot.disconnect();
+24 -6
View File
@@ -24,6 +24,7 @@ export interface BotInstance {
defaultChannel: string; defaultChannel: string;
channelPassword: string; channelPassword: string;
autoStart: boolean; autoStart: boolean;
identity?: string;
} }
export interface BotDatabase { export interface BotDatabase {
@@ -36,6 +37,15 @@ export interface BotDatabase {
close(): void; close(): void;
} }
function migrateSchema(db: Database.Database): void {
// Add identity column if it doesn't exist (migration for existing databases)
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
const hasIdentity = columns.some((c) => c.name === "identity");
if (!hasIdentity) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
}
function initTables(db: Database.Database): void { function initTables(db: Database.Database): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS play_history ( CREATE TABLE IF NOT EXISTS play_history (
@@ -58,7 +68,8 @@ function initTables(db: Database.Database): void {
nickname TEXT NOT NULL, nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL, defaultChannel TEXT NOT NULL,
channelPassword TEXT NOT NULL, channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0 autoStart INTEGER NOT NULL DEFAULT 0,
identity TEXT
); );
`); `);
} }
@@ -67,6 +78,7 @@ export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma("journal_mode = WAL"); db.pragma("journal_mode = WAL");
initTables(db); initTables(db);
migrateSchema(db);
const insertHistory = db.prepare(` const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl) INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
@@ -78,8 +90,8 @@ export function createDatabase(dbPath: string): BotDatabase {
`); `);
const upsertInstance = db.prepare(` const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart) INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart) VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
serverAddress = excluded.serverAddress, serverAddress = excluded.serverAddress,
@@ -87,7 +99,8 @@ export function createDatabase(dbPath: string): BotDatabase {
nickname = excluded.nickname, nickname = excluded.nickname,
defaultChannel = excluded.defaultChannel, defaultChannel = excluded.defaultChannel,
channelPassword = excluded.channelPassword, channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart autoStart = excluded.autoStart,
identity = excluded.identity
`); `);
const selectInstances = db.prepare(`SELECT * FROM bot_instances`); const selectInstances = db.prepare(`SELECT * FROM bot_instances`);
@@ -109,14 +122,19 @@ export function createDatabase(dbPath: string): BotDatabase {
upsertInstance.run({ upsertInstance.run({
...instance, ...instance,
autoStart: instance.autoStart ? 1 : 0, autoStart: instance.autoStart ? 1 : 0,
identity: instance.identity ?? null,
}); });
}, },
getBotInstances() { getBotInstances() {
const rows = selectInstances.all() as Array< const rows = selectInstances.all() as Array<
Omit<BotInstance, "autoStart"> & { autoStart: number } Omit<BotInstance, "autoStart" | "identity"> & { autoStart: number; identity: string | null }
>; >;
return rows.map((r) => ({ ...r, autoStart: r.autoStart === 1 })); return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
identity: r.identity ?? undefined,
}));
}, },
deleteBotInstance(id) { deleteBotInstance(id) {
+12
View File
@@ -53,6 +53,18 @@ export class TS3Client extends EventEmitter {
} }
async connect(): Promise<void> { async connect(): Promise<void> {
// Clean up any existing connection before creating a new one
if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting");
try {
await this.client.disconnect();
} catch {
// Ignore errors during cleanup
}
this.client = null;
this.clientId = 0;
}
const addr = `${this.options.host}:${this.options.port}`; const addr = `${this.options.host}:${this.options.port}`;
this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)"); this.logger.info({ addr }, "Connecting to TeamSpeak server (full client protocol)");