@grinev/opencode-telegram-bot 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -69,6 +69,9 @@ OPENCODE_MODEL_ID=big-pickle
69
69
  # Hide tool call service messages (default: false)
70
70
  # HIDE_TOOL_CALL_MESSAGES=false
71
71
 
72
+ # Hide tool file edit documents sent as .txt attachments (default: false)
73
+ # HIDE_TOOL_FILE_MESSAGES=false
74
+
72
75
  # Assistant message formatting mode (default: markdown)
73
76
  # markdown = convert assistant replies to Telegram MarkdownV2
74
77
  # raw = show assistant replies as plain text
package/README.md CHANGED
@@ -73,7 +73,7 @@ opencode serve
73
73
  The fastest way — run directly with `npx`:
74
74
 
75
75
  ```bash
76
- npx @grinev/opencode-telegram-bot
76
+ npx @grinev/opencode-telegram-bot@latest
77
77
  ```
78
78
 
79
79
  > 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,20 @@ npm install -g @grinev/opencode-telegram-bot
87
87
  opencode-telegram start
88
88
  ```
89
89
 
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
+
102
+ For Linux `systemd` setup, see [`docs/LINUX_SYSTEMD_SETUP.md`](./docs/LINUX_SYSTEMD_SETUP.md).
103
+
90
104
  To reconfigure at any time:
91
105
 
92
106
  ```bash
@@ -170,6 +184,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
170
184
  | `SERVICE_MESSAGES_INTERVAL_SEC` | Service messages interval (thinking + tool calls); keep `>=2` to avoid Telegram rate limits, `0` = immediate | No | `5` |
171
185
  | `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
172
186
  | `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
187
+ | `HIDE_TOOL_FILE_MESSAGES` | Hide file edit documents sent as `.txt` attachments (`edit_*.txt`, `write_*.txt`) | No | `false` |
173
188
  | `RESPONSE_STREAMING` | Stream assistant replies while they are generated across one or more Telegram messages | No | `true` |
174
189
  | `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
175
190
  | `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
@@ -1,5 +1,6 @@
1
+ import fs from "node:fs/promises";
1
2
  import { readFile } from "node:fs/promises";
2
- import { createBot } from "../bot/index.js";
3
+ import { cleanupBotRuntime, createBot } from "../bot/index.js";
3
4
  import { config } from "../config.js";
4
5
  import { loadSettings } from "../settings/manager.js";
5
6
  import { processManager } from "../process/manager.js";
@@ -8,7 +9,10 @@ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
8
9
  import { reconcileStoredModelSelection } from "../model/manager.js";
9
10
  import { getRuntimeMode } from "../runtime/mode.js";
10
11
  import { getRuntimePaths } from "../runtime/paths.js";
12
+ import { clearServiceStateFile } from "../service/manager.js";
13
+ import { getServiceStateFilePathFromEnv, isServiceChildProcess } from "../service/runtime.js";
11
14
  import { getLogFilePath, initializeLogger, logger } from "../utils/logger.js";
