@grinev/opencode-telegram-bot 0.16.0 → 0.17.0

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.
@@ -0,0 +1,196 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { config } from "../../config.js";
3
+ import { getGitWorktreeContext } from "../../git/worktree.js";
4
+ import { clearAllInteractionState } from "../../interaction/cleanup.js";
5
+ import { getProjectByWorktree } from "../../project/manager.js";
6
+ import { upsertSessionDirectory } from "../../session/cache-manager.js";
7
+ import { getCurrentProject } from "../../settings/manager.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ import { t } from "../../i18n/index.js";
10
+ import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
11
+ import { switchToProject } from "../utils/switch-project.js";
12
+ import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
13
+ import { buildProjectButtonLabel, calculateProjectsPaginationRange } from "./projects.js";
14
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
15
+ const WORKTREE_CALLBACK_PREFIX = "worktree:";
16
+ const WORKTREE_PAGE_CALLBACK_PREFIX = "worktree:page:";
17
+ function formatWorktreeButtonLabel(label, isActive) {
18
+ const prefix = isActive ? "✅ " : "";
19
+ const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
20
+ if (label.length <= availableLength) {
21
+ return `${prefix}${label}`;
22
+ }
23
+ return `${prefix}${label.slice(0, Math.max(0, availableLength - 3))}...`;
24
+ }
25
+ function buildWorktreeButtonLabel(index, entry) {
26
+ return buildProjectButtonLabel(index, entry.path);
27
+ }
28
+ function parseWorktreePageCallback(data) {
29
+ if (!data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
30
+ return null;
31
+ }
32
+ const rawPage = data.slice(WORKTREE_PAGE_CALLBACK_PREFIX.length);
33
+ if (!/^\d+$/.test(rawPage)) {
34
+ return null;
35
+ }
36
+ return Number.parseInt(rawPage, 10);
37
+ }
38
+ function parseWorktreeIndexCallback(data) {
39
+ if (!data.startsWith(WORKTREE_CALLBACK_PREFIX) ||
40
+ data.startsWith(WORKTREE_PAGE_CALLBACK_PREFIX)) {
41
+ return null;
42
+ }
43
+ const rawIndex = data.slice(WORKTREE_CALLBACK_PREFIX.length);
44
+ if (!/^\d+$/.test(rawIndex)) {
45
+ return null;
46
+ }
47
+ return Number.parseInt(rawIndex, 10);
48
+ }
49
+ function buildWorktreeMenuText(page, totalPages) {
50
+ const baseText = t("worktree.select_with_current");
51
+ if (totalPages <= 1) {
52
+ return baseText;
53
+ }
54
+ return `${baseText}\n\n${t("projects.page_indicator", {
55
+ current: String(page + 1),
56
+ total: String(totalPages),
57
+ })}`;
58
+ }
59
+ function buildWorktreeKeyboard(worktrees, page) {
60
+ const keyboard = new InlineKeyboard();
61
+ const pageSize = config.bot.projectsListLimit;
62
+ const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(worktrees.length, page, pageSize);
63
+ worktrees.slice(startIndex, endIndex).forEach((entry, index) => {
64
+ const label = buildWorktreeButtonLabel(startIndex + index, entry);
65
+ keyboard
66
+ .text(formatWorktreeButtonLabel(label, entry.isCurrent), `${WORKTREE_CALLBACK_PREFIX}${startIndex + index}`)
67
+ .row();
68
+ });
69
+ if (totalPages > 1) {
70
+ if (normalizedPage > 0) {
71
+ keyboard.text(t("projects.prev_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage - 1}`);
72
+ }
73
+ if (normalizedPage < totalPages - 1) {
74
+ keyboard.text(t("projects.next_page"), `${WORKTREE_PAGE_CALLBACK_PREFIX}${normalizedPage + 1}`);
75
+ }
76
+ }
77
+ return keyboard;
78
+ }
79
+ function buildWorktreeMenuView(worktrees, page) {
80
+ const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(worktrees.length, page, config.bot.projectsListLimit);
81
+ return {
82
+ text: buildWorktreeMenuText(normalizedPage, totalPages),
83
+ keyboard: buildWorktreeKeyboard(worktrees, normalizedPage),
84
+ };
85
+ }
86
+ async function loadCurrentWorktreeContext() {
87
+ const currentProject = getCurrentProject();
88
+ if (!currentProject) {
89
+ return { currentProject: null, context: null };
90
+ }
91
+ const context = await getGitWorktreeContext(currentProject.worktree);
92
+ return { currentProject, context };
93
+ }
94
+ export async function worktreeCommand(ctx) {
95
+ try {
96
+ if (isForegroundBusy()) {
97
+ await replyBusyBlocked(ctx);
98
+ return;
99
+ }
100
+ const { currentProject, context } = await loadCurrentWorktreeContext();
101
+ if (!currentProject) {
102
+ await ctx.reply(t("worktree.project_not_selected"));
103
+ return;
104
+ }
105
+ if (!context) {
106
+ await ctx.reply(t("worktree.not_git_repo"));
107
+ return;
108
+ }
109
+ if (context.worktrees.length === 0) {
110
+ await ctx.reply(t("worktree.empty"));
111
+ return;
112
+ }
113
+ const { text, keyboard } = buildWorktreeMenuView(context.worktrees, 0);
114
+ await replyWithInlineMenu(ctx, {
115
+ menuKind: "worktree",
116
+ text,
117
+ keyboard,
118
+ });
119
+ }
120
+ catch (error) {
121
+ logger.error("[Bot] Error loading worktrees:", error);
122
+ await ctx.reply(t("worktree.fetch_error"));
123
+ }
124
+ }
125
+ export async function handleWorktreeCallback(ctx) {
126
+ const callbackQuery = ctx.callbackQuery;
127
+ if (!callbackQuery?.data || !callbackQuery.data.startsWith(WORKTREE_CALLBACK_PREFIX)) {
128
+ return false;
129
+ }
130
+ if (isForegroundBusy()) {
131
+ await replyBusyBlocked(ctx);
132
+ return true;
133
+ }
134
+ const page = parseWorktreePageCallback(callbackQuery.data);
135
+ const index = parseWorktreeIndexCallback(callbackQuery.data);
136
+ const isActiveMenu = await ensureActiveInlineMenu(ctx, "worktree");
137
+ if (!isActiveMenu) {
138
+ return true;
139
+ }
140
+ try {
141
+ const { currentProject, context } = await loadCurrentWorktreeContext();
142
+ if (!currentProject) {
143
+ clearAllInteractionState("worktree_project_missing");
144
+ await ctx.answerCallbackQuery();
145
+ await ctx.reply(t("worktree.project_not_selected"));
146
+ return true;
147
+ }
148
+ if (!context) {
149
+ clearAllInteractionState("worktree_git_context_missing");
150
+ await ctx.answerCallbackQuery({ text: t("worktree.not_git_repo_callback") });
151
+ return true;
152
+ }
153
+ if (page !== null) {
154
+ if (context.worktrees.length === 0) {
155
+ await ctx.answerCallbackQuery({ text: t("worktree.page_empty_callback") });
156
+ return true;
157
+ }
158
+ const { text, keyboard } = buildWorktreeMenuView(context.worktrees, page);
159
+ await ctx.answerCallbackQuery();
160
+ await ctx.editMessageText(text, {
161
+ reply_markup: appendInlineMenuCancelButton(keyboard, "worktree"),
162
+ });
163
+ return true;
164
+ }
165
+ if (index === null) {
166
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
167
+ return true;
168
+ }
169
+ const selectedWorktree = context.worktrees[index];
170
+ if (!selectedWorktree) {
171
+ await ctx.answerCallbackQuery({ text: t("worktree.selection_missing_callback") });
172
+ return true;
173
+ }
174
+ if (selectedWorktree.isCurrent) {
175
+ await ctx.answerCallbackQuery({ text: t("worktree.already_selected_callback") });
176
+ return true;
177
+ }
178
+ logger.info(`[Bot] Worktree selected: ${selectedWorktree.path}`);
179
+ await upsertSessionDirectory(selectedWorktree.path, Date.now());
180
+ const projectInfo = await getProjectByWorktree(selectedWorktree.path);
181
+ const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: selectedWorktree.path }, "worktree_switched");
182
+ await ctx.answerCallbackQuery();
183
+ await ctx.reply(t("worktree.selected", { worktree: selectedWorktree.path }), {
184
+ reply_markup: replyKeyboard,
185
+ });
186
+ await ctx.deleteMessage();
187
+ return true;
188
+ }
189
+ catch (error) {
190
+ logger.error("[Bot] Error handling worktree callback:", error);
191
+ clearAllInteractionState("worktree_select_error");
192
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
193
+ await ctx.reply(t("worktree.select_error"));
194
+ return true;
195
+ }
196
+ }
@@ -11,6 +11,7 @@ const INLINE_MENU_KINDS = [
11
11
  "variant",
12
12
  "context",
13
13
  "open",
14
+ "worktree",
14
15
  ];
