feat: add idle timeout setting to auto-disconnect bot when channel is empty

This commit is contained in:
NeoPecos committed 2026-04-13 17:27:51 +08:00
1 parent d7107677c0
commit 7785cc972b
6 files changed
+120 -1

No files matched your search

Regular → Executable
+49
View File
@@ -60,6 +60,8 @@ export class BotInstance extends EventEmitter {
private disconnectEmitted = false; private disconnectEmitted = false;
private voteSkipUsers = new Set<string>(); private voteSkipUsers = new Set<string>();
private isAdvancing = false; private isAdvancing = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private channelUserCount = 0;
constructor(options: BotInstanceOptions) { constructor(options: BotInstanceOptions) {
super(); super();
@@ -121,6 +123,10 @@ export class BotInstance extends EventEmitter {
this.disconnectEmitted = true; this.disconnectEmitted = true;
this.emit("disconnected"); this.emit("disconnected");
}); });
this.tsClient.on("connected", () => {
this._startIdlePoller();
});
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@@ -138,6 +144,7 @@ export class BotInstance extends EventEmitter {
} }
disconnect(): void { disconnect(): void {
this._cancelIdleTimer();
this.player.stop(); this.player.stop();
this.connected = false; this.connected = false;
if (!this.disconnectEmitted) { if (!this.disconnectEmitted) {
@@ -147,6 +154,48 @@ export class BotInstance extends EventEmitter {
this.tsClient.disconnect(); this.tsClient.disconnect();
} }
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
updateIdleTimeout(minutes: number): void {
this.config.idleTimeoutMinutes = minutes;
if (minutes === 0) this._cancelIdleTimer();
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
if (!this.connected) return;
try {
const clients = await this.tsClient.getClientsInChannel();
const userCount = clients.length - 1; // 排除 bot 自身
if (userCount <= 0) {
this._scheduleIdleCheck();
} else {
this._cancelIdleTimer();
}
} catch { /* ignore */ }
setTimeout(poll, 30_000);
};
setTimeout(poll, 30_000);
}
private _scheduleIdleCheck(): void {
if (this.idleTimer !== null) return; // 已经在倒计时,不重复创建
const minutes = this.config.idleTimeoutMinutes ?? 0;
if (!this.connected || minutes <= 0) return;
this.idleTimer = setTimeout(() => {
if (!this.connected) return;
this.logger.info({ idleMinutes: minutes }, "Channel empty, disconnecting due to idle timeout");
this.disconnect();
}, minutes * 60 * 1000);
}
private _cancelIdleTimer(): void {
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
}
private async handleTextMessage(msg: TS3TextMessage): Promise<void> { private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand( const parsed = parseCommand(
msg.message, msg.message,
Regular → Executable
+2
View File
@@ -13,6 +13,7 @@ export interface BotConfig {
adminGroups: number[]; adminGroups: number[];
autoReturnDelay: number; autoReturnDelay: number;
autoPauseOnEmpty: boolean; autoPauseOnEmpty: boolean;
idleTimeoutMinutes: number;
} }
export function getDefaultConfig(): BotConfig { export function getDefaultConfig(): BotConfig {
@@ -28,6 +29,7 @@ export function getDefaultConfig(): BotConfig {
adminGroups: [], adminGroups: [],
autoReturnDelay: 300, autoReturnDelay: 300,
autoPauseOnEmpty: true, autoPauseOnEmpty: true,
idleTimeoutMinutes: 0,
}; };
} }
Regular → Executable
+1
View File
@@ -71,6 +71,7 @@ async function main() {
bilibiliProvider, bilibiliProvider,
database: db, database: db,
config, config,
configPath: CONFIG_PATH,
logger, logger,
cookieStore, cookieStore,
staticDir: STATIC_DIR, staticDir: STATIC_DIR,
Regular → Executable
+23
View File
@@ -1,11 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import type { BotManager } from "../../bot/manager.js"; import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js"; import type { BotConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
export function createBotRouter( export function createBotRouter(
botManager: BotManager, botManager: BotManager,
config: BotConfig, config: BotConfig,
configPath: string,
logger: Logger logger: Logger
): Router { ): Router {
const router = Router(); const router = Router();
@@ -115,6 +117,27 @@ export function createBotRouter(
res.status(500).json({ error: (err as Error).message }); res.status(500).json({ error: (err as Error).message });
} }
}); });
// GET /api/bot/settings — 读取全局 bot 行为设置
router.get("/settings", (_req, res) => {
res.json({ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0 });
});
// POST /api/bot/settings — 保存全局 bot 行为设置
router.post("/settings", (req, res) => {
const { idleTimeoutMinutes } = req.body;
if (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0) {
res.status(400).json({ error: "idleTimeoutMinutes must be a non-negative number" });
return;
}
config.idleTimeoutMinutes = idleTimeoutMinutes;
saveConfig(configPath, config);
// 通知所有 bot 实例更新定时器
for (const bot of botManager.getAllBots()) {
bot.updateIdleTimeout(idleTimeoutMinutes);
}
res.json({ ok: true });
});
return router; return router;
} }
Regular → Executable
+2 -1
View File
@@ -22,6 +22,7 @@ export interface WebServerOptions {
bilibiliProvider: MusicProvider; bilibiliProvider: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
configPath: string;
logger: Logger; logger: Logger;
cookieStore?: CookieStore; cookieStore?: CookieStore;
staticDir?: string; staticDir?: string;
@@ -41,7 +42,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use( app.use(
"/api/bot", "/api/bot",
createBotRouter(options.botManager, options.config, logger) createBotRouter(options.botManager, options.config, options.configPath, logger)
); );
app.use( app.use(
"/api/music", "/api/music",
Regular → Executable
+43
View File
@@ -371,6 +371,32 @@
</div> </div>
</div> </div>
</section> </section>
<!-- Idle Timeout -->
<section class="settings-section">
<h2 class="section-title">行为设置</h2>
<div class="setting-row">
<div class="setting-label">
<Icon icon="mdi:timer-off-outline" class="setting-icon" />
<div>
<div>闲置自动退出</div>
<div style="font-size:12px; opacity:0.6; margin-top:2px">频道无人时,机器人自动断开的等待时间(0 = 不退出)</div>
</div>
</div>
<div class="prefix-input-wrap">
<input
v-model.number="idleTimeout"
type="number"
min="0"
class="input input-sm"
style="max-width:80px"
placeholder="0"
/>
<span style="font-size:13px; opacity:0.7">分钟</span>
<button class="btn-primary" @click="saveIdleTimeout">保存</button>
</div>
</div>
</section>
</div> </div>
</template> </template>
@@ -661,10 +687,27 @@ async function savePrefix() {
// Prefix is saved client-side for now // Prefix is saved client-side for now
} }
// Idle timeout
const idleTimeout = ref(0);
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
} catch { /* ignore */ }
}
async function saveIdleTimeout() {
try {
await axios.post('/api/bot/settings', { idleTimeoutMinutes: idleTimeout.value });
} catch { /* ignore */ }
}
onMounted(() => { onMounted(() => {
store.fetchBots(); // Refresh bot status on page visit store.fetchBots(); // Refresh bot status on page visit
checkAuthStatus(); checkAuthStatus();
loadQuality(); loadQuality();
loadIdleTimeout();
}); });
onUnmounted(() => { onUnmounted(() => {