15
+ const SHUTDOWN_TIMEOUT_MS = 5000;
12
16
  async function getBotVersion() {
13
17
  try {
14
18
  const packageJsonPath = new URL("../../package.json", import.meta.url);
@@ -40,15 +44,81 @@ export async function startBotApp() {
40
44
  await warmupSessionDirectoryCache();
41
45
  const bot = createBot();
42
46
  await scheduledTaskRuntime.initialize(bot);
47
+ let shutdownStarted = false;
48
+ let serviceStateCleared = false;
49
+ let shutdownTimeout = null;
50
+ const clearManagedServiceState = async () => {
51
+ if (!isServiceChildProcess() || serviceStateCleared) {
52
+ return;
53
+ }
54
+ const stateFilePath = getServiceStateFilePathFromEnv();
55
+ if (!stateFilePath) {
56
+ return;
57
+ }
58
+ try {
59
+ await fs.access(stateFilePath);
60
+ }
61
+ catch (error) {
62
+ if (error.code === "ENOENT") {
63
+ serviceStateCleared = true;
64
+ return;
65
+ }
66
+ throw error;
67
+ }
68
+ await clearServiceStateFile(stateFilePath);
69
+ serviceStateCleared = true;
70
+ };
71
+ const shutdown = (signal) => {
72
+ if (shutdownStarted) {
73
+ return;
74
+ }
75
+ shutdownStarted = true;
76
+ logger.info(`[App] Received ${signal}, shutting down...`);
77
+ cleanupBotRuntime(`app_shutdown_${signal.toLowerCase()}`);
78
+ scheduledTaskRuntime.shutdown();
79
+ shutdownTimeout = setTimeout(() => {
80
+ logger.warn(`[App] Shutdown did not finish in ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit.`);
81
+ process.exit(0);
82
+ }, SHUTDOWN_TIMEOUT_MS);
83
+ shutdownTimeout.unref?.();
84
+ try {
85
+ bot.stop();
86
+ }
87
+ catch (error) {
88
+ logger.warn("[App] Failed to stop Telegram bot cleanly", error);
89
+ }
90
+ void clearManagedServiceState().catch((error) => {
91
+ logger.warn("[App] Failed to clear managed service state", error);
92
+ });
93
+ };
94
+ const handleSigint = () => shutdown("SIGINT");
95
+ const handleSigterm = () => shutdown("SIGTERM");
96
+ process.on("SIGINT", handleSigint);
97
+ process.on("SIGTERM", handleSigterm);
43
98
  const webhookInfo = await bot.api.getWebhookInfo();
44
99
  if (webhookInfo.url) {
45
100
  logger.info(`[Bot] Webhook detected: ${webhookInfo.url}, removing...`);
46
101
  await bot.api.deleteWebhook();
47
102
  logger.info("[Bot] Webhook removed, switching to long polling");
48
103
  }
49
- await bot.start({
50
- onStart: (botInfo) => {
51
- logger.info(`Bot @${botInfo.username} started!`);
52
- },
53
- });
104
+ try {
105
+ await bot.start({
106
+ onStart: (botInfo) => {
107
+ logger.info(`Bot @${botInfo.username} started!`);
108
+ },
109
+ });
110
+ }
111
+ finally {
112
+ process.off("SIGINT", handleSigint);
113
+ process.off("SIGTERM", handleSigterm);
114
+ if (shutdownTimeout) {
115
+ clearTimeout(shutdownTimeout);
116
+ shutdownTimeout = null;
117
+ }
118
+ cleanupBotRuntime("app_shutdown_complete");
119
+ scheduledTaskRuntime.shutdown();
120
+ await clearManagedServiceState().catch((error) => {
121
+ logger.warn("[App] Failed to clear managed service state", error);
122
+ });
123
+ }
54
124
  }
package/dist/bot/index.js CHANGED
@@ -36,7 +36,7 @@ import { questionManager } from "../question/manager.js";
36
36
  import { interactionManager } from "../interaction/manager.js";
37
37
  import { clearAllInteractionState } from "../interaction/cleanup.js";
38
38
  import { keyboardManager } from "../keyboard/manager.js";
39
- import { subscribeToEvents } from "../opencode/events.js";
39
+ import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
40
40
  import { summaryAggregator } from "../summary/aggregator.js";
41
41
  import { formatToolInfo } from "../summary/formatter.js";
42
42
  import { renderSubagentCards } from "../summary/subagent-formatter.js";
@@ -68,6 +68,7 @@ import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload
68
68
  let botInstance = null;
69
69
  let chatIdInstance = null;
70
70
  let commandsInitialized = false;
71
+ let heartbeatTimer = null;
71
72
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
72
73
  const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
73
74
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
@@ -442,6 +443,9 @@ async function ensureEventSubscription(directory) {
442
443
  if (!currentSession || currentSession.id !== fileInfo.sessionId) {
443
444
  return;
444
445
  }
446
+ if (config.bot.hideToolFileMessages) {
447
+ return;
448
+ }
445
449
  try {
446
450
  await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
447
451
  const toolMessage = formatToolInfo(fileInfo);
@@ -718,6 +722,10 @@ export function createBot() {
718
722
  clearAllInteractionState("bot_startup");
719
723
  sessionCompletionTasks.clear();
720
724
  assistantRunState.clearAll("bot_startup");
725
+ if (heartbeatTimer) {
726
+ clearInterval(heartbeatTimer);
727
+ heartbeatTimer = null;
728
+ }
721
729
  const botOptions = {};
722
730
  if (config.telegram.proxyUrl) {
723
731
  const proxyUrl = config.telegram.proxyUrl;
@@ -740,7 +748,7 @@ export function createBot() {
740
748
  const bot = new Bot(config.telegram.token, botOptions);
741
749
  // Heartbeat for diagnostics: verify the event loop is not blocked
742
750
  let heartbeatCounter = 0;
743
- setInterval(() => {
751
+ heartbeatTimer = setInterval(() => {
744
752
  heartbeatCounter++;
745
753
  if (heartbeatCounter % 6 === 0) {
746
754
  // Log every 30 seconds (5 sec * 6)
@@ -1053,3 +1061,18 @@ export function createBot() {
1053
1061
  });
1054
1062
  return bot;
1055
1063
  }
1064
+ export function cleanupBotRuntime(reason) {
1065
+ stopEventListening();
1066
+ summaryAggregator.clear();
1067
+ responseStreamer.clearAll(reason);
1068
+ toolCallStreamer.clearAll(reason);
1069
+ toolMessageBatcher.clearAll(reason);
1070
+ sessionCompletionTasks.clear();
1071
+ assistantRunState.clearAll(reason);
1072
+ if (heartbeatTimer) {
1073
+ clearInterval(heartbeatTimer);
1074
+ heartbeatTimer = null;
1075
+ }
1076
+ botInstance = null;
1077
+ chatIdInstance = null;
1078
+ }
package/dist/cli/args.js CHANGED
@@ -1,4 +1,11 @@
1
- import { t } from "../i18n/index.js";
1
+ const CLI_MESSAGES = {
2
+ unknownCommand: (value) => `Unknown command: ${value}`,
3
+ modeRequiresValue: "Option --mode requires a value: sources|installed",
4
+ invalidMode: (value) => `Invalid mode value: ${value}. Expected sources|installed`,
5
+ unknownOption: (value) => `Unknown option: ${value}`,
6
+ modeOnlyStart: "Option --mode is supported only for the start command",
7
+ daemonOnlyStart: "Option --daemon is supported only for the start command",
8
+ };
2
9
  const SUPPORTED_COMMANDS = ["start", "status", "stop", "config"];
3
10
  function isCliCommand(value) {
4
11
  return SUPPORTED_COMMANDS.includes(value);
@@ -16,6 +23,7 @@ export function parseCliArgs(argv) {
16
23
  const args = [...argv];
17
24
  let command = "start";
18
25
  let mode;
26
+ let daemon = false;
19
27
  let showHelp = false;
20
28
  let currentIndex = 0;
21
29
  const firstArg = args[0];
@@ -23,8 +31,9 @@ export function parseCliArgs(argv) {
23
31
  if (!isCliCommand(firstArg)) {
24
32
  return {
25
33
  command,
34
+ daemon,
26
35
  showHelp: true,
27
- error: t("cli.args.unknown_command", { value: firstArg }),
36
+ error: CLI_MESSAGES.unknownCommand(firstArg),
28
37
  };
29
38
  }
30
39
  command = firstArg;
@@ -37,23 +46,30 @@ export function parseCliArgs(argv) {
37
46
  currentIndex += 1;
38
47
  continue;
39
48
  }
49
+ if (token === "--daemon") {
50
+ daemon = true;
51
+ currentIndex += 1;
52
+ continue;
53
+ }
40
54
  if (token === "--mode") {
41
55
  const modeValue = args[currentIndex + 1];
42
56
  if (!modeValue || modeValue.startsWith("-")) {
43
57
  return {
44
58
  command,
59
+ daemon,
45
60
  mode,
46
61
  showHelp: true,
47
- error: t("cli.args.mode_requires_value"),
62
+ error: CLI_MESSAGES.modeRequiresValue,
48
63
  };
49
64
  }
50
65
  const parsedMode = normalizeMode(modeValue);
51
66
  if (!parsedMode) {
52
67
  return {
53
68
  command,
69
+ daemon,
54
70
  mode,
55
71
  showHelp: true,
56
- error: t("cli.args.invalid_mode", { value: modeValue }),
72
+ error: CLI_MESSAGES.invalidMode(modeValue),
57
73
  };
58
74
  }
59
75
  mode = parsedMode;
@@ -66,9 +82,10 @@ export function parseCliArgs(argv) {
66
82
  if (!parsedMode) {
67
83
  return {
68
84
  command,
85
+ daemon,
69
86
  mode,
70
87
  showHelp: true,
71
- error: t("cli.args.invalid_mode", { value: modeValue }),
88
+ error: CLI_MESSAGES.invalidMode(modeValue),
72
89
  };
73
90
  }
74
91
  mode = parsedMode;
@@ -77,21 +94,33 @@ export function parseCliArgs(argv) {
77
94
  }
78
95
  return {
79
96
  command,
97
+ daemon,
80
98
  mode,
81
99
  showHelp: true,
82
- error: t("cli.args.unknown_option", { value: token }),
100
+ error: CLI_MESSAGES.unknownOption(token),
83
101
  };
84
102
  }
85
103
  if (command !== "start" && mode) {
86
104
  return {
87
105
  command,
106
+ daemon,
107
+ mode,
108
+ showHelp: true,
109
+ error: CLI_MESSAGES.modeOnlyStart,
110
+ };
111
+ }
112
+ if (command !== "start" && daemon) {
113
+ return {
114
+ command,
115
+ daemon,
88
116
  mode,
89
117
  showHelp: true,
90
- error: t("cli.args.mode_only_start"),
118
+ error: CLI_MESSAGES.daemonOnlyStart,
91
119
  };
92
120
  }
93
121
  return {
94
122
  command,
123
+ daemon,
95
124
  mode,
96
125
  showHelp,
97
126
  };
package/dist/cli.js CHANGED
@@ -1,10 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseCliArgs } from "./cli/args.js";
3
3
  import { resolveRuntimeMode, setRuntimeMode } from "./runtime/mode.js";
4
- import { t } from "./i18n/index.js";
4
+ import { getRuntimePaths } from "./runtime/paths.js";
5
5
  const EXIT_SUCCESS = 0;
6
6
  const EXIT_RUNTIME_ERROR = 1;
7
7
  const EXIT_INVALID_ARGS = 2;
8
+ const CLI_USAGE = `Usage:
9
+ opencode-telegram [start] [--daemon] [--mode installed]
10
+ opencode-telegram status
11
+ opencode-telegram stop
12
+ opencode-telegram config
13
+
14
+ Notes:
15
+ - No command defaults to start
16
+ - start runs in foreground by default
17
+ - --daemon is supported for the installed runtime only`;
18
+ const CLI_MESSAGES = {
19
+ daemonRequiresInstalled: "Daemon mode is supported only for the installed runtime. Use `opencode-telegram start` for foreground source runs.",
20
+ unknownServiceError: "Unknown service error.",
21
+ cleanupStale: "Removed stale daemon state file.",
22
+ cleanupInvalid: "Removed invalid daemon state file.",
23
+ startSuccess: "OpenCode Telegram Bot daemon started.",
24
+ startAlreadyRunning: "OpenCode Telegram Bot daemon is already running.",
25
+ statusRunning: "Service status: running",
26
+ statusStopped: "Service status: stopped",
27
+ stopSuccess: "OpenCode Telegram Bot daemon stopped.",
28
+ stopAlreadyStopped: "OpenCode Telegram Bot daemon is not running.",
29
+ linePid: (pid) => `PID: ${pid}`,
30
+ lineStartedAt: (startedAt) => `Started at: ${startedAt}`,
31
+ lineUptimeSec: (seconds) => `Uptime: ${seconds} sec`,
32
+ lineLogFile: (filePath) => `Log file: ${filePath}`,
33
+ lineAppHome: (appHome) => `App home: ${appHome}`,
34
+ errorPrefix: (message) => `CLI error: ${message}`,
35
+ };
8
36
  function writeStdout(message) {
9
37
  process.stdout.write(`${message}\n`);
10
38
  }
@@ -12,18 +40,33 @@ function writeStderr(message) {
12
40
  process.stderr.write(`${message}\n`);
13
41
  }
14
42
  function printUsage() {
15
- writeStdout(t("cli.usage"));
43
+ writeStdout(CLI_USAGE);
16
44
  }
17
- function getPlaceholderMessage(command) {
18
- if (command === "status") {
19
- return t("cli.placeholder.status");
45
+ function formatServiceCleanupMessage(cleanupReason) {
46
+ if (cleanupReason === "stale") {
47
+ return CLI_MESSAGES.cleanupStale;
20
48
  }
21
- if (command === "stop") {
22
- return t("cli.placeholder.stop");
49
+ if (cleanupReason === "invalid") {
50
+ return CLI_MESSAGES.cleanupInvalid;
23
51
  }
24
- return t("cli.placeholder.unavailable");
52
+ return null;
25
53
  }
26
- async function runStartCommand(mode) {
54
+ function formatServiceDetails(details) {
55
+ const uptimeSec = Math.max(0, Math.floor((Date.now() - Date.parse(details.startedAt)) / 1000));
56
+ return [
57
+ CLI_MESSAGES.linePid(details.pid),
58
+ CLI_MESSAGES.lineStartedAt(details.startedAt),
59
+ CLI_MESSAGES.lineUptimeSec(uptimeSec),
60
+ CLI_MESSAGES.lineLogFile(details.logFilePath),
61
+ CLI_MESSAGES.lineAppHome(details.appHome),
62
+ ].join("\n");
63
+ }
64
+ function writeOptionalLine(message) {
65
+ if (message) {
66
+ writeStdout(message);
67
+ }
68
+ }
69
+ async function runStartCommand(mode, daemon) {
27
70
  const modeResult = resolveRuntimeMode({
28
71
  defaultMode: "installed",
29
72
  explicitMode: mode,
@@ -32,10 +75,46 @@ async function runStartCommand(mode) {
32
75
  throw new Error(modeResult.error);
33
76
  }
34
77
  setRuntimeMode(modeResult.mode);
35
- const { initializeLogger } = await import("./utils/logger.js");
36
- await initializeLogger();
78
+ if (daemon && modeResult.mode !== "installed") {
79
+ throw new Error(CLI_MESSAGES.daemonRequiresInstalled);
80
+ }
37
81
  const { ensureRuntimeConfigForStart } = await import("./runtime/bootstrap.js");
38
82
  await ensureRuntimeConfigForStart();
83
+ if (daemon) {
84
+ const { startBotDaemon } = await import("./service/manager.js");
85
+ const result = await startBotDaemon(modeResult.mode);
86
+ const cleanupMessage = formatServiceCleanupMessage(result.cleanupReason);
87
+ const runtimePaths = getRuntimePaths();
88
+ writeOptionalLine(cleanupMessage);
89
+ if (!result.success) {
90
+ if (result.alreadyRunning && result.service) {
91
+ writeStdout(CLI_MESSAGES.startAlreadyRunning);
92
+ writeStdout("");
93
+ writeStdout(formatServiceDetails({
94
+ pid: result.service.pid,
95
+ startedAt: result.service.startedAt,
96
+ logFilePath: result.service.logFilePath,
97
+ appHome: runtimePaths.appHome,
98
+ }));
99
+ return EXIT_SUCCESS;
100
+ }
101
+ throw new Error(result.error || CLI_MESSAGES.unknownServiceError);
102
+ }
103
+ if (!result.service) {
104
+ throw new Error(CLI_MESSAGES.unknownServiceError);
105
+ }
106
+ writeStdout(CLI_MESSAGES.startSuccess);
107
+ writeStdout("");
108
+ writeStdout(formatServiceDetails({
109
+ pid: result.service.pid,
110
+ startedAt: result.service.startedAt,
111
+ logFilePath: result.service.logFilePath,
112
+ appHome: runtimePaths.appHome,
113
+ }));
114
+ return EXIT_SUCCESS;
115
+ }
116
+ const { initializeLogger } = await import("./utils/logger.js");
117
+ await initializeLogger();
39
118
  const { startBotApp } = await import("./app/start-bot-app.js");
40
119
  await startBotApp();
41
120
  return EXIT_SUCCESS;
@@ -48,8 +127,40 @@ async function runConfigCommand() {
48
127
  await runConfigWizardCommand();
49
128
  return EXIT_SUCCESS;
50
129
  }
51
- async function runPlaceholderCommand(command) {
52
- writeStdout(getPlaceholderMessage(command));
130
+ async function runStatusCommand() {
131
+ setRuntimeMode("installed");
132
+ const { getBotServiceStatus } = await import("./service/manager.js");
133
+ const runtimePaths = getRuntimePaths();
134
+ const status = await getBotServiceStatus();
135
+ writeOptionalLine(formatServiceCleanupMessage(status.cleanupReason));
136
+ if (status.status !== "running" || !status.service) {
137
+ writeStdout(CLI_MESSAGES.statusStopped);
138
+ writeStdout(CLI_MESSAGES.lineAppHome(runtimePaths.appHome));
139
+ return EXIT_SUCCESS;
140
+ }
141
+ writeStdout(CLI_MESSAGES.statusRunning);
142
+ writeStdout("");
143
+ writeStdout(formatServiceDetails({
144
+ pid: status.service.pid,
145
+ startedAt: status.service.startedAt,
146
+ logFilePath: status.service.logFilePath,
147
+ appHome: runtimePaths.appHome,
148
+ }));
149
+ return EXIT_SUCCESS;
150
+ }
151
+ async function runStopCommand() {
152
+ setRuntimeMode("installed");
153
+ const { stopBotDaemon } = await import("./service/manager.js");
154
+ const result = await stopBotDaemon();
155
+ writeOptionalLine(formatServiceCleanupMessage(result.cleanupReason));
156
+ if (!result.success) {
157
+ throw new Error(result.error || CLI_MESSAGES.unknownServiceError);
158
+ }
159
+ if (result.alreadyStopped) {
160
+ writeStdout(CLI_MESSAGES.stopAlreadyStopped);
161
+ return EXIT_SUCCESS;
162
+ }
163
+ writeStdout(CLI_MESSAGES.stopSuccess);
53
164
  return EXIT_SUCCESS;
54
165
  }
55
166
  async function runCli(argv) {
@@ -62,12 +173,15 @@ async function runCli(argv) {
62
173
  return parsedArgs.error ? EXIT_INVALID_ARGS : EXIT_SUCCESS;
63
174
  }
64
175
  if (parsedArgs.command === "start") {
65
- return runStartCommand(parsedArgs.mode);
176
+ return runStartCommand(parsedArgs.mode, parsedArgs.daemon);
66
177
  }
67
178
  if (parsedArgs.command === "config") {
68
179
  return runConfigCommand();
69
180
  }
70
- return runPlaceholderCommand(parsedArgs.command);
181
+ if (parsedArgs.command === "status") {
182
+ return runStatusCommand();
183
+ }
184
+ return runStopCommand();
71
185
  }
72
186
  void runCli(process.argv.slice(2))
73
187
  .then((exitCode) => {
@@ -75,10 +189,10 @@ void runCli(process.argv.slice(2))
75
189
  })
76
190
  .catch((error) => {
77
191
  if (error instanceof Error) {
78
- writeStderr(t("cli.error.prefix", { message: error.message }));
192
+ writeStderr(CLI_MESSAGES.errorPrefix(error.message));
79
193
  }
80
194
  else {
81
- writeStderr(t("cli.error.prefix", { message: String(error) }));
195
+ writeStderr(CLI_MESSAGES.errorPrefix(String(error)));
82
196
  }
83
197
  process.exitCode = EXIT_RUNTIME_ERROR;
84
198
  });
package/dist/config.js CHANGED
@@ -78,6 +78,7 @@ export const config = {
78
78
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
79
79
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
80
80
  hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
81
+ hideToolFileMessages: getOptionalBooleanEnvVar("HIDE_TOOL_FILE_MESSAGES", false),
81
82
  messageFormatMode: getOptionalMessageFormatModeEnvVar("MESSAGE_FORMAT_MODE", "markdown"),
82
83
  },
83
84
  files: {
package/dist/i18n/de.js CHANGED
@@ -324,16 +324,6 @@ export const de = {
324
324
  "commands.page_empty_callback": "Keine Befehle auf dieser Seite",
325
325
  "commands.page_load_error_callback": "Diese Seite konnte nicht geladen werden. Bitte versuche es erneut.",
326
326
  "cmd.description.rename": "Aktuelle Sitzung umbenennen",
327
- "cli.usage": "Verwendung:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nHinweise:\n - Ohne Befehl wird standardmäßig `start` verwendet\n - `--mode` wird derzeit nur für `start` unterstützt",
328
- "cli.placeholder.status": "Befehl `status` ist derzeit ein Platzhalter. Echte Statusprüfungen werden in der Service-Schicht hinzugefügt (Phase 5).",
329
- "cli.placeholder.stop": "Befehl `stop` ist derzeit ein Platzhalter. Ein echter Stop des Hintergrundprozesses wird in der Service-Schicht hinzugefügt (Phase 5).",
330
- "cli.placeholder.unavailable": "Befehl ist nicht verfügbar.",
331
- "cli.error.prefix": "CLI-Fehler: {message}",
332
- "cli.args.unknown_command": "Unbekannter Befehl: {value}",
333
- "cli.args.mode_requires_value": "Option --mode erfordert einen Wert: sources|installed",
334
- "cli.args.invalid_mode": "Ungültiger Wert für --mode: {value}. Erwartet sources|installed",
335
- "cli.args.unknown_option": "Unbekannte Option: {value}",
336
- "cli.args.mode_only_start": "Option --mode wird nur für den start-Befehl unterstützt",
337
327
  "legacy.models.fetch_error": "🔴 Modellliste konnte nicht geladen werden. Prüfe den Serverstatus mit /status.",
338
328
  "legacy.models.empty": "📋 Keine verfügbaren Modelle. Konfiguriere Provider in OpenCode.",
339
329
  "legacy.models.header": "📋 Verfügbare Modelle:\n\n",
package/dist/i18n/en.js CHANGED
@@ -324,16 +324,6 @@ export const en = {
324
324
  "commands.page_empty_callback": "No commands on this page",
325
325
  "commands.page_load_error_callback": "Cannot load this page. Please try again.",
326
326
  "cmd.description.rename": "Rename current session",
327
- "cli.usage": "Usage:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes:\n - No command defaults to `start`\n - `--mode` is currently supported for `start` only",
328
- "cli.placeholder.status": "Command `status` is currently a placeholder. Real status checks will be added in service layer (Phase 5).",
329
- "cli.placeholder.stop": "Command `stop` is currently a placeholder. Real background process stop will be added in service layer (Phase 5).",
330
- "cli.placeholder.unavailable": "Command is unavailable.",
331
- "cli.error.prefix": "CLI error: {message}",
332
- "cli.args.unknown_command": "Unknown command: {value}",
333
- "cli.args.mode_requires_value": "Option --mode requires a value: sources|installed",
334
- "cli.args.invalid_mode": "Invalid mode value: {value}. Expected sources|installed",
335
- "cli.args.unknown_option": "Unknown option: {value}",
336
- "cli.args.mode_only_start": "Option --mode is supported only for the start command",
337
327
  "legacy.models.fetch_error": "🔴 Failed to get models list. Check server status with /status.",
338
328
  "legacy.models.empty": "📋 No available models. Configure providers in OpenCode.",
339
329
  "legacy.models.header": "📋 Available models:\n\n",
package/dist/i18n/es.js CHANGED
@@ -324,16 +324,6 @@ export const es = {
324
324
  "commands.page_empty_callback": "No hay comandos en esta página",
325
325
  "commands.page_load_error_callback": "No se pudo cargar esta página. Por favor, inténtalo de nuevo.",
326
326
  "cmd.description.rename": "Renombrar la sesión actual",
327
- "cli.usage": "Uso:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotas:\n - Sin comando, el valor por defecto es `start`\n - `--mode` actualmente solo se admite para `start`",
328
- "cli.placeholder.status": "El comando `status` es actualmente un marcador de posición. Las comprobaciones reales de estado se agregarán en la capa de servicio (Fase 5).",
329
- "cli.placeholder.stop": "El comando `stop` es actualmente un marcador de posición. La detención real del proceso en segundo plano se agregará en la capa de servicio (Fase 5).",
330
- "cli.placeholder.unavailable": "El comando no esta disponible.",
331
- "cli.error.prefix": "Error de CLI: {message}",
332
- "cli.args.unknown_command": "Comando desconocido: {value}",
333
- "cli.args.mode_requires_value": "La opción --mode requiere un valor: sources|installed",
334
- "cli.args.invalid_mode": "Valor de --mode inválido: {value}. Se espera sources|installed",
335
- "cli.args.unknown_option": "Opción desconocida: {value}",
336
- "cli.args.mode_only_start": "La opción --mode solo se admite para el comando start",
337
327
  "legacy.models.fetch_error": "🔴 No se pudo obtener la lista de modelos. Revisa el estado del servidor con /status.",
338
328
  "legacy.models.empty": "📋 No hay modelos disponibles. Configura los proveedores en OpenCode.",
339
329
  "legacy.models.header": "📋 Modelos disponibles:\n\n",
package/dist/i18n/fr.js CHANGED
@@ -324,16 +324,6 @@ export const fr = {
324
324
  "commands.page_empty_callback": "Aucune commande sur cette page",
325
325
  "commands.page_load_error_callback": "Impossible de charger cette page. Veuillez réessayer.",
326
326
  "cmd.description.rename": "Renommer la session actuelle",
327
- "cli.usage": "Utilisation :\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes :\n - Sans commande, `start` est utilisé par défaut\n - `--mode` n'est actuellement pris en charge que pour `start`",
328
- "cli.placeholder.status": "La commande `status` est actuellement un placeholder. Les vraies vérifications d'état seront ajoutées dans la couche service (Phase 5).",
329
- "cli.placeholder.stop": "La commande `stop` est actuellement un placeholder. Le véritable arrêt du processus en arrière-plan sera ajouté dans la couche service (Phase 5).",
330
- "cli.placeholder.unavailable": "Commande indisponible.",
331
- "cli.error.prefix": "Erreur CLI : {message}",
332
- "cli.args.unknown_command": "Commande inconnue : {value}",
333
- "cli.args.mode_requires_value": "L'option --mode nécessite une valeur : sources|installed",
334
- "cli.args.invalid_mode": "Valeur de mode invalide : {value}. Attendu : sources|installed",
335
- "cli.args.unknown_option": "Option inconnue : {value}",
336
- "cli.args.mode_only_start": "L'option --mode est prise en charge uniquement pour la commande start",
337
327
  "legacy.models.fetch_error": "🔴 Impossible de récupérer la liste des modèles. Vérifiez l'état du serveur avec /status.",
338
328
  "legacy.models.empty": "📋 Aucun modèle disponible. Configurez les fournisseurs dans OpenCode.",
339
329
  "legacy.models.header": "📋 Modèles disponibles :\n\n",
package/dist/i18n/ru.js CHANGED
@@ -324,16 +324,6 @@ export const ru = {
324
324
  "commands.page_empty_callback": "На этой странице нет команд",
325
325
  "commands.page_load_error_callback": "Не удалось загрузить эту страницу. Пожалуйста, попробуйте снова.",
326
326
  "cmd.description.rename": "Переименовать текущую сессию",
327
- "cli.usage": "Использование:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nЗаметки:\n - Без команды по умолчанию используется `start`\n - `--mode` сейчас поддерживается только для `start`",
328
- "cli.placeholder.status": "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).",
329
- "cli.placeholder.stop": "Команда `stop` пока работает как заглушка. Реальная остановка фонового процесса появится на этапе service-слоя (Этап 5).",
330
- "cli.placeholder.unavailable": "Команда недоступна.",
331
- "cli.error.prefix": "CLI error: {message}",
332
- "cli.args.unknown_command": "Неизвестная команда: {value}",
333
- "cli.args.mode_requires_value": "Опция --mode требует значение: sources|installed",
334
- "cli.args.invalid_mode": "Некорректное значение --mode: {value}. Ожидается sources|installed",
335
- "cli.args.unknown_option": "Неизвестная опция: {value}",
336
- "cli.args.mode_only_start": "Опция --mode поддерживается только для команды start",
337
327
  "legacy.models.fetch_error": "🔴 Не удалось получить список моделей. Проверьте статус сервера /status.",
338
328
  "legacy.models.empty": "📋 Нет доступных моделей. Настройте провайдеры через OpenCode.",
339
329
  "legacy.models.header": "📋 Доступные модели:\n\n",
package/dist/i18n/zh.js CHANGED
@@ -324,16 +324,6 @@ export const zh = {
324
324
  "commands.page_empty_callback": "这一页没有命令",
325
325
  "commands.page_load_error_callback": "无法加载此页面。请重试。",
326
326
  "cmd.description.rename": "重命名当前会话",
327
- "cli.usage": "用法:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\n注意:\n - 无命令时默认为 `start`\n - `--mode` 当前仅支持 `start`",
328
- "cli.placeholder.status": "`status` 命令当前为占位符。实际状态检查将在服务层中添加(第5阶段)。",
329
- "cli.placeholder.stop": "`stop` 命令当前为占位符。实际后台进程停止功能将在服务层中添加(第5阶段)。",
330
- "cli.placeholder.unavailable": "命令不可用。",
331
- "cli.error.prefix": "CLI 错误:{message}",
332
- "cli.args.unknown_command": "未知命令:{value}",
333
- "cli.args.mode_requires_value": "选项 --mode 需要一个值:sources|installed",
334
- "cli.args.invalid_mode": "无效的 --mode 值:{value}。期望 sources|installed",
335
- "cli.args.unknown_option": "未知选项:{value}",
336
- "cli.args.mode_only_start": "选项 --mode 仅支持 start 命令",
337
327
  "legacy.models.fetch_error": "🔴 获取模型列表失败。请使用 /status 检查服务器状态。",
338
328
  "legacy.models.empty": "📋 没有可用模型。请在 OpenCode 中配置 providers。",
339
329
  "legacy.models.header": "📋 可用模型:\n\n",
@@ -122,6 +122,14 @@ export class ScheduledTaskRuntime {
122
122
  this.flushInProgress = false;
123
123
  }
124
124
  }
125
+ shutdown() {
126
+ for (const timer of this.timersByTaskId.values()) {
127
+ clearTimeout(timer);
128
+ }
129
+ this.timersByTaskId.clear();
130
+ this.runningTaskIds.clear();
131
+ this.initialized = false;
132
+ }
125
133
  __resetForTests() {
126
134
  for (const timer of this.timersByTaskId.values()) {
127
135
  clearTimeout(timer);
@@ -0,0 +1,244 @@
1
+ import fs from "node:fs";
2
+ import fsPromises from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { exec, spawn } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { getRuntimePaths } from "../runtime/paths.js";
7
+ import { buildServiceChildEnv } from "./runtime.js";
8
+ const execAsync = promisify(exec);
9
+ const SERVICE_STATE_FILE_NAME = "bot-service.json";
10
+ const PROCESS_EXIT_POLL_MS = 100;
11
+ const DEFAULT_STOP_TIMEOUT_MS = 5000;
12
+ function sanitizeTimestampForFile(timestamp) {
13
+ return timestamp.replace(/:/g, "-").replace("T", "_");
14
+ }
15
+ function createServiceLogFilePath(logsDirPath) {
16
+ const timestamp = sanitizeTimestampForFile(new Date().toISOString().slice(0, 19));
17
+ return path.join(logsDirPath, `bot-service-${timestamp}.log`);
18
+ }
19
+ function isValidServiceState(value) {
20
+ if (!value || typeof value !== "object") {
21
+ return false;
22
+ }
23
+ const candidate = value;
24
+ return (typeof candidate.pid === "number" &&
25
+ Number.isInteger(candidate.pid) &&
26
+ candidate.pid > 0 &&
27
+ typeof candidate.startedAt === "string" &&
28
+ candidate.startedAt.length > 0 &&
29
+ typeof candidate.logFilePath === "string" &&
30
+ candidate.logFilePath.length > 0 &&
31
+ candidate.mode === "daemon");
32
+ }
33
+ async function writeFileAtomically(filePath, content) {
34
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
35
+ const tempFilePath = `${filePath}.${process.pid}.tmp`;
36
+ await fsPromises.writeFile(tempFilePath, content, "utf-8");
37
+ await fsPromises.rename(tempFilePath, filePath);
38
+ }
39
+ async function readServiceStateFile(filePath) {
40
+ try {
41
+ const content = await fsPromises.readFile(filePath, "utf-8");
42
+ const parsed = JSON.parse(content);
43
+ if (!isValidServiceState(parsed)) {
44
+ await clearServiceStateFile(filePath);
45
+ return { service: null, cleanupReason: "invalid" };
46
+ }
47
+ return { service: parsed, cleanupReason: null };
48
+ }
49
+ catch (error) {
50
+ if (error.code === "ENOENT") {
51
+ return { service: null, cleanupReason: null };
52
+ }
53
+ if (error instanceof SyntaxError) {
54
+ await clearServiceStateFile(filePath);
55
+ return { service: null, cleanupReason: "invalid" };
56
+ }
57
+ throw error;
58
+ }
59
+ }
60
+ function isProcessAlive(pid) {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ async function waitForProcessExit(pid, timeoutMs) {
70
+ const startTime = Date.now();
71
+ while (Date.now() - startTime < timeoutMs) {
72
+ if (!isProcessAlive(pid)) {
73
+ return true;
74
+ }
75
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_EXIT_POLL_MS));
76
+ }
77
+ return !isProcessAlive(pid);
78
+ }
79
+ function getServiceEntryScriptPath() {
80
+ const scriptPath = process.argv[1];
81
+ if (!scriptPath || scriptPath.trim().length === 0) {
82
+ throw new Error("Failed to resolve CLI entry script path.");
83
+ }
84
+ return path.resolve(scriptPath);
85
+ }
86
+ async function stopWindowsProcess(pid, timeoutMs) {
87
+ try {
88
+ await execAsync(`taskkill /PID ${pid} /T`);
89
+ }
90
+ catch {
91
+ // Continue with forced stop if the process is still alive.
92
+ }
93
+ if (await waitForProcessExit(pid, timeoutMs)) {
94
+ return;
95
+ }
96
+ await execAsync(`taskkill /F /PID ${pid} /T`);
97
+ await waitForProcessExit(pid, timeoutMs);
98
+ }
99
+ async function stopUnixProcess(pid, timeoutMs) {
100
+ process.kill(pid, "SIGTERM");
101
+ if (await waitForProcessExit(pid, timeoutMs)) {
102
+ return;
103
+ }
104
+ process.kill(pid, "SIGKILL");
105
+ await waitForProcessExit(pid, timeoutMs);
106
+ }
107
+ export function getServiceStateFilePath() {
108
+ return path.join(getRuntimePaths().runDirPath, SERVICE_STATE_FILE_NAME);
109
+ }
110
+ export async function clearServiceStateFile(filePath = getServiceStateFilePath()) {
111
+ try {
112
+ await fsPromises.unlink(filePath);
113
+ }
114
+ catch (error) {
115
+ if (error.code !== "ENOENT") {
116
+ throw error;
117
+ }
118
+ }
119
+ }
120
+ export async function getBotServiceStatus() {
121
+ const stateFilePath = getServiceStateFilePath();
122
+ const { service, cleanupReason } = await readServiceStateFile(stateFilePath);
123
+ if (!service) {
124
+ return {
125
+ status: "stopped",
126
+ service: null,
127
+ cleanupReason,
128
+ };
129
+ }
130
+ if (!isProcessAlive(service.pid)) {
131
+ await clearServiceStateFile(stateFilePath);
132
+ return {
133
+ status: "stopped",
134
+ service: null,
135
+ cleanupReason: "stale",
136
+ };
137
+ }
138
+ return {
139
+ status: "running",
140
+ service,
141
+ cleanupReason,
142
+ };
143
+ }
144
+ export async function startBotDaemon(mode) {
145
+ const currentStatus = await getBotServiceStatus();
146
+ if (currentStatus.status === "running" && currentStatus.service) {
147
+ return {
148
+ success: false,
149
+ service: currentStatus.service,
150
+ cleanupReason: currentStatus.cleanupReason,
151
+ alreadyRunning: true,
152
+ };
153
+ }
154
+ const runtimePaths = getRuntimePaths();
155
+ await Promise.all([
156
+ fsPromises.mkdir(runtimePaths.runDirPath, { recursive: true }),
157
+ fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true }),
158
+ ]);
159
+ const stateFilePath = getServiceStateFilePath();
160
+ const logFilePath = createServiceLogFilePath(runtimePaths.logsDirPath);
161
+ const logFileDescriptor = fs.openSync(logFilePath, "a");
162
+ try {
163
+ const childArgs = [getServiceEntryScriptPath(), "start"];
164
+ if (mode) {
165
+ childArgs.push("--mode", mode);
166
+ }
167
+ const childProcess = spawn(process.execPath, childArgs, {
168
+ detached: true,
169
+ stdio: ["ignore", logFileDescriptor, logFileDescriptor],
170
+ windowsHide: true,
171
+ env: buildServiceChildEnv(process.env, stateFilePath),
172
+ });
173
+ if (!childProcess.pid) {
174
+ throw new Error("Failed to start background bot process.");
175
+ }
176
+ childProcess.unref();
177
+ const serviceState = {
178
+ pid: childProcess.pid,
179
+ startedAt: new Date().toISOString(),
180
+ logFilePath,
181
+ mode: "daemon",
182
+ };
183
+ await writeFileAtomically(stateFilePath, `${JSON.stringify(serviceState, null, 2)}\n`);
184
+ return {
185
+ success: true,
186
+ service: serviceState,
187
+ cleanupReason: currentStatus.cleanupReason,
188
+ };
189
+ }
190
+ catch (error) {
191
+ await clearServiceStateFile(stateFilePath);
192
+ return {
193
+ success: false,
194
+ service: null,
195
+ cleanupReason: currentStatus.cleanupReason,
196
+ error: error instanceof Error ? error.message : String(error),
197
+ };
198
+ }
199
+ finally {
200
+ fs.closeSync(logFileDescriptor);
201
+ }
202
+ }
203
+ export async function stopBotDaemon(timeoutMs = DEFAULT_STOP_TIMEOUT_MS) {
204
+ const currentStatus = await getBotServiceStatus();
205
+ if (currentStatus.status !== "running" || !currentStatus.service) {
206
+ return {
207
+ success: true,
208
+ service: null,
209
+ cleanupReason: currentStatus.cleanupReason,
210
+ alreadyStopped: true,
211
+ };
212
+ }
213
+ const { pid } = currentStatus.service;
214
+ try {
215
+ if (process.platform === "win32") {
216
+ await stopWindowsProcess(pid, timeoutMs);
217
+ }
218
+ else {
219
+ await stopUnixProcess(pid, timeoutMs);
220
+ }
221
+ if (isProcessAlive(pid)) {
222
+ return {
223
+ success: false,
224
+ service: currentStatus.service,
225
+ cleanupReason: currentStatus.cleanupReason,
226
+ error: `Failed to stop background bot process PID=${pid}.`,
227
+ };
228
+ }
229
+ await clearServiceStateFile();
230
+ return {
231
+ success: true,
232
+ service: currentStatus.service,
233
+ cleanupReason: currentStatus.cleanupReason,
234
+ };
235
+ }
236
+ catch (error) {
237
+ return {
238
+ success: false,
239
+ service: currentStatus.service,
240
+ cleanupReason: currentStatus.cleanupReason,
241
+ error: error instanceof Error ? error.message : String(error),
242
+ };
243
+ }
244
+ }
@@ -0,0 +1,19 @@
1
+ const SERVICE_CHILD_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_CHILD";
2
+ const SERVICE_STATE_PATH_ENV_KEY = "OPENCODE_TELEGRAM_SERVICE_STATE_PATH";
3
+ export function buildServiceChildEnv(baseEnv, stateFilePath) {
4
+ return {
5
+ ...baseEnv,
6
+ [SERVICE_CHILD_ENV_KEY]: "1",
7
+ [SERVICE_STATE_PATH_ENV_KEY]: stateFilePath,
8
+ };
9
+ }
10
+ export function isServiceChildProcess() {
11
+ return process.env[SERVICE_CHILD_ENV_KEY] === "1";
12
+ }
13
+ export function getServiceStateFilePathFromEnv() {
14
+ const value = process.env[SERVICE_STATE_PATH_ENV_KEY];
15
+ if (!value || value.trim().length === 0) {
16
+ return null;
17
+ }
18
+ return value;
19
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",