Skip to content

Commit a84ce68

Browse files
committed
chore(cli): support bot daemon mode and graceful stop
1 parent 7a8e542 commit a84ce68

File tree

17 files changed

+933
-120
lines changed

17 files changed

+933
-120
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ opencode serve
7373
The fastest way — run directly with `npx`:
7474

7575
```bash
76-
npx @grinev/opencode-telegram-bot
76+
npx @grinev/opencode-telegram-bot@latest
7777
```
7878

7979
> Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
@@ -87,6 +87,18 @@ npm install -g @grinev/opencode-telegram-bot
8787
opencode-telegram start
8888
```
8989

90+
`start` runs in the foreground by default. This is the recommended mode for `systemd`, Docker, local debugging, and other external process managers.
91+
92+
To run the bot in the built-in background mode instead:
93+
94+
```bash
95+
opencode-telegram start --daemon
96+
opencode-telegram status
97+
opencode-telegram stop
98+
```
99+
100+
> Built-in daemon mode is intended for standalone npm installs without an external supervisor. For `systemd`, `pm2`, or Docker, keep using `opencode-telegram start` without `--daemon`.
101+
90102
To reconfigure at any time:
91103

92104
```bash

src/app/start-bot-app.ts

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import fs from "node:fs/promises";
12
import { readFile } from "node:fs/promises";
23

3-
import { createBot } from "../bot/index.js";
4+
import { cleanupBotRuntime, createBot } from "../bot/index.js";
45
import { config } from "../config.js";
56
import { loadSettings } from "../settings/manager.js";
67
import { processManager } from "../process/manager.js";
@@ -9,8 +10,12 @@ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
910
import { reconcileStoredModelSelection } from "../model/manager.js";
1011
import { getRuntimeMode } from "../runtime/mode.js";
1112
import { getRuntimePaths } from "../runtime/paths.js";
13+
import { clearServiceStateFile } from "../service/manager.js";
14+
import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
1215
import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
1316

