@grinev/opencode-telegram-bot 0.16.1 → 0.18.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.
Files changed (46) hide show
  1. package/.env.example +5 -0
  2. package/README.md +72 -43
  3. package/dist/app/start-bot-app.js +0 -2
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +166 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +2 -0
  9. package/dist/bot/commands/new.js +10 -18
  10. package/dist/bot/commands/opencode-start.js +27 -24
  11. package/dist/bot/commands/opencode-stop.js +51 -23
  12. package/dist/bot/commands/projects.js +30 -6
  13. package/dist/bot/commands/sessions.js +19 -20
  14. package/dist/bot/commands/skills.js +376 -0
  15. package/dist/bot/commands/start.js +2 -0
  16. package/dist/bot/commands/status.js +21 -14
  17. package/dist/bot/commands/worktree.js +196 -0
  18. package/dist/bot/handlers/inline-menu.js +1 -0
  19. package/dist/bot/handlers/prompt.js +26 -31
  20. package/dist/bot/handlers/voice.js +8 -1
  21. package/dist/bot/index.js +90 -10
  22. package/dist/bot/middleware/interaction-guard.js +1 -1
  23. package/dist/bot/utils/busy-guard.js +3 -2
  24. package/dist/bot/utils/external-user-input.js +46 -0
  25. package/dist/bot/utils/switch-project.js +2 -0
  26. package/dist/config.js +2 -0
  27. package/dist/external-input/suppression.js +54 -0
  28. package/dist/git/worktree.js +158 -0
  29. package/dist/i18n/de.js +54 -1
  30. package/dist/i18n/en.js +54 -1
  31. package/dist/i18n/es.js +54 -1
  32. package/dist/i18n/fr.js +54 -1
  33. package/dist/i18n/ru.js +54 -1
  34. package/dist/i18n/zh.js +54 -1
  35. package/dist/interaction/guard.js +2 -1
  36. package/dist/opencode/events.js +13 -1
  37. package/dist/opencode/process.js +182 -0
  38. package/dist/pinned/manager.js +46 -61
  39. package/dist/project/manager.js +47 -32
  40. package/dist/runtime/bootstrap.js +119 -7
  41. package/dist/scheduled-task/executor.js +137 -7
  42. package/dist/settings/manager.js +9 -12
  43. package/dist/summary/aggregator.js +54 -9
  44. package/package.json +1 -1
  45. package/dist/process/manager.js +0 -273
  46. package/dist/process/types.js +0 -1
