@grinev/opencode-telegram-bot 0.6.0 → 0.6.1-rc.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/README.md CHANGED
@@ -5,14 +5,16 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
6
  [![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
7
7
 
8
- Control your [OpenCode](https://opencode.ai) coding agent from your phone. Send tasks, switch models, monitor progress — all through Telegram.
8
+ OpenCode Telegram Bot is a secure Telegram client for [OpenCode](https://opencode.ai) CLI that runs on your local machine.
9
9
 
10
- No open ports, no exposed APIs. The bot runs on your machine alongside OpenCode and communicates exclusively through the Telegram Bot API.
10
+ Run AI coding tasks, monitor progress, switch models, and manage sessions from your phone.
11
+
12
+ No open ports, no exposed APIs. The bot communicates with your local OpenCode server and the Telegram Bot API only.
13
+
14
+ Quick start: `npx @grinev/opencode-telegram-bot`
11
15
 
12
16
  <p align="center">
13
- <img src="assets/Screenshot-1.png" width="32%" alt="Sending a coding task and receiving file edit results" />
14
- <img src="assets/Screenshot-2.png" width="32%" alt="Live session status with context usage and changed files" />
15
- <img src="assets/Screenshot-3.png" width="32%" alt="Switching between AI models from favorites" />
17
+ <img src="assets/screencast.gif" width="45%" alt="OpenCode Telegram Bot screencast" />
16
18
  </p>
17
19
 
18
20
  ## Features
package/dist/bot/index.js CHANGED
@@ -53,9 +53,23 @@ import { t } from "../i18n/index.js";
53
53
  let botInstance = null;
54
54
  let chatIdInstance = null;
55
55
  let commandsInitialized = false;
56
+ const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
57
+ const __filename = fileURLToPath(import.meta.url);
58
+ const __dirname = path.dirname(__filename);
59
+ const TEMP_DIR = path.join(__dirname, "..", ".tmp");
60
+ function prepareDocumentCaption(caption) {
61
+ const normalizedCaption = caption.trim();
62
+ if (!normalizedCaption) {
63
+ return "";
64
+ }
65
+ if (normalizedCaption.length <= TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH) {
66
+ return normalizedCaption;
67
+ }
68
+ return `${normalizedCaption.slice(0, TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH - 3)}...`;
69
+ }
56
70
  const toolMessageBatcher = new ToolMessageBatcher({
57
71
  intervalSeconds: 5,
58
- sendMessage: async (sessionId, text) => {
72
+ sendText: async (sessionId, text) => {
59
73
  if (!botInstance || !chatIdInstance) {
60
74
  return;
61
75
  }
@@ -67,6 +81,28 @@ const toolMessageBatcher = new ToolMessageBatcher({
67
81
  disable_notification: true,
68
82
  });
69
83
  },
84
+ sendFile: async (sessionId, fileData) => {
85
+ if (!botInstance || !chatIdInstance) {
86
+ return;
87
+ }
88
+ const currentSession = getCurrentSession();
89
+ if (!currentSession || currentSession.id !== sessionId) {
90
+ return;
91
+ }
92
+ const tempFilePath = path.join(TEMP_DIR, fileData.filename);
93
+ try {
94
+ logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
95
+ await fs.mkdir(TEMP_DIR, { recursive: true });
96
+ await fs.writeFile(tempFilePath, fileData.buffer);
97
+ await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
98
+ caption: fileData.caption,
99
+ disable_notification: true,
100
+ });
101
+ }
102
+ finally {
103
+ await fs.unlink(tempFilePath).catch(() => { });
104
+ }
105
+ },
70
106
  });
71
107
  async function ensureCommandsInitialized(ctx, next) {
72
108
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
@@ -142,9 +178,6 @@ async function ensureEventSubscription(directory) {
142
178
  }
143
179
  });
144
180
  summaryAggregator.setOnTool(async (toolInfo) => {
145
- if (config.bot.hideToolCallMessages) {
146
- return;
147
- }
148
181
  if (!botInstance || !chatIdInstance) {
149
182
  logger.error("Bot or chat ID not available for sending tool notification");
150
183
  return;
@@ -153,6 +186,11 @@ async function ensureEventSubscription(directory) {
153
186
  if (!currentSession || currentSession.id !== toolInfo.sessionId) {
154
187
  return;
155
188
  }
189
+ const shouldIncludeToolInfoInFileCaption = toolInfo.hasFileAttachment &&
190
+ (toolInfo.tool === "write" || toolInfo.tool === "edit" || toolInfo.tool === "apply_patch");
191
+ if (config.bot.hideToolCallMessages || shouldIncludeToolInfoInFileCaption) {
192
+ return;
193
+ }
156
194
  try {
157
195
  const message = formatToolInfo(toolInfo);
158
196
  if (message) {
@@ -163,29 +201,22 @@ async function ensureEventSubscription(directory) {
163
201
  logger.error("Failed to send tool notification to Telegram:", err);
164
202
  }
165
203
  });
166
- summaryAggregator.setOnToolFile(async (fileData) => {
204
+ summaryAggregator.setOnToolFile(async (fileInfo) => {
167
205
  if (!botInstance || !chatIdInstance) {
168
206
  logger.error("Bot or chat ID not available for sending file");
169
207
  return;
170
208
  }
171
209
  const currentSession = getCurrentSession();
172
- if (!currentSession) {
210
+ if (!currentSession || currentSession.id !== fileInfo.sessionId) {
173
211
  return;
174
212
  }
175
- const __filename = fileURLToPath(import.meta.url);
176
- const __dirname = path.dirname(__filename);
177
- const tempDir = path.join(__dirname, "..", ".tmp");
178
213
  try {
179
- logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
180
- await fs.mkdir(tempDir, { recursive: true });
181
- const tempFilePath = path.join(tempDir, fileData.filename);
182
- await fs.writeFile(tempFilePath, fileData.buffer);
183
- await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
184
- caption: fileData.caption,
185
- disable_notification: true,
214
+ const toolMessage = formatToolInfo(fileInfo);
215
+ const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
216
+ toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
217
+ ...fileInfo.fileData,
218
+ caption,
186
219
  });
187
- await fs.unlink(tempFilePath);
188
- logger.debug(`[Bot] Temporary file deleted: ${fileData.filename}`);
189
220
  }
190
221
  catch (err) {
191
222
  logger.error("Failed to send file to Telegram:", err);
@@ -298,9 +298,10 @@ class SummaryAggregator {
298
298
  }
299
299
  if ("status" in state && state.status === "completed") {
300
300
  logger.debug(`[Aggregator] Tool completed: callID=${part.callID}, tool=${part.tool}`, JSON.stringify(state, null, 2));
301
- const notifiedKey = `notified-${part.callID}`;
302
- if (!this.processedToolStates.has(notifiedKey)) {
303
- this.processedToolStates.add(notifiedKey);
301
+ const completedKey = `completed-${part.callID}`;
302
+ if (!this.processedToolStates.has(completedKey)) {
303
+ this.processedToolStates.add(completedKey);
304
+ const preparedFileContext = this.prepareToolFileContext(part.tool, input, title, state.metadata);
304
305
  const toolData = {
305
306
  sessionId: part.sessionID,
306
307
  messageId: messageID,
@@ -310,103 +311,105 @@ class SummaryAggregator {
310
311
  input,
311
312
  title,
312
313
  metadata: state.metadata,
314
+ hasFileAttachment: !!preparedFileContext.fileData,
313
315
  };
314
316
  logger.debug(`[Aggregator] Sending tool notification to Telegram: tool=${part.tool}, title=${title || "N/A"}`);
315
317
  if (this.onToolCallback) {
316
318
  this.onToolCallback(toolData);
317
319
  }
318
- }
319
- }
320
- if ("status" in state && state.status === "completed") {
321
- const fileKey = `file-${part.callID}`;
322
- if (!this.processedToolStates.has(fileKey)) {
323
- this.processedToolStates.add(fileKey);
324
- if (part.tool === "write" && input && "content" in input && "filePath" in input) {
325
- const filePath = normalizePathForDisplay(input.filePath);
326
- const fileData = prepareCodeFile(input.content, filePath, "write");
327
- if (fileData && this.onToolFileCallback) {
328
- logger.debug(`[Aggregator] Sending write file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
329
- this.onToolFileCallback(fileData);
330
- }
331
- // Notify about file change for pinned message
332
- if (this.onFileChangeCallback) {
333
- const lines = input.content.split("\n").length;
334
- this.onFileChangeCallback({
335
- file: filePath,
336
- additions: lines,
337
- deletions: 0,
338
- });
339
- }
340
- }
341
- else if (part.tool === "edit" &&
342
- state.metadata &&
343
- "diff" in state.metadata &&
344
- "filediff" in state.metadata) {
345
- const filediff = state.metadata.filediff;
346
- const filePath = filediff.file ? normalizePathForDisplay(filediff.file) : undefined;
347
- const diff = state.metadata.diff;
348
- if (filePath && diff) {
349
- const fileData = prepareCodeFile(diff, filePath, "edit");
350
- if (fileData && this.onToolFileCallback) {
351
- logger.debug(`[Aggregator] Sending ${part.tool} file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
352
- this.onToolFileCallback(fileData);
353
- }
354
- // Notify about file change for pinned message
355
- if (this.onFileChangeCallback) {
356
- this.onFileChangeCallback({
357
- file: filePath,
358
- additions: filediff.additions || 0,
359
- deletions: filediff.deletions || 0,
360
- });
361
- }
362
- }
320
+ if (preparedFileContext.fileData && this.onToolFileCallback) {
321
+ logger.debug(`[Aggregator] Sending ${part.tool} file: ${preparedFileContext.fileData.filename} (${preparedFileContext.fileData.buffer.length} bytes)`);
322
+ this.onToolFileCallback({
323
+ ...toolData,
324
+ hasFileAttachment: true,
325
+ fileData: preparedFileContext.fileData,
326
+ });
363
327
  }
364
- else if (part.tool === "apply_patch") {
365
- const metadata = state.metadata;
366
- const filePathFromInput = input && typeof input.filePath === "string"
367
- ? normalizePathForDisplay(input.filePath)
368
- : input && typeof input.path === "string"
369
- ? normalizePathForDisplay(input.path)
370
- : "";
371
- const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
372
- const filePath = (metadata?.filediff?.file && normalizePathForDisplay(metadata.filediff.file)) ||
373
- filePathFromInput ||
374
- normalizePathForDisplay(filePathFromTitle);
375
- const diffText = typeof metadata?.diff === "string"
376
- ? metadata.diff
377
- : input && typeof input.patchText === "string"
378
- ? input.patchText
379
- : "";
380
- if (filePath && diffText) {
381
- const fileData = prepareCodeFile(diffText, filePath, "edit");
382
- if (fileData && this.onToolFileCallback) {
383
- logger.debug(`[Aggregator] Sending apply_patch file: ${fileData.filename} (${fileData.buffer.length} bytes)`);
384
- this.onToolFileCallback(fileData);
385
- }
386
- }
387
- if (filePath && this.onFileChangeCallback) {
388
- if (metadata?.filediff) {
389
- this.onFileChangeCallback({
390
- file: filePath,
391
- additions: metadata.filediff.additions || 0,
392
- deletions: metadata.filediff.deletions || 0,
393
- });
394
- }
395
- else if (diffText) {
396
- const changes = countDiffChangesFromText(diffText);
397
- this.onFileChangeCallback({
398
- file: filePath,
399
- additions: changes.additions,
400
- deletions: changes.deletions,
401
- });
402
- }
403
- }
328
+ if (preparedFileContext.fileChange && this.onFileChangeCallback) {
329
+ this.onFileChangeCallback(preparedFileContext.fileChange);
404
330
  }
405
331
  }
406
332
  }
407
333
  }
408
334
  this.lastUpdated = Date.now();
409
335
  }
336
+ prepareToolFileContext(tool, input, title, metadata) {
337
+ if (tool === "write" && input) {
338
+ const filePath = typeof input.filePath === "string" ? normalizePathForDisplay(input.filePath) : "";
339
+ const hasContent = typeof input.content === "string";
340
+ const content = hasContent ? input.content : "";
341
+ if (!filePath || !hasContent) {
342
+ return { fileData: null, fileChange: null };
343
+ }
344
+ return {
345
+ fileData: prepareCodeFile(content, filePath, "write"),
346
+ fileChange: {
347
+ file: filePath,
348
+ additions: content.split("\n").length,
349
+ deletions: 0,
350
+ },
351
+ };
352
+ }
353
+ if (tool === "edit" && metadata) {
354
+ const editMetadata = metadata;
355
+ const filePath = editMetadata.filediff?.file
356
+ ? normalizePathForDisplay(editMetadata.filediff.file)
357
+ : "";
358
+ const diffText = typeof editMetadata.diff === "string" ? editMetadata.diff : "";
359
+ if (!filePath || !diffText) {
360
+ return { fileData: null, fileChange: null };
361
+ }
362
+ return {
363
+ fileData: prepareCodeFile(diffText, filePath, "edit"),
364
+ fileChange: {
365
+ file: filePath,
366
+ additions: editMetadata.filediff?.additions || 0,
367
+ deletions: editMetadata.filediff?.deletions || 0,
368
+ },
369
+ };
370
+ }
371
+ if (tool === "apply_patch") {
372
+ const patchMetadata = metadata;
373
+ const filePathFromInput = input && typeof input.filePath === "string"
374
+ ? normalizePathForDisplay(input.filePath)
375
+ : input && typeof input.path === "string"
376
+ ? normalizePathForDisplay(input.path)
377
+ : "";
378
+ const filePathFromTitle = title ? extractFirstUpdatedFileFromTitle(title) : "";
379
+ const filePath = (patchMetadata?.filediff?.file && normalizePathForDisplay(patchMetadata.filediff.file)) ||
380
+ filePathFromInput ||
381
+ normalizePathForDisplay(filePathFromTitle);
382
+ const diffText = typeof patchMetadata?.diff === "string"
383
+ ? patchMetadata.diff
384
+ : input && typeof input.patchText === "string"
385
+ ? input.patchText
386
+ : "";
387
+ if (!filePath) {
388
+ return { fileData: null, fileChange: null };
389
+ }
390
+ const fileChange = patchMetadata?.filediff
391
+ ? {
392
+ file: filePath,
393
+ additions: patchMetadata.filediff.additions || 0,
394
+ deletions: patchMetadata.filediff.deletions || 0,
395
+ }
396
+ : diffText
397
+ ? (() => {
398
+ const changes = countDiffChangesFromText(diffText);
399
+ return {
400
+ file: filePath,
401
+ additions: changes.additions,
402
+ deletions: changes.deletions,
403
+ };
404
+ })()
405
+ : null;
406
+ return {
407
+ fileData: diffText ? prepareCodeFile(diffText, filePath, "edit") : null,
408
+ fileChange,
409
+ };
410
+ }
411
+ return { fileData: null, fileChange: null };
412
+ }
410
413
  hashString(str) {
411
414
  let hash = 0;
412
415
  for (let i = 0; i < str.length; i++) {
@@ -13,13 +13,16 @@ function normalizeIntervalSeconds(value) {
13
13
  }
14
14
  export class ToolMessageBatcher {
15
15
  intervalSeconds;
16
- sendMessage;
16
+ sendText;
17
+ sendFile;
17
18
  queues = new Map();
18
19
  timers = new Map();
20
+ sessionTasks = new Map();
19
21
  generation = 0;
20
22
  constructor(options) {
21
23
  this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
22
- this.sendMessage = options.sendMessage;
24
+ this.sendText = options.sendText;
25
+ this.sendFile = options.sendFile;
23
26
  }
24
27
  setIntervalSeconds(nextIntervalSeconds) {
25
28
  const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
@@ -47,29 +50,34 @@ export class ToolMessageBatcher {
47
50
  }
48
51
  if (this.intervalSeconds === 0) {
49
52
  const expectedGeneration = this.generation;
50
- logger.debug(`[ToolBatcher] Sending immediate message: session=${sessionId}`);
51
- void this.sendMessageSafe(sessionId, normalizedMessage, "immediate", expectedGeneration);
53
+ logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
54
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
52
55
  return;
53
56
  }
54
57
  const queue = this.queues.get(sessionId) ?? [];
55
- queue.push(normalizedMessage);
58
+ queue.push({ kind: "text", text: normalizedMessage });
56
59
  this.queues.set(sessionId, queue);
57
- logger.debug(`[ToolBatcher] Queued message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
60
+ logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
58
61
  this.ensureTimer(sessionId);
59
62
  }
60
- async flushSession(sessionId, reason) {
61
- const expectedGeneration = this.generation;
62
- this.clearTimer(sessionId);
63
- const queuedMessages = this.queues.get(sessionId);
64
- if (!queuedMessages || queuedMessages.length === 0) {
63
+ enqueueFile(sessionId, fileData) {
64
+ if (!sessionId) {
65
65
  return;
66
66
  }
67
- this.queues.delete(sessionId);
68
- const batches = this.packMessages(queuedMessages);
69
- logger.debug(`[ToolBatcher] Flushing ${queuedMessages.length} tool messages as ${batches.length} Telegram messages (session=${sessionId}, reason=${reason})`);
70
- for (const batchMessage of batches) {
71
- await this.sendMessageSafe(sessionId, batchMessage, reason, expectedGeneration);
67
+ if (this.intervalSeconds === 0) {
68
+ const expectedGeneration = this.generation;
69
+ logger.debug(`[ToolBatcher] Sending immediate file message: session=${sessionId}`);
70
+ void this.enqueueTask(sessionId, () => this.sendFileSafe(sessionId, fileData, "immediate", expectedGeneration));
71
+ return;
72
72
  }
73
+ const queue = this.queues.get(sessionId) ?? [];
74
+ queue.push({ kind: "file", fileData });
75
+ this.queues.set(sessionId, queue);
76
+ logger.debug(`[ToolBatcher] Queued file message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
77
+ this.ensureTimer(sessionId);
78
+ }
79
+ async flushSession(sessionId, reason) {
80
+ await this.enqueueTask(sessionId, () => this.flushSessionInternal(sessionId, reason));
73
81
  }
74
82
  async flushAll(reason) {
75
83
  for (const sessionId of Array.from(this.timers.keys())) {
@@ -121,17 +129,86 @@ export class ToolMessageBatcher {
121
129
  }, this.intervalSeconds * 1000);
122
130
  this.timers.set(sessionId, timer);
123
131
  }
124
- async sendMessageSafe(sessionId, text, reason, expectedGeneration) {
132
+ enqueueTask(sessionId, task) {
133
+ const previousTask = this.sessionTasks.get(sessionId) ?? Promise.resolve();
134
+ const nextTask = previousTask
135
+ .catch(() => undefined)
136
+ .then(task)
137
+ .finally(() => {
138
+ if (this.sessionTasks.get(sessionId) === nextTask) {
139
+ this.sessionTasks.delete(sessionId);
140
+ }
141
+ });
142
+ this.sessionTasks.set(sessionId, nextTask);
143
+ return nextTask;
144
+ }
145
+ async flushSessionInternal(sessionId, reason) {
146
+ const expectedGeneration = this.generation;
147
+ this.clearTimer(sessionId);
148
+ const queuedItems = this.queues.get(sessionId);
149
+ if (!queuedItems || queuedItems.length === 0) {
150
+ return;
151
+ }
152
+ this.queues.delete(sessionId);
153
+ const flushItems = this.buildFlushItems(queuedItems);
154
+ logger.debug(`[ToolBatcher] Flushing ${queuedItems.length} queued items as ${flushItems.length} Telegram sends (session=${sessionId}, reason=${reason})`);
155
+ for (const item of flushItems) {
156
+ if (item.kind === "text") {
157
+ await this.sendTextSafe(sessionId, item.text, reason, expectedGeneration);
158
+ }
159
+ else {
160
+ await this.sendFileSafe(sessionId, item.fileData, reason, expectedGeneration);
161
+ }
162
+ }
163
+ }
164
+ async sendTextSafe(sessionId, text, reason, expectedGeneration) {
125
165
  if (this.generation !== expectedGeneration) {
126
- logger.debug(`[ToolBatcher] Dropping stale tool batch message: session=${sessionId}, reason=${reason}`);
166
+ logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
127
167
  return;
128
168
  }
129
169
  try {
130
- await this.sendMessage(sessionId, text);
170
+ await this.sendText(sessionId, text);
131
171
  }
132
172
  catch (err) {
133
- logger.error(`[ToolBatcher] Failed to send tool batch message: session=${sessionId}, reason=${reason}`, err);
173
+ logger.error(`[ToolBatcher] Failed to send tool text message: session=${sessionId}, reason=${reason}`, err);
174
+ }
175
+ }
176
+ async sendFileSafe(sessionId, fileData, reason, expectedGeneration) {
177
+ if (this.generation !== expectedGeneration) {
178
+ logger.debug(`[ToolBatcher] Dropping stale tool file message: session=${sessionId}, reason=${reason}`);
179
+ return;
134
180
  }
181
+ try {
182
+ await this.sendFile(sessionId, fileData);
183
+ }
184
+ catch (err) {
185
+ logger.error(`[ToolBatcher] Failed to send tool file message: session=${sessionId}, reason=${reason}`, err);
186
+ }
187
+ }
188
+ buildFlushItems(entries) {
189
+ const result = [];
190
+ const textBuffer = [];
191
+ const flushTextBuffer = () => {
192
+ if (textBuffer.length === 0) {
193
+ return;
194
+ }
195
+ const packedTextMessages = this.packMessages(textBuffer);
196
+ for (const text of packedTextMessages) {
197
+ result.push({ kind: "text", text });
198
+ }
199
+ textBuffer.length = 0;
200
+ };
201
+ for (const entry of entries) {
202
+ if (entry.kind === "text") {
203
+ textBuffer.push(entry.text);
204
+ }
205
+ else {
206
+ flushTextBuffer();
207
+ result.push({ kind: "file", fileData: entry.fileData });
208
+ }
209
+ }
210
+ flushTextBuffer();
211
+ return result;
135
212
  }
136
213
  packMessages(messages) {
137
214
  const normalizedEntries = messages
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.6.0",
3
+ "version": "0.6.1-rc.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",