@grinev/opencode-telegram-bot 0.4.0 → 0.6.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 (36) hide show
  1. package/.env.example +11 -0
  2. package/README.md +26 -15
  3. package/dist/bot/commands/definitions.js +7 -4
  4. package/dist/bot/commands/help.js +7 -1
  5. package/dist/bot/commands/new.js +4 -0
  6. package/dist/bot/commands/projects.js +18 -9
  7. package/dist/bot/commands/rename.js +49 -1
  8. package/dist/bot/commands/sessions.js +15 -2
  9. package/dist/bot/commands/stop.js +3 -5
  10. package/dist/bot/handlers/agent.js +12 -1
  11. package/dist/bot/handlers/context.js +15 -25
  12. package/dist/bot/handlers/inline-menu.js +119 -0
  13. package/dist/bot/handlers/model.js +15 -2
  14. package/dist/bot/handlers/permission.js +81 -12
  15. package/dist/bot/handlers/question.js +97 -9
  16. package/dist/bot/handlers/variant.js +12 -1
  17. package/dist/bot/index.js +92 -16
  18. package/dist/bot/middleware/interaction-guard.js +80 -0
  19. package/dist/bot/middleware/unknown-command.js +22 -0
  20. package/dist/bot/utils/commands.js +21 -0
  21. package/dist/config.js +31 -0
  22. package/dist/i18n/en.js +23 -4
  23. package/dist/i18n/ru.js +23 -4
  24. package/dist/interaction/cleanup.js +24 -0
  25. package/dist/interaction/guard.js +87 -0
  26. package/dist/interaction/manager.js +106 -0
  27. package/dist/interaction/types.js +1 -0
  28. package/dist/permission/manager.js +60 -38
  29. package/dist/pinned/manager.js +7 -5
  30. package/dist/question/manager.js +33 -0
  31. package/dist/rename/manager.js +3 -0
  32. package/dist/settings/manager.js +6 -1
  33. package/dist/summary/aggregator.js +87 -6
  34. package/dist/summary/formatter.js +91 -15
  35. package/dist/summary/tool-message-batcher.js +182 -0
  36. package/package.json +1 -1
@@ -2,6 +2,7 @@ import * as path from "path";
2
2
  import { config } from "../config.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { t } from "../i18n/index.js";
5
+ import { getCurrentProject } from "../settings/manager.js";
5
6
  const TELEGRAM_MESSAGE_LIMIT = 4096;
6
7
  function splitText(text, maxLength) {
7
8
  const parts = [];
@@ -21,6 +22,27 @@ function splitText(text, maxLength) {
21
22
  }
22
23
  return parts;
23
24
  }