@@ -3,8 +3,6 @@ import { setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
5
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { pinnedMessageManager } from "../../pinned/manager.js";
8
6
  import { keyboardManager } from "../../keyboard/manager.js";
9
7
  import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
8
  import { getStoredModel } from "../../model/manager.js";
@@ -13,7 +11,8 @@ import { createMainKeyboard } from "../utils/keyboard.js";
13
11
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
14
12
  import { logger } from "../../utils/logger.js";
15
13
  import { t } from "../../i18n/index.js";
16
- export async function newCommand(ctx) {
14
+ import { attachToSession } from "../../attach/service.js";
15
+ export async function newCommand(ctx, deps) {
17
16
  try {
18
17
  if (isForegroundBusy()) {
19
18
  await replyBusyBlocked(ctx);
@@ -38,27 +37,20 @@ export async function newCommand(ctx) {
38
37
  directory: currentProject.worktree,
39
38
  };
40
39
  setCurrentSession(sessionInfo);
41
- summaryAggregator.clear();
42
40
  clearAllInteractionState("session_created");
43
41
  await ingestSessionInfoForCache(session);
44
- // Initialize pinned message manager and create pinned message
45
- if (!pinnedMessageManager.isInitialized()) {
46
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
47
- }
48
- // Initialize keyboard manager if not already
49
- keyboardManager.initialize(ctx.api, ctx.chat.id);
50
- try {
51
- await pinnedMessageManager.onSessionChange(session.id, session.title);
52
- }
53
- catch (err) {
54
- logger.error("[Bot] Error creating pinned message:", err);
55
- }
42
+ await attachToSession({
43
+ bot: deps.bot,
44
+ chatId: ctx.chat.id,
45
+ session: sessionInfo,
46
+ ensureEventSubscription: deps.ensureEventSubscription,
47
+ });
56
48
  // Get current state for keyboard
57
49
  const currentAgent = await resolveProjectAgent(getStoredAgent());
58
50
  const currentModel = getStoredModel();
59
- const contextInfo = pinnedMessageManager.getContextInfo();
60
- const variantName = formatVariantForButton(currentModel.variant || "default");
61
51
  keyboardManager.updateAgent(currentAgent);
52
+ const contextInfo = keyboardManager.getContextInfo();
53
+ const variantName = formatVariantForButton(currentModel.variant || "default");
62
54
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
63
55
  await ctx.reply(t("new.created", { title: session.title }), {
64
56
  reply_markup: keyboard,
@@ -1,5 +1,7 @@
1
+ import { spawn } from "node:child_process";
2
+ import { config } from "../../config.js";
1
3
  import { opencodeClient } from "../../opencode/client.js";
2
- import { processManager } from "../../process/manager.js";
4
+ import { resolveLocalOpencodeTarget } from "../../opencode/process.js";
3
5
  import { logger } from "../../utils/logger.js";
4
6
  import { t } from "../../i18n/index.js";
5
7
  import { editBotText } from "../utils/telegram-text.js";
@@ -31,43 +33,45 @@ async function waitForServerReady(maxWaitMs = 10000) {
31
33
  */
32
34
  export async function opencodeStartCommand(ctx) {
33
35
  try {
34
- // 1. Check if process is already running under our management
35
- if (processManager.isRunning()) {
36
- const uptime = processManager.getUptime();
37
- const uptimeStr = uptime ? Math.floor(uptime / 1000) : 0;
38
- await ctx.reply(t("opencode_start.already_running_managed", {
39
- pid: processManager.getPID() ?? "-",
40
- seconds: uptimeStr,
41
- }));
36
+ const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
37
+ if (!localTarget) {
38
+ await ctx.reply(t("opencode_start.remote_configured"));
42
39
  return;
43
40
  }
44
- // 2. Check if server is accessible (external process)
41
+ // Check if server is already accessible.
45
42
  try {
46
43
  const { data, error } = await opencodeClient.global.health();
47
44
  if (!error && data?.healthy) {
48
- await ctx.reply(t("opencode_start.already_running_external", {
49
- version: data.version || t("common.unknown"),
50
- }));
45
+ await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
51
46
  return;
52
47
  }
53
48
  }
54
49
  catch {
55
- // Server not accessible, continue with start
50
+ // Server not accessible, continue with start.
56
51
  }
57
- // 3. Notify user that we're starting the server
58
52
  const statusMessage = await ctx.reply(t("opencode_start.starting"));
59
- // 4. Start the process
60
- const { success, error } = await processManager.start();
61
- if (!success) {
53
+ const isWindows = process.platform === "win32";
54
+ const command = isWindows ? "cmd.exe" : "opencode";
55
+ const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
56
+ const childProcess = spawn(command, args, {
57
+ detached: true,
58
+ stdio: "ignore",
59
+ windowsHide: isWindows,
60
+ });
61
+ childProcess.once("error", (error) => {
62
+ logger.error("[Bot] OpenCode server process failed to start", error);
63
+ });
64
+ const pid = childProcess.pid;
65
+ if (!pid) {
62
66
  await editBotText({
63
67
  api: ctx.api,
64
68
  chatId: ctx.chat.id,
65
69
  messageId: statusMessage.message_id,
66
- text: t("opencode_start.start_error", { error: error || t("common.unknown_error") }),
70
+ text: t("opencode_start.start_error", { error: t("common.unknown_error") }),
67
71
  });
68
72
  return;
69
73
  }
70
- // 5. Wait for server to become ready
74
+ childProcess.unref();
71
75
  logger.info("[Bot] Waiting for OpenCode server to become ready...");
72
76
  const ready = await waitForServerReady(10000);
73
77
  if (!ready) {
@@ -76,23 +80,22 @@ export async function opencodeStartCommand(ctx) {
76
80
  chatId: ctx.chat.id,
77
81
  messageId: statusMessage.message_id,
78
82
  text: t("opencode_start.started_not_ready", {
79
- pid: processManager.getPID() ?? "-",
83
+ pid,
80
84
  }),
81
85
  });
82
86
  return;
83
87
  }
84
- // 6. Get server version and send success message
85
88
  const { data: health } = await opencodeClient.global.health();
86
89
  await editBotText({
87
90
  api: ctx.api,
88
91
  chatId: ctx.chat.id,
89
92
  messageId: statusMessage.message_id,
90
93
  text: t("opencode_start.success", {
91
- pid: processManager.getPID() ?? "-",
94
+ pid,
92
95
  version: health?.version || t("common.unknown"),
93
96
  }),
94
97
  });
95
- logger.info(`[Bot] OpenCode server started successfully, PID=${processManager.getPID()}`);
98
+ logger.info(`[Bot] OpenCode server started successfully, PID=${pid}, port=${localTarget.port}`);
96
99
  }
97
100
  catch (err) {
98
101
  logger.error("[Bot] Error in /opencode-start command:", err);
@@ -1,41 +1,69 @@
1
+ import { config } from "../../config.js";
1
2
  import { opencodeClient } from "../../opencode/client.js";
2
- import { processManager } from "../../process/manager.js";
3
+ import { findServerPid, killServerProcess, resolveLocalOpencodeTarget, } from "../../opencode/process.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { t } from "../../i18n/index.js";
6
+ import { editBotText } from "../utils/telegram-text.js";
5
7
  /**
6
8
  * Command handler for /opencode-stop
7
9
  * Stops the OpenCode server process
8
10
  */
9
11
  export async function opencodeStopCommand(ctx) {
10
12
  try {
11
- // 1. Check if process is running under our management
12
- if (!processManager.isRunning()) {
13
- // Check if there's an external server running
14
- try {
15
- const { data, error } = await opencodeClient.global.health();
16
- if (!error && data?.healthy) {
17
- await ctx.reply(t("opencode_stop.external_running"));
18
- return;
19
- }
20
- }
21
- catch {
22
- // Server not accessible
13
+ const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
14
+ if (!localTarget) {
15
+ await ctx.reply(t("opencode_stop.remote_configured"));
16
+ return;
17
+ }
18
+ try {
19
+ const { data, error } = await opencodeClient.global.health();
20
+ if (error || !data?.healthy) {
21
+ await ctx.reply(t("opencode_stop.not_running"));
22
+ return;
23
23
  }
24
+ }
25
+ catch {
24
26
  await ctx.reply(t("opencode_stop.not_running"));
25
27
  return;
26
28
  }
27
- // 2. Notify user that we're stopping the server
28
- const pid = processManager.getPID();
29
- const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid: pid ?? "-" }));
30
- // 3. Stop the process
31
- const { success, error } = await processManager.stop(5000);
32
- if (!success) {
33
- await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.stop_error", { error: error || t("common.unknown_error") }));
29
+ const pid = await findServerPid(localTarget.port);
30
+ if (!pid) {
31
+ await ctx.reply(t("opencode_stop.pid_not_found", { port: localTarget.port }));
34
32
  return;
35
33
  }
36
- // 4. Success - process has been stopped
37
- await ctx.api.editMessageText(ctx.chat.id, statusMessage.message_id, t("opencode_stop.success"));
38
- logger.info("[Bot] OpenCode server stopped successfully");
34
+ const statusMessage = await ctx.reply(t("opencode_stop.stopping", { pid }));
35
+ const stopped = await killServerProcess(pid, 5000);
36
+ if (!stopped) {
37
+ await editBotText({
38
+ api: ctx.api,
39
+ chatId: ctx.chat.id,
40
+ messageId: statusMessage.message_id,
41
+ text: t("opencode_stop.stop_error", { error: t("common.unknown_error") }),
42
+ });
43
+ return;
44
+ }
45
+ try {
46
+ const { data, error } = await opencodeClient.global.health();
47
+ if (!error && data?.healthy) {
48
+ await editBotText({
49
+ api: ctx.api,
50
+ chatId: ctx.chat.id,
51
+ messageId: statusMessage.message_id,
52
+ text: t("opencode_stop.stop_error", { error: t("opencode_stop.still_running") }),
53
+ });
54
+ return;
55
+ }
56
+ }
57
+ catch {
58
+ // Health check failure after stop is expected.
59
+ }
60
+ await editBotText({
61
+ api: ctx.api,
62
+ chatId: ctx.chat.id,
63
+ messageId: statusMessage.message_id,
64
+ text: t("opencode_stop.success"),
65
+ });
66
+ logger.info(`[Bot] OpenCode server stopped successfully, PID=${pid}, port=${localTarget.port}`);
39
67
  }
40
68
  catch (err) {
41
69
  logger.error("[Bot] Error in /opencode-stop command:", err);
@@ -1,5 +1,6 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { getCurrentProject } from "../../settings/manager.js";
3
+ import { getGitWorktreeContext } from "../../git/worktree.js";
3
4
  import { getProjects } from "../../project/manager.js";
4
5
  import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
5
6
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
@@ -68,14 +69,37 @@ function buildProjectsMenuText(currentProjectName, page, totalPages) {
68
69
  total: String(totalPages),
69
70
  })}`;
70
71
  }
71
- function buildProjectsKeyboard(projects, page) {
72
+ function worktreeKey(worktree) {
73
+ return process.platform === "win32" ? worktree.toLowerCase() : worktree;
74
+ }
75
+ async function getActiveProjectWorktree() {
76
+ const currentProject = getCurrentProject();
77
+ if (!currentProject) {
78
+ return null;
79
+ }
80
+ try {
81
+ const worktreeContext = await getGitWorktreeContext(currentProject.worktree);
82
+ if (worktreeContext) {
83
+ return worktreeContext.mainProjectPath;
84
+ }
85
+ }
86
+ catch (error) {
87
+ logger.debug("[Projects] Could not resolve active git worktree metadata:", error);
88
+ }
89
+ return currentProject.worktree;
90
+ }
91
+ async function buildProjectsKeyboard(projects, page) {
72
92
  const keyboard = new InlineKeyboard();
73
93
  const currentProject = getCurrentProject();
94
+ const activeProjectWorktree = await getActiveProjectWorktree();
74
95
  const pageSize = config.bot.projectsListLimit;
75
96
  const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateProjectsPaginationRange(projects.length, page, pageSize);
76
97
  projects.slice(startIndex, endIndex).forEach((project, index) => {
77
98
  const isActive = currentProject &&
78
- (project.id === currentProject.id || project.worktree === currentProject.worktree);
99
+ (project.id === currentProject.id ||
100
+ project.worktree === currentProject.worktree ||
101
+ (activeProjectWorktree !== null &&
102
+ worktreeKey(project.worktree) === worktreeKey(activeProjectWorktree)));
79
103
  const label = buildProjectButtonLabel(startIndex + index, project.worktree);
80
104
  const labelWithCheck = formatProjectButtonLabel(label, Boolean(isActive));
81
105
  keyboard.text(labelWithCheck, `project:${project.id}`).row();
@@ -90,14 +114,14 @@ function buildProjectsKeyboard(projects, page) {
90
114
  }
91
115
  return keyboard;
92
116
  }
93
- function buildProjectsMenuView(projects, page) {
117
+ async function buildProjectsMenuView(projects, page) {
94
118
  const currentProject = getCurrentProject();
95
119
  const pageSize = config.bot.projectsListLimit;
96
120
  const { page: normalizedPage, totalPages } = calculateProjectsPaginationRange(projects.length, page, pageSize);
97
121
  const currentProjectName = currentProject?.name || currentProject?.worktree || null;
98
122
  return {
99
123
  text: buildProjectsMenuText(currentProjectName, normalizedPage, totalPages),
100
- keyboard: buildProjectsKeyboard(projects, normalizedPage),
124
+ keyboard: await buildProjectsKeyboard(projects, normalizedPage),
101
125
  };
102
126
  }
103
127
  export async function projectsCommand(ctx) {
@@ -112,7 +136,7 @@ export async function projectsCommand(ctx) {
112
136
  await ctx.reply(t("projects.empty"));
113
137
  return;
114
138
  }
115
- const { text, keyboard } = buildProjectsMenuView(projects, 0);
139
+ const { text, keyboard } = await buildProjectsMenuView(projects, 0);
116
140
  await replyWithInlineMenu(ctx, {
117
141
  menuKind: "project",
118
142
  text,
@@ -146,7 +170,7 @@ export async function handleProjectSelect(ctx) {
146
170
  await ctx.reply(t("projects.empty"));
147
171
  return true;
148
172
  }
149
- const { text, keyboard } = buildProjectsMenuView(projects, page);
173
+ const { text, keyboard } = await buildProjectsMenuView(projects, page);
150
174
  await ctx.answerCallbackQuery();
151
175
  await ctx.editMessageText(text, {
152
176
  reply_markup: appendInlineMenuCancelButton(keyboard, "project"),
@@ -4,8 +4,6 @@ import { resolveProjectAgent } from "../../agent/manager.js";
4
4
  import { setCurrentSession } from "../../session/manager.js";
5
5
  import { getCurrentProject } from "../../settings/manager.js";
6
6
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
7
- import { summaryAggregator } from "../../summary/aggregator.js";
8
- import { pinnedMessageManager } from "../../pinned/manager.js";
9
7
  import { keyboardManager } from "../../keyboard/manager.js";
10
8
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
11
9
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
@@ -13,6 +11,7 @@ import { logger } from "../../utils/logger.js";
13
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
14
12
  import { config } from "../../config.js";
15
13
  import { getDateLocale, t } from "../../i18n/index.js";
14
+ import { attachToSession } from "../../attach/service.js";
16
15
  const SESSION_CALLBACK_PREFIX = "session:";
17
16
  const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
18
17
  const SESSION_FETCH_EXTRA_COUNT = 1;
@@ -120,7 +119,7 @@ export async function sessionsCommand(ctx) {
120
119
  await ctx.reply(t("sessions.fetch_error"));
121
120
  }
122
121
  }
123
- export async function handleSessionSelect(ctx) {
122
+ export async function handleSessionSelect(ctx, deps) {
124
123
  const callbackQuery = ctx.callbackQuery;
125
124
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
126
125
  return false;
@@ -182,7 +181,6 @@ export async function handleSessionSelect(ctx) {
182
181
  directory: currentProject.worktree,
183
182
  };
184
183
  setCurrentSession(sessionInfo);
185
- summaryAggregator.clear();
186
184
  clearAllInteractionState("session_switched");
187
185
  await ctx.answerCallbackQuery();
188
186
  let loadingMessageId = null;
@@ -195,30 +193,31 @@ export async function handleSessionSelect(ctx) {
195
193
  logger.error("[Sessions] Failed to send loading message:", err);
196
194
  }
197
195
  }
198
- // Initialize pinned message manager if not already
199
- if (!pinnedMessageManager.isInitialized() && ctx.chat) {
200
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
201
- }
202
- // Initialize keyboard manager if not already
203
- if (ctx.chat) {
204
- keyboardManager.initialize(ctx.api, ctx.chat.id);
205
- }
206
196
  try {
207
- // Create new pinned message for this session
208
- await pinnedMessageManager.onSessionChange(session.id, session.title);
209
- // Load context from session history (for existing sessions)
210
- // Wait for it to complete so keyboard has correct context
211
- await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree);
197
+ await attachToSession({
198
+ bot: deps.bot,
199
+ chatId: ctx.chat.id,
200
+ session: sessionInfo,
201
+ ensureEventSubscription: deps.ensureEventSubscription,
202
+ });
212
203
  }
213
204
  catch (err) {
214
- logger.error("[Bot] Error initializing pinned message:", err);
205
+ if (loadingMessageId) {
206
+ try {
207
+ await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
208
+ }
209
+ catch (deleteError) {
210
+ logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
211
+ }
212
+ }
213
+ logger.error("[Sessions] Error following selected session:", err);
214
+ throw err;
215
215
  }
216
216
  if (ctx.chat) {
217
217
  const chatId = ctx.chat.id;
218
218
  const currentAgent = await resolveProjectAgent();
219
219
  keyboardManager.updateAgent(currentAgent);
220
- // Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
221
- const contextInfo = pinnedMessageManager.getContextInfo();
220
+ const contextInfo = keyboardManager.getContextInfo();
222
221
  if (contextInfo) {
223
222
  keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
224
223
  }