@grinev/opencode-telegram-bot 0.5.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.
@@ -0,0 +1,259 @@
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
+ sendText;
17
+ sendFile;
18
+ queues = new Map();
19
+ timers = new Map();
20
+ sessionTasks = new Map();
21
+ generation = 0;
22
+ constructor(options) {
23
+ this.intervalSeconds = normalizeIntervalSeconds(options.intervalSeconds);
24
+ this.sendText = options.sendText;
25
+ this.sendFile = options.sendFile;
26
+ }
27
+ setIntervalSeconds(nextIntervalSeconds) {
28
+ const normalized = normalizeIntervalSeconds(nextIntervalSeconds);
29
+ if (this.intervalSeconds === normalized) {
30
+ return;
31
+ }
32
+ this.intervalSeconds = normalized;
33
+ logger.info(`[ToolBatcher] Interval updated: ${normalized}s`);
34
+ if (normalized === 0) {
35
+ void this.flushAll("interval_updated");
36
+ return;
37
+ }
38
+ const sessionIds = Array.from(this.queues.keys());
39
+ for (const sessionId of sessionIds) {
40
+ this.restartTimer(sessionId);
41
+ }
42
+ }
43
+ getIntervalSeconds() {
44
+ return this.intervalSeconds;
45
+ }
46
+ enqueue(sessionId, message) {
47
+ const normalizedMessage = message.trim();
48
+ if (!sessionId || normalizedMessage.length === 0) {
49
+ return;
50
+ }
51
+ if (this.intervalSeconds === 0) {
52
+ const expectedGeneration = this.generation;
53
+ logger.debug(`[ToolBatcher] Sending immediate text message: session=${sessionId}`);
54
+ void this.enqueueTask(sessionId, () => this.sendTextSafe(sessionId, normalizedMessage, "immediate", expectedGeneration));
55
+ return;
56
+ }
57
+ const queue = this.queues.get(sessionId) ?? [];
58
+ queue.push({ kind: "text", text: normalizedMessage });
59
+ this.queues.set(sessionId, queue);
60
+ logger.debug(`[ToolBatcher] Queued text message: session=${sessionId}, queueSize=${queue.length}, interval=${this.intervalSeconds}s`);
61
+ this.ensureTimer(sessionId);
62
+ }
63
+ enqueueFile(sessionId, fileData) {
64
+ if (!sessionId) {
65
+ return;
66
+ }
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
+ }
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));
81
+ }
82
+ async flushAll(reason) {
83
+ for (const sessionId of Array.from(this.timers.keys())) {
84
+ this.clearTimer(sessionId);
85
+ }
86
+ const sessionIds = Array.from(this.queues.keys());
87
+ for (const sessionId of sessionIds) {
88
+ await this.flushSession(sessionId, reason);
89
+ }
90
+ }
91
+ clearSession(sessionId, reason) {
92
+ this.generation++;
93
+ this.clearTimer(sessionId);
94
+ if (this.queues.delete(sessionId)) {
95
+ logger.debug(`[ToolBatcher] Cleared session queue: session=${sessionId}, reason=${reason}`);
96
+ }
97
+ }
98
+ clearAll(reason) {
99
+ this.generation++;
100
+ for (const timer of this.timers.values()) {
101
+ clearTimeout(timer);
102
+ }
103
+ const queuedSessions = this.queues.size;
104
+ this.timers.clear();
105
+ this.queues.clear();
106
+ if (queuedSessions > 0) {
107
+ logger.debug(`[ToolBatcher] Cleared all queued tool messages: sessions=${queuedSessions}, reason=${reason}`);
108
+ }
109
+ }
110
+ clearTimer(sessionId) {
111
+ const timer = this.timers.get(sessionId);
112
+ if (!timer) {
113
+ return;
114
+ }
115
+ clearTimeout(timer);
116
+ this.timers.delete(sessionId);
117
+ }
118
+ ensureTimer(sessionId) {
119
+ if (this.timers.has(sessionId)) {
120
+ return;
121
+ }
122
+ this.restartTimer(sessionId);
123
+ }
124
+ restartTimer(sessionId) {
125
+ this.clearTimer(sessionId);
126
+ const timer = setTimeout(() => {
127
+ this.timers.delete(sessionId);
128
+ void this.flushSession(sessionId, "interval_elapsed");
129
+ }, this.intervalSeconds * 1000);
130
+ this.timers.set(sessionId, timer);
131
+ }
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) {
165
+ if (this.generation !== expectedGeneration) {
166
+ logger.debug(`[ToolBatcher] Dropping stale tool text message: session=${sessionId}, reason=${reason}`);
167
+ return;
168
+ }
169
+ try {
170
+ await this.sendText(sessionId, text);
171
+ }
172
+ catch (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;
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;
212
+ }
213
+ packMessages(messages) {
214
+ const normalizedEntries = messages
215
+ .flatMap((message) => this.splitLongText(message, TELEGRAM_MESSAGE_MAX_LENGTH))
216
+ .filter((entry) => entry.length > 0);
217
+ if (normalizedEntries.length === 0) {
218
+ return [];
219
+ }
220
+ const result = [];
221
+ let current = "";
222
+ for (const entry of normalizedEntries) {
223
+ if (!current) {
224
+ current = entry;
225
+ continue;
226
+ }
227
+ const candidate = `${current}\n\n${entry}`;
228
+ if (candidate.length <= TELEGRAM_MESSAGE_MAX_LENGTH) {
229
+ current = candidate;
230
+ continue;
231
+ }
232
+ result.push(current);
233
+ current = entry;
234
+ }
235
+ if (current) {
236
+ result.push(current);
237
+ }
238
+ return result;
239
+ }
240
+ splitLongText(text, limit) {
241
+ if (text.length <= limit) {
242
+ return [text];
243
+ }
244
+ const chunks = [];
245
+ let remaining = text;
246
+ while (remaining.length > limit) {
247
+ let splitIndex = remaining.lastIndexOf("\n", limit);
248
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
249
+ splitIndex = limit;
250
+ }
251
+ chunks.push(remaining.slice(0, splitIndex));
252
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
253
+ }
254
+ if (remaining.length > 0) {
255
+ chunks.push(remaining);
256
+ }
257
+ return chunks;
258
+ }
259
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.5.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",