25
+ export function normalizePathForDisplay(filePath) {
26
+ const normalizedPath = filePath.replace(/\\/g, "/");
27
+ const project = getCurrentProject();
28
+ if (!project?.worktree) {
29
+ return normalizedPath;
30
+ }
31
+ const normalizedWorktree = project.worktree.replace(/\\/g, "/").replace(/\/+$/, "");
32
+ if (!normalizedWorktree) {
33
+ return normalizedPath;
34
+ }
35
+ const pathForCompare = process.platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath;
36
+ const worktreeForCompare = process.platform === "win32" ? normalizedWorktree.toLowerCase() : normalizedWorktree;
37
+ if (pathForCompare === worktreeForCompare) {
38
+ return ".";
39
+ }
40
+ const worktreePrefix = `${worktreeForCompare}/`;
41
+ if (pathForCompare.startsWith(worktreePrefix)) {
42
+ return normalizedPath.slice(normalizedWorktree.length + 1);
43
+ }
44
+ return normalizedPath;
45
+ }
24
46
  export function formatSummary(text) {
25
47
  if (!text || text.trim().length === 0) {
26
48
  return [];
@@ -50,9 +72,10 @@ function getToolDetails(tool, input) {
50
72
  case "read":
51
73
  case "edit":
52
74
  case "write":
53
- const path = input.path || input.filePath;
54
- if (typeof path === "string")
55
- return path;
75
+ case "apply_patch":
76
+ const filePath = input.path || input.filePath;
77
+ if (typeof filePath === "string")
78
+ return normalizePathForDisplay(filePath);
56
79
  break;
57
80
  case "bash":
58
81
  if (typeof input.command === "string")
@@ -88,6 +111,8 @@ function getToolIcon(tool) {
88
111
  return "✍️";
89
112
  case "edit":
90
113
  return "✏️";
114
+ case "apply_patch":
115
+ return "🩹";
91
116
  case "bash":
92
117
  return "💻";
93
118
  case "glob":
@@ -133,6 +158,37 @@ function formatTodos(todos) {
133
158
  }
134
159
  return result;
135
160
  }
161
+ function formatDiffLineInfo(filediff) {
162
+ const parts = [];
163
+ if (filediff.additions && filediff.additions > 0)
164
+ parts.push(`+${filediff.additions}`);
165
+ if (filediff.deletions && filediff.deletions > 0)
166
+ parts.push(`-${filediff.deletions}`);
167
+ return parts.length > 0 ? ` (${parts.join(" ")})` : "";
168
+ }
169
+ function countDiffChangesFromText(text) {
170
+ let additions = 0;
171
+ let deletions = 0;
172
+ for (const line of text.split("\n")) {
173
+ if (line.startsWith("+") && !line.startsWith("+++")) {
174
+ additions++;
175
+ continue;
176
+ }
177
+ if (line.startsWith("-") && !line.startsWith("---")) {
178
+ deletions++;
179
+ }
180
+ }
181
+ return { additions, deletions };
182
+ }
183
+ function extractFirstUpdatedFileFromTitle(title) {
184
+ for (const rawLine of title.split("\n")) {
185
+ const line = rawLine.trim();
186
+ if (line.length >= 3 && line[1] === " " && /[AMDURC]/.test(line[0])) {
187
+ return line.slice(2).trim();
188
+ }
189
+ }
190
+ return "";
191
+ }
136
192
  export function formatToolInfo(toolInfo) {
137
193
  const { tool, input, title } = toolInfo;
138
194
  logger.debug(`[Formatter] formatToolInfo: tool=${tool}, hasMetadata=${!!toolInfo.metadata}, hasFilediff=${!!toolInfo.metadata?.filediff}`);
@@ -151,22 +207,41 @@ export function formatToolInfo(toolInfo) {
151
207
  if (tool === "bash" && input && typeof input.command === "string") {
152
208
  details = input.command;
153
209
  }
210
+ if (tool === "apply_patch") {
211
+ const filediff = toolInfo.metadata && "filediff" in toolInfo.metadata
212
+ ? toolInfo.metadata.filediff
213
+ : undefined;
214
+ if (filediff?.file) {
215
+ details = normalizePathForDisplay(filediff.file);
216
+ }
217
+ else if (title) {
218
+ const fileFromTitle = extractFirstUpdatedFileFromTitle(title);
219
+ if (fileFromTitle) {
220
+ details = normalizePathForDisplay(fileFromTitle);
221
+ }
222
+ }
223
+ }
154
224
  const detailsStr = details ? ` ${details}` : "";
155
225
  let lineInfo = "";
156
226
  if (tool === "write" && input && "content" in input && typeof input.content === "string") {
157
227
  const lines = countLines(input.content);
158
228
  lineInfo = ` (+${lines})`;
159
229
  }
160
- if (tool === "edit" && toolInfo.metadata && "filediff" in toolInfo.metadata) {
230
+ if ((tool === "edit" || tool === "apply_patch") &&
231
+ toolInfo.metadata &&
232
+ "filediff" in toolInfo.metadata) {
161
233
  const filediff = toolInfo.metadata.filediff;
162
- logger.debug("[Formatter] Edit metadata:", JSON.stringify(toolInfo.metadata, null, 2));
163
- const parts = [];
164
- if (filediff.additions && filediff.additions > 0)
165
- parts.push(`+${filediff.additions}`);
166
- if (filediff.deletions && filediff.deletions > 0)
167
- parts.push(`-${filediff.deletions}`);
168
- if (parts.length > 0) {
169
- lineInfo = ` (${parts.join(" ")})`;
234
+ logger.debug("[Formatter] Diff metadata:", JSON.stringify(toolInfo.metadata, null, 2));
235
+ lineInfo = formatDiffLineInfo(filediff);
236
+ }
237
+ if (tool === "apply_patch" && !lineInfo) {
238
+ const diffText = toolInfo.metadata && typeof toolInfo.metadata.diff === "string"
239
+ ? toolInfo.metadata.diff
240
+ : input && typeof input.patchText === "string"
241
+ ? input.patchText
242
+ : "";
243
+ if (diffText) {
244
+ lineInfo = formatDiffLineInfo(countDiffChangesFromText(diffText));
170
245
  }
171
246
  }
172
247
  return `${toolIcon} ${description}${tool}${detailsStr}${lineInfo}`;
@@ -209,18 +284,19 @@ function formatDiff(diff) {
209
284
  return formattedLines.join("\n");
210
285
  }
211
286
  export function prepareCodeFile(content, filePath, operation) {
287
+ const displayPath = normalizePathForDisplay(filePath);
212
288
  let processedContent = content;
213
289
  if (operation === "edit") {
214
290
  processedContent = formatDiff(content);
215
291
  }
216
292
  const sizeKb = Buffer.byteLength(processedContent, "utf8") / 1024;
217
293
  if (sizeKb > config.files.maxFileSizeKb) {
218
- logger.debug(`[Formatter] File too large: ${filePath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
294
+ logger.debug(`[Formatter] File too large: ${displayPath} (${sizeKb.toFixed(2)} KB > ${config.files.maxFileSizeKb} KB)`);
219
295
  return null;
220
296
  }
221
297
  const header = operation === "write"
222
- ? t("tool.file_header.write", { path: filePath })
223
- : t("tool.file_header.edit", { path: filePath });
298
+ ? t("tool.file_header.write", { path: displayPath })
299
+ : t("tool.file_header.edit", { path: displayPath });
224
300
  const fullContent = header + processedContent;
225
301
  const buffer = Buffer.from(fullContent, "utf8");
226
302
  const basename = path.basename(filePath);
@@ -0,0 +1,182 @@
1
+ import { logger } from "../utils/logger.js";
2
+ const DEFAULT_INTERVAL_SECONDS = 5;
3
+ const TELEGRAM_MESSAGE_MAX_LENGTH = 4096;
4
+ function normalizeIntervalSeconds(value) {
5
+ if (!Number.isFinite(value)) {
6
+ return DEFAULT_INTERVAL_SECONDS;
7
+ }
8
+ const normalized = Math.floor(value);
9
+ if (normalized < 0) {
10
+ return DEFAULT_INTERVAL_SECONDS;
11
+ }
12
+ return normalized;
13
+ }
14
+ export class ToolMessageBatcher {
15
+ intervalSeconds;
16
+ sendMessage;
17
+ queues = new Map();
18
+ timers = new Map();
19
+ generation = 0;
20
+ constructor(options) {
21
+ this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
22
+ this.sendMessage = options.sendMessage;
23
+ }
24
+ setIntervalSeconds(nextIntervalSeconds) {
25
+ const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
26
+ if (this.intervalSeconds === normalized) {
27
+ return;
28
+ }
29
+ this.intervalSeconds = normalized;
30
+ logger.info(`[ToolBatcher] Interval updated: ${normalized}s`);
31
+ if (normalized === 0) {
32
+ void this.flushAll("interval_updated");
33
+ return;
34
+ }
35
+ const sessionIds = Array.from(this.queues.keys());
36
+ for (const sessionId of sessionIds) {
37
+ this.restartTimer(sessionId);
38
+ }
39
+ }
40
+ getIntervalSeconds() {
41
+ return this.intervalSeconds;
42
+ }
43
+ enqueue(sessionId, message) {
44
+ const normalizedMessage = message.trim();
45
+ if (!sessionId || normalizedMessage.length === 0) {
46
+ return;
47
+ }
48
+ if (this.intervalSeconds === 0) {
49
+ const expectedGeneration = this.generation;
50
+ logger.debug(`[ToolBatcher] Sending immediate message: session=${sessionId}`);
51
+ void this.sendMessageSafe(sessionId, normalizedMessage, "immediate", expectedGeneration);
52
+ return;
53
+ }
54
+ const queue = this.queues.get(sessionId) ?? [];
55
+ queue.push(normalizedMessage);
56
+ this.queues.set(sessionId, queue);
57
+ logger.debug(`[ToolBatcher] Queued message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
58
+ this.ensureTimer(sessionId);
59
+ }
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) {
65
+ return;
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);
72
+ }
73
+ }
74
+ async flushAll(reason) {
75
+ for (const sessionId of Array.from(this.timers.keys())) {
76
+ this.clearTimer(sessionId);
77
+ }
78
+ const sessionIds = Array.from(this.queues.keys());
79
+ for (const sessionId of sessionIds) {
80
+ await this.flushSession(sessionId, reason);
81
+ }
82
+ }
83
+ clearSession(sessionId, reason) {
84
+ this.generation++;
85
+ this.clearTimer(sessionId);
86
+ if (this.queues.delete(sessionId)) {
87
+ logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
88
+ }
89
+ }
90
+ clearAll(reason) {
91
+ this.generation++;
92
+ for (const timer of this.timers.values()) {
93
+ clearTimeout(timer);
94
+ }
95
+ const queuedSessions = this.queues.size;
96
+ this.timers.clear();
97
+ this.queues.clear();
98
+ if (queuedSessions > 0) {
99
+ logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
100
+ }
101
+ }
102
+ clearTimer(sessionId) {
103
+ const timer = this.timers.get(sessionId);
104
+ if (!timer) {
105
+ return;
106
+ }
107
+ clearTimeout(timer);
108
+ this.timers.delete(sessionId);
109
+ }
110
+ ensureTimer(sessionId) {
111
+ if (this.timers.has(sessionId)) {
112
+ return;
113
+ }
114
+ this.restartTimer(sessionId);
115
+ }
116
+ restartTimer(sessionId) {
117
+ this.clearTimer(sessionId);
118
+ const timer = setTimeout(() => {
119
+ this.timers.delete(sessionId);
120
+ void this.flushSession(sessionId, "interval_elapsed");
121
+ }, this.intervalSeconds * 1000);
122
+ this.timers.set(sessionId, timer);
123
+ }
124
+ async sendMessageSafe(sessionId, text, reason, expectedGeneration) {
125
+ if (this.generation !== expectedGeneration) {
126
+ logger.debug(`[ToolBatcher] Dropping stale tool batch message: session=${sessionId}, reason=${reason}`);
127
+ return;
128
+ }
129
+ try {
130
+ await this.sendMessage(sessionId, text);
131
+ }
132
+ catch (err) {
133
+ logger.error(`[ToolBatcher] Failed to send tool batch message: session=${sessionId}, reason=${reason}`, err);
134
+ }
135
+ }
136
+ packMessages(messages) {
137
+ const normalizedEntries = messages
138
+ .flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
139
+ .filter((entry) => entry.length > 0);
140
+ if (normalizedEntries.length === 0) {
141
+ return [];
142
+ }
143
+ const result = [];
144
+ let current = "";
145
+ for (const entry of normalizedEntries) {
146
+ if (!current) {
147
+ current = entry;
148
+ continue;
149
+ }
150
+ const candidate = `${current}\n\n${entry}`;
151
+ if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
152
+ current = candidate;
153
+ continue;
154
+ }
155
+ result.push(current);
156
+ current = entry;
157
+ }
158
+ if (current) {
159
+ result.push(current);
160
+ }
161
+ return result;
162
+ }
163
+ splitLongText(text, limit) {
164
+ if (text.length <= limit) {
165
+ return [text];
166
+ }
167
+ const chunks = [];
168
+ let remaining = text;
169
+ while (remaining.length > limit) {
170
+ let splitIndex = remaining.lastIndexOf("\n", limit);
171
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
172
+ splitIndex = limit;
173
+ }
174
+ chunks.push(remaining.slice(0, splitIndex));
175
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
176
+ }
177
+ if (remaining.length > 0) {
178
+ chunks.push(remaining);
179
+ }
180
+ return chunks;
181
+ }
182
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",