15
16
  function isInlineMenuKind(value) {
16
17
  return INLINE_MENU_KINDS.includes(value);
@@ -156,8 +156,15 @@ export async function handleVoiceMessage(ctx, deps) {
156
156
  logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
157
157
  }
158
158
  logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
159
+ let textForLLM = recognizedText;
160
+ const notePrompt = config.stt.notePrompt.trim();
161
+ if (notePrompt && notePrompt.toLowerCase() !== "false" && notePrompt !== "0") {
162
+ const llmNote = `[Note: ${notePrompt}]`;
163
+ logger.debug(`[Voice] Added STT note to LLM prompt: ${llmNote}`);
164
+ textForLLM = `${llmNote}\n${recognizedText}`;
165
+ }
159
166
  // Process the recognized text as a prompt
160
- await processPrompt(ctx, recognizedText, deps);
167
+ await processPrompt(ctx, textForLLM, deps);
161
168
  }
162
169
  catch (err) {
163
170
  const errorMessage = err instanceof Error ? err.message : "unknown error";
package/dist/bot/index.js CHANGED
@@ -16,6 +16,7 @@ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTT
16
16
  import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
17
17
  import { newCommand } from "./commands/new.js";
18
18
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
+ import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
19
20
  import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
20
21
  import { abortCommand } from "./commands/abort.js";
21
22
  import { opencodeStartCommand } from "./commands/opencode-start.js";
@@ -24,6 +25,7 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
24
25
  import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
25
26
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
26
27
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
28
+ import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
27
29
  import { ttsCommand } from "./commands/tts.js";
28
30
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
29
31
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
@@ -36,7 +38,7 @@ import { questionManager } from "../question/manager.js";
36
38
  import { interactionManager } from "../interaction/manager.js";
37
39
  import { clearAllInteractionState } from "../interaction/cleanup.js";
38
40
  import { keyboardManager } from "../keyboard/manager.js";
39
- import { subscribeToEvents } from "../opencode/events.js";
41
+ import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
40
42
  import { summaryAggregator } from "../summary/aggregator.js";
41
43
  import { formatToolInfo } from "../summary/formatter.js";
42
44
  import { renderSubagentCards } from "../summary/subagent-formatter.js";
@@ -68,6 +70,7 @@ import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload
68
70
  let botInstance = null;
69
71
  let chatIdInstance = null;
70
72
  let commandsInitialized = false;
73
+ let heartbeatTimer = null;
71
74
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
72
75
  const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
73
76
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
@@ -442,6 +445,9 @@ async function ensureEventSubscription(directory) {
442
445
  if (!currentSession || currentSession.id !== fileInfo.sessionId) {
443
446
  return;
444
447
  }
448
+ if (config.bot.hideToolFileMessages) {
449
+ return;
450
+ }
445
451
  try {
446
452
  await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
447
453
  const toolMessage = formatToolInfo(fileInfo);
@@ -718,6 +724,10 @@ export function createBot() {
718
724
  clearAllInteractionState("bot_startup");
719
725
  sessionCompletionTasks.clear();
720
726
  assistantRunState.clearAll("bot_startup");
727
+ if (heartbeatTimer) {
728
+ clearInterval(heartbeatTimer);
729
+ heartbeatTimer = null;
730
+ }
721
731
  const botOptions = {};
722
732
  if (config.telegram.proxyUrl) {
723
733
  const proxyUrl = config.telegram.proxyUrl;
@@ -740,7 +750,7 @@ export function createBot() {
740
750
  const bot = new Bot(config.telegram.token, botOptions);
741
751
  // Heartbeat for diagnostics: verify the event loop is not blocked
742
752
  let heartbeatCounter = 0;
743
- setInterval(() => {
753
+ heartbeatTimer = setInterval(() => {
744
754
  heartbeatCounter++;
745
755
  if (heartbeatCounter % 6 === 0) {
746
756
  // Log every 30 seconds (5 sec * 6)
@@ -793,6 +803,7 @@ export function createBot() {
793
803
  bot.command("opencode_start", opencodeStartCommand);
794
804
  bot.command("opencode_stop", opencodeStopCommand);
795
805
  bot.command("projects", projectsCommand);
806
+ bot.command("worktree", worktreeCommand);
796
807
  bot.command("open", openCommand);
797
808
  bot.command("sessions", sessionsCommand);
798
809
  bot.command("new", newCommand);
@@ -801,6 +812,7 @@ export function createBot() {
801
812
  bot.command("tasklist", taskListCommand);
802
813
  bot.command("rename", renameCommand);
803
814
  bot.command("commands", commandsCommand);
815
+ bot.command("skills", skillsCommand);
804
816
  bot.on("message:text", unknownCommandMiddleware);
805
817
  bot.on("callback_query:data", async (ctx) => {
806
818
  logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
@@ -817,6 +829,7 @@ export function createBot() {
817
829
  }
818
830
  const handledSession = await handleSessionSelect(ctx);
819
831
  const handledProject = await handleProjectSelect(ctx);
832
+ const handledWorktree = await handleWorktreeCallback(ctx);
820
833
  const handledOpen = await handleOpenCallback(ctx);
821
834
  const handledQuestion = await handleQuestionCallback(ctx);
822
835
  const handledPermission = await handlePermissionCallback(ctx);
@@ -828,10 +841,12 @@ export function createBot() {
828
841
  const handledTaskList = await handleTaskListCallback(ctx);
829
842
  const handledRenameCancel = await handleRenameCancel(ctx);
830
843
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
831
- logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}`);
844
+ const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
845
+ logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}`);
832
846
  if (!handledInlineCancel &&
833
847
  !handledSession &&
834
848
  !handledProject &&
849
+ !handledWorktree &&
835
850
  !handledOpen &&
836
851
  !handledQuestion &&
837
852
  !handledPermission &&
@@ -842,7 +857,8 @@ export function createBot() {
842
857
  !handledTask &&
843
858
  !handledTaskList &&
844
859
  !handledRenameCancel &&
845
- !handledCommands) {
860
+ !handledCommands &&
861
+ !handledSkills) {
846
862
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
847
863
  await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
848
864
  }
@@ -1041,6 +1057,10 @@ export function createBot() {
1041
1057
  if (handledCommandArgs) {
1042
1058
  return;
1043
1059
  }
1060
+ const handledSkillArgs = await handleSkillTextArguments(ctx, promptDeps);
1061
+ if (handledSkillArgs) {
1062
+ return;
1063
+ }
1044
1064
  await processUserPrompt(ctx, text, promptDeps);
1045
1065
  logger.debug("[Bot] message:text handler completed (prompt sent in background)");
1046
1066
  });
@@ -1053,3 +1073,18 @@ export function createBot() {
1053
1073
  });
1054
1074
  return bot;
1055
1075
  }
1076
+ export function cleanupBotRuntime(reason) {
1077
+ stopEventListening();
1078
+ summaryAggregator.clear();
1079
+ responseStreamer.clearAll(reason);
1080
+ toolCallStreamer.clearAll(reason);
1081
+ toolMessageBatcher.clearAll(reason);
1082
+ sessionCompletionTasks.clear();
1083
+ assistantRunState.clearAll(reason);
1084
+ if (heartbeatTimer) {
1085
+ clearInterval(heartbeatTimer);
1086
+ heartbeatTimer = null;
1087
+ }
1088
+ botInstance = null;
1089
+ chatIdInstance = null;
1090
+ }
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
  });