17+
const SHUTDOWN_TIMEOUT_MS = 5000;
18+
1419
async function getBotVersion(): Promise<string> {
1520
try {
1621
const packageJsonPath = new URL("../../package.json", import.meta.url);
@@ -48,16 +53,91 @@ export async function startBotApp(): Promise<void> {
4853
const bot = createBot();
4954
await scheduledTaskRuntime.initialize(bot);
5055

56+
let shutdownStarted = false;
57+
let serviceStateCleared = false;
58+
let shutdownTimeout: ReturnType<typeof setTimeout> | null = null;
59+
60+
const clearManagedServiceState = async (): Promise<void> => {
61+
if (!isServiceChildProcess() || serviceStateCleared) {
62+
return;
63+
}
64+
65+
const stateFilePath = getServiceStateFilePathFromEnv();
66+
if (!stateFilePath) {
67+
return;
68+
}
69+
70+
try {
71+
await fs.access(stateFilePath);
72+
} catch (error) {
73+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
74+
serviceStateCleared = true;
75+
return;
76+
}
77+
78+
throw error;
79+
}
80+
81+
await clearServiceStateFile(stateFilePath);
82+
serviceStateCleared = true;
83+
};
84+
85+
const shutdown = (signal: NodeJS.Signals): void => {
86+
if (shutdownStarted) {
87+
return;
88+
}
89+
90+
shutdownStarted = true;
91+
logger.info(`[App] Received ${signal}, shutting down...`);
92+
cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
93+
scheduledTaskRuntime.shutdown();
94+
95+
shutdownTimeout = setTimeout(() => {
96+
logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
97+
process.exit(0);
98+
}, SHUTDOWN_TIMEOUT_MS);
99+
shutdownTimeout.unref?.();
100+
101+
try {
102+
bot.stop();
103+
} catch (error) {
104+
logger.warn("[App] Failed to stop Telegram bot cleanly", error);
105+
}
106+
107+
void clearManagedServiceState().catch((error) => {
108+
logger.warn("[App] Failed to clear managed service state", error);
109+
});
110+
};
111+
112+
const handleSigint = (): void => shutdown("SIGINT");
113+
const handleSigterm = (): void => shutdown("SIGTERM");
114+
process.on("SIGINT", handleSigint);
115+
process.on("SIGTERM", handleSigterm);
116+
51117
const webhookInfo = await bot.api.getWebhookInfo();
52118
if (webhookInfo.url) {
53119
logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
54120
await bot.api.deleteWebhook();
55121
logger.info("[Bot] Webhook removed, switching to long polling");
56122
}
57123

58-
await bot.start({
59-
onStart: (botInfo) => {
60-
logger.info(`Bot @${botInfo.username} started!`);
61-
},
62-
});
124+
try {
125+
await bot.start({
126+
onStart: (botInfo) => {
127+
logger.info(`Bot @${botInfo.username} started!`);
128+
},
129+
});
130+
} finally {
131+
process.off("SIGINT", handleSigint);
132+
process.off("SIGTERM", handleSigterm);
133+
if (shutdownTimeout) {
134+
clearTimeout(shutdownTimeout);
135+
shutdownTimeout = null;
136+
}
137+
cleanupBotRuntime("app_shutdown_complete");
138+
scheduledTaskRuntime.shutdown();
139+
await clearManagedServiceState().catch((error) => {
140+
logger.warn("[App] Failed to clear managed service state", error);
141+
});
142+
}
63143
}

src/bot/index.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import { questionManager } from "../question/manager.js";
4848
import { interactionManager } from "../interaction/manager.js";
4949
import { clearAllInteractionState } from "../interaction/cleanup.js";
5050
import { keyboardManager } from "../keyboard/manager.js";
51-
import { subscribeToEvents } from "../opencode/events.js";
51+
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
5252
import { summaryAggregator } from "../summary/aggregator.js";
5353
import { formatToolInfo } from "../summary/formatter.js";
5454
import { renderSubagentCards } from "../summary/subagent-formatter.js";
@@ -92,6 +92,7 @@ import {
9292
let botInstance: Bot<Context> | null = null;
9393
let chatIdInstance: number | null = null;
9494
let commandsInitialized = false;
95+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
9596

9697
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
9798
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
@@ -882,6 +883,11 @@ export function createBot(): Bot<Context> {
882883
sessionCompletionTasks.clear();
883884
assistantRunState.clearAll("bot_startup");
884885

886+
if (heartbeatTimer) {
887+
clearInterval(heartbeatTimer);
888+
heartbeatTimer = null;
889+
}
890+
885891
const botOptions: ConstructorParameters<typeof Bot<Context>>[1] = {};
886892

887893
if (config.telegram.proxyUrl) {
@@ -908,7 +914,7 @@ export function createBot(): Bot<Context> {
908914

909915
// Heartbeat for diagnostics: verify the event loop is not blocked
910916
let heartbeatCounter = 0;
911-
setInterval(() => {
917+
heartbeatTimer = setInterval(() => {
912918
heartbeatCounter++;
913919
if (heartbeatCounter % 6 === 0) {
914920
// Log every 30 seconds (5 sec * 6)
@@ -1289,3 +1295,21 @@ export function createBot(): Bot<Context> {
12891295

12901296
return bot;
12911297
}
1298+
1299+
export function cleanupBotRuntime(reason: string): void {
1300+
stopEventListening();
1301+
summaryAggregator.clear();
1302+
responseStreamer.clearAll(reason);
1303+
toolCallStreamer.clearAll(reason);
1304+
toolMessageBatcher.clearAll(reason);
1305+
sessionCompletionTasks.clear();
1306+
assistantRunState.clearAll(reason);
1307+
1308+
if (heartbeatTimer) {
1309+
clearInterval(heartbeatTimer);
1310+
heartbeatTimer = null;
1311+
}
1312+
1313+
botInstance = null;
1314+
chatIdInstance = null;
1315+
}

0 commit comments

Comments
 (0)