@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
@@ -32,6 +32,7 @@ class SummaryAggregator {
32
32
  lastUpdated = 0;
33
33
  onCompleteCallback = null;
34
34
  onPartialCallback = null;
35
+ onExternalUserInputCallback = null;
35
36
  onToolCallback = null;
36
37
  onToolFileCallback = null;
37
38
  onQuestionCallback = null;
@@ -50,6 +51,7 @@ class SummaryAggregator {
50
51
  onClearedCallback = null;
51
52
  processedToolStates = new Set();
52
53
  thinkingFiredForMessages = new Set();
54
+ deliveredExternalUserMessageIds = new Set();
53
55
  knownTextPartIds = new Map();
54
56
  bot = null;
55
57
  chatId = null;
@@ -73,6 +75,9 @@ class SummaryAggregator {
73
75
  setOnPartial(callback) {
74
76
  this.onPartialCallback = callback;
75
77
  }
78
+ setOnExternalUserInput(callback) {
79
+ this.onExternalUserInputCallback = callback;
80
+ }
76
81
  setOnTool(callback) {
77
82
  this.onToolCallback = callback;
78
83
  }
@@ -226,6 +231,7 @@ class SummaryAggregator {
226
231
  this.knownTextPartIds.clear();
227
232
  this.processedToolStates.clear();
228
233
  this.thinkingFiredForMessages.clear();
234
+ this.deliveredExternalUserMessageIds.clear();
229
235
  this.trackedSessionParents.clear();
230
236
  this.subagentStates.clear();
231
237
  this.subagentOrder = [];
@@ -605,6 +611,10 @@ class SummaryAggregator {
605
611
  }
606
612
  const messageID = info.id;
607
613
  this.messages.set(messageID, { role: info.role });
614
+ if (info.role === "user") {
615
+ this.emitExternalUserInputIfReady(info.sessionID, messageID);
616
+ return;
617
+ }
608
618
  if (info.role === "assistant") {
609
619
  if (!this.textMessageStates.has(messageID)) {
610
620
  this.textMessageStates.set(messageID, {
@@ -655,15 +665,8 @@ class SummaryAggregator {
655
665
  completedAt: time?.completed,
656
666
  });
657
667
  }
658
- this.textMessageStates.delete(messageID);
659
- this.messages.delete(messageID);
660
- this.partHashes.delete(messageID);
661
- this.knownTextPartIds.delete(messageID);
668
+ this.cleanupCompletedMessage(messageID);
662
669
  logger.debug(`[Aggregator] Message completed cleanup: remaining messages=${this.textMessageStates.size}`);
663
- if (this.textMessageStates.size === 0) {
664
- logger.debug("[Aggregator] No more active messages, stopping typing indicator");
665
- this.stopTypingIndicator();
666
- }
667
670
  }
668
671
  this.lastUpdated = Date.now();
669
672
  }
@@ -741,6 +744,9 @@ class SummaryAggregator {
741
744
  this.startTypingIndicator();
742
745
  this.emitPartialText(part.sessionID, messageID, fullText);
743
746
  }
747
+ else if (messageInfo && messageInfo.role === "user") {
748
+ this.emitExternalUserInputIfReady(part.sessionID, messageID);
749
+ }
744
750
  else {
745
751
  const state = this.getOrCreateTextMessageState(messageID);
746
752
  state.optimisticUpdateCount++;
@@ -857,9 +863,48 @@ class SummaryAggregator {
857
863
  if (!combined.trim()) {
858
864
  return;
859
865
  }
866
+ const messageInfo = this.messages.get(messageID);
867
+ if (messageInfo?.role === "user") {
868
+ this.emitExternalUserInputIfReady(sessionID, messageID);
869
+ return;
870
+ }
860
871
  this.startTypingIndicator();
861
872
  this.emitPartialText(sessionID, messageID, combined);
862
873
  }
874
+ emitExternalUserInputIfReady(sessionId, messageId) {
875
+ if (sessionId !== this.currentSessionId || this.deliveredExternalUserMessageIds.has(messageId)) {
876
+ return;
877
+ }
878
+ const messageInfo = this.messages.get(messageId);
879
+ if (!messageInfo || messageInfo.role !== "user") {
880
+ return;
881
+ }
882
+ const messageText = this.getCombinedMessageText(messageId).trim();
883
+ if (!messageText) {
884
+ return;
885
+ }
886
+ this.deliveredExternalUserMessageIds.add(messageId);
887
+ this.cleanupCompletedMessage(messageId);
888
+ if (!this.onExternalUserInputCallback) {
889
+ return;
890
+ }
891
+ const callback = this.onExternalUserInputCallback;
892
+ setImmediate(() => {
893
+ Promise.resolve(callback(sessionId, messageId, messageText)).catch((err) => {
894
+ logger.error("[Aggregator] Error in external user input callback:", err);
895
+ });
896
+ });
897
+ }
898
+ cleanupCompletedMessage(messageId) {
899
+ this.textMessageStates.delete(messageId);
900
+ this.messages.delete(messageId);
901
+ this.partHashes.delete(messageId);
902
+ this.knownTextPartIds.delete(messageId);
903
+ if (this.textMessageStates.size === 0) {
904
+ logger.debug("[Aggregator] No more active messages, stopping typing indicator");
905
+ this.stopTypingIndicator();
906
+ }
907
+ }
863
908
  emitPartialText(sessionId, messageId, messageText) {
864
909
  if (!this.onPartialCallback || !messageText.trim()) {
865
910
  return;
@@ -1103,7 +1148,7 @@ class SummaryAggregator {
1103
1148
  const callback = this.onQuestionCallback;
1104
1149
  setImmediate(async () => {
1105
1150
  try {
1106
- await callback(questions, id);
1151
+ await callback(questions, id, sessionID);
1107
1152
  }
1108
1153
  catch (err) {
1109
1154
  logger.error("[Aggregator] Error in question callback:", err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
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",
@@ -1,273 +0,0 @@
1
- import { spawn, exec } from "child_process";
2
- import { promisify } from "util";
3
- import { getServerProcess, setServerProcess, clearServerProcess } from "../settings/manager.js";
4
- import { logger } from "../utils/logger.js";
5
- const execAsync = promisify(exec);
6
- /**
7
- * Singleton manager for OpenCode server process
8
- * Handles starting, stopping, and monitoring the server process
9
- * Persists PID to settings.json for recovery after bot restart
10
- */
11
- class ProcessManager {
12
- state = {
13
- process: null,
14
- pid: null,
15
- startTime: null,
16
- isRunning: false,
17
- };
18
- /**
19
- * Initialize the manager by restoring state from settings
20
- * Checks if the stored process is still alive
21
- */
22
- async initialize() {
23
- const savedProcess = getServerProcess();
24
- if (!savedProcess) {
25
- logger.debug("[ProcessManager] No saved process found in settings");
26
- return;
27
- }
28
- logger.info(`[ProcessManager] Found saved process: PID=${savedProcess.pid}`);
29
- // Check if the process is still alive
30
- if (this.isProcessAlive(savedProcess.pid)) {
31
- logger.info(`[ProcessManager] Process PID=${savedProcess.pid} is still alive, restoring state`);
32
- this.state = {
33
- process: null, // Cannot recover ChildProcess reference
34
- pid: savedProcess.pid,
35
- startTime: new Date(savedProcess.startTime),
36
- isRunning: true,
37
- };
38
- }
39
- else {
40
- logger.warn(`[ProcessManager] Process PID=${savedProcess.pid} is dead, cleaning up`);
41
- clearServerProcess();
42
- }
43
- }
44
- /**
45
- * Start the OpenCode server process
46
- */
47
- async start() {
48
- if (this.state.isRunning) {
49
- return {
50
- success: false,
51
- error: "Process already running",
52
- };
53
- }
54
- try {
55
- logger.info("[ProcessManager] Starting OpenCode server process...");
56
- const isWindows = process.platform === "win32";
57
- const command = isWindows ? "cmd.exe" : "opencode";
58
- const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
59
- // Spawn the process
60
- // Windows: use cmd.exe to resolve npm-installed global commands
61
- // Unix-like: run opencode directly
62
- const childProcess = spawn(command, args, {
63
- detached: false,
64
- stdio: ["ignore", "pipe", "pipe"],
65
- windowsHide: isWindows,
66
- });
67
- if (!childProcess.pid) {
68
- throw new Error("Failed to start OpenCode server process. Ensure 'opencode' is installed and available in PATH.");
69
- }
70
- // Setup event handlers
71
- childProcess.on("error", (err) => {
72
- logger.error("[ProcessManager] Process error:", err);
73
- this.cleanup();
74
- });
75
- childProcess.on("exit", (code, signal) => {
76
- logger.info(`[ProcessManager] Process exited: code=${code}, signal=${signal}`);
77
- this.cleanup();
78
- });
79
- // Log stdout/stderr
80
- if (childProcess.stdout) {
81
- childProcess.stdout.on("data", (data) => {
82
- logger.debug(`[OpenCode Server] ${data.toString().trim()}`);
83
- });
84
- }
85
- if (childProcess.stderr) {
86
- childProcess.stderr.on("data", (data) => {
87
- logger.warn(`[OpenCode Server Error] ${data.toString().trim()}`);
88
- });
89
- }
90
- // Save state in memory
91
- const startTime = new Date();
92
- this.state = {
93
- process: childProcess,
94
- pid: childProcess.pid,
95
- startTime,
96
- isRunning: true,
97
- };
98
- // Persist to settings.json
99
- setServerProcess({
100
- pid: childProcess.pid,
101
- startTime: startTime.toISOString(),
102
- });
103
- logger.info(`[ProcessManager] OpenCode server started with PID=${childProcess.pid}`);
104
- return { success: true };
105
- }
106
- catch (err) {
107
- const errorMessage = err instanceof Error ? err.message : String(err);
108
- logger.error("[ProcessManager] Failed to start process:", err);
109
- this.cleanup();
110
- return { success: false, error: errorMessage };
111
- }
112
- }
113
- /**
114
- * Stop the OpenCode server process
115
- * Sends SIGINT (Ctrl+C) and waits for graceful shutdown
116
- * Falls back to SIGKILL if timeout is exceeded
117
- */
118
- async stop(timeoutMs = 5000) {
119
- if (!this.state.isRunning || !this.state.pid) {
120
- return {
121
- success: false,
122
- error: "Process not running",
123
- };
124
- }
125
- try {
126
- const pid = this.state.pid;
127
- logger.info(`[ProcessManager] Stopping process PID=${pid}...`);
128
- // On Windows, use taskkill to kill the entire process tree
129
- // This is necessary because cmd.exe spawns child processes
130
- if (process.platform === "win32") {
131
- try {
132
- // /F = force terminate, /T = terminate tree, /PID = process id
133
- logger.debug(`[ProcessManager] Using taskkill to terminate process tree for PID=${pid}`);
134
- await execAsync(`taskkill /F /T /PID ${pid}`);
135
- logger.info(`[ProcessManager] Process tree terminated successfully for PID=${pid}`);
136
- }
137
- catch (err) {
138
- // taskkill returns error if process not found, which is ok
139
- const error = err;
140
- if (error.message?.includes("not found")) {
141
- logger.debug(`[ProcessManager] Process PID=${pid} already terminated`);
142
- }
143
- else {
144
- logger.warn(`[ProcessManager] taskkill error for PID=${pid}:`, err);
145
- }
146
- }
147
- // Wait a bit for cleanup
148
- await new Promise((resolve) => setTimeout(resolve, 1000));
149
- }
150
- else {
151
- // Unix-like systems: use SIGINT/SIGKILL
152
- if (this.state.process) {
153
- const childProcess = this.state.process;
154
- // Send SIGINT (Ctrl+C)
155
- logger.debug(`[ProcessManager] Sending SIGINT to PID=${pid}`);
156
- childProcess.kill("SIGINT");
157
- // Wait for graceful shutdown
158
- const gracefulExit = await this.waitForProcessExit(childProcess, timeoutMs);
159
- if (!gracefulExit && this.state.isRunning) {
160
- logger.warn(`[ProcessManager] Graceful shutdown failed, sending SIGKILL to PID=${pid}`);
161
- childProcess.kill("SIGKILL");
162
- await new Promise((resolve) => setTimeout(resolve, 2000));
163
- }
164
- }
165
- else {
166
- // No ChildProcess reference (recovered from settings)
167
- logger.debug(`[ProcessManager] Sending SIGTERM to PID=${pid}`);
168
- try {
169
- process.kill(pid, "SIGTERM");
170
- }
171
- catch (err) {
172
- logger.debug(`[ProcessManager] Failed to send SIGTERM to PID=${pid}:`, err);
173
- }
174
- // Wait for process to die
175
- await new Promise((resolve) => setTimeout(resolve, timeoutMs));
176
- // Check if still alive
177
- if (this.isProcessAlive(pid)) {
178
- logger.warn(`[ProcessManager] Graceful shutdown failed, sending SIGKILL to PID=${pid}`);
179
- try {
180
- process.kill(pid, "SIGKILL");
181
- }
182
- catch (err) {
183
- logger.error(`[ProcessManager] Failed to send SIGKILL to PID=${pid}:`, err);
184
- }
185
- await new Promise((resolve) => setTimeout(resolve, 2000));
186
- }
187
- }
188
- }
189
- this.cleanup();
190
- logger.info(`[ProcessManager] Process PID=${pid} stopped successfully`);
191
- return { success: true };
192
- }
193
- catch (err) {
194
- const errorMessage = err instanceof Error ? err.message : String(err);
195
- logger.error("[ProcessManager] Failed to stop process:", err);
196
- return { success: false, error: errorMessage };
197
- }
198
- }
199
- /**
200
- * Check if the process is running
201
- * Validates that the process with stored PID is actually alive
202
- */
203
- isRunning() {
204
- if (!this.state.isRunning || !this.state.pid) {
205
- return false;
206
- }
207
- // Verify that the process is actually alive
208
- if (!this.isProcessAlive(this.state.pid)) {
209
- logger.warn(`[ProcessManager] Process PID=${this.state.pid} appears dead, cleaning up`);
210
- this.cleanup();
211
- return false;
212
- }
213
- return true;
214
- }
215
- /**
216
- * Get the process ID of the running server
217
- */
218
- getPID() {
219
- return this.state.pid;
220
- }
221
- /**
222
- * Get the uptime of the server in milliseconds
223
- */
224
- getUptime() {
225
- if (!this.state.startTime || !this.state.isRunning) {
226
- return null;
227
- }
228
- return Date.now() - this.state.startTime.getTime();
229
- }
230
- /**
231
- * Check if a process with given PID is alive
232
- * Uses process.kill(pid, 0) which checks existence without killing
233
- */
234
- isProcessAlive(pid) {
235
- try {
236
- process.kill(pid, 0);
237
- return true;
238
- }
239
- catch {
240
- return false;
241
- }
242
- }
243
- /**
244
- * Wait for process to exit
245
- */
246
- async waitForProcessExit(childProcess, timeoutMs) {
247
- return new Promise((resolve) => {
248
- const exitHandler = () => {
249
- logger.debug("[ProcessManager] Process exited gracefully");
250
- resolve(true);
251
- };
252
- childProcess.once("exit", exitHandler);
253
- setTimeout(() => {
254
- childProcess.removeListener("exit", exitHandler);
255
- resolve(false);
256
- }, timeoutMs);
257
- });
258
- }
259
- /**
260
- * Clean up state and settings
261
- */
262
- cleanup() {
263
- this.state = {
264
- process: null,
265
- pid: null,
266
- startTime: null,
267
- isRunning: false,
268
- };
269
- clearServerProcess();
270
- }
271
- }
272
- // Export singleton instance
273
- export const processManager = new ProcessManager();
@@ -1 +0,0 @@
1
- export {};