@grinev/opencode-telegram-bot 0.13.0 → 0.13.2

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.
@@ -19,9 +19,19 @@ const COMMANDS_CALLBACK_CANCEL = `${COMMANDS_CALLBACK_PREFIX}cancel`;
19
19
  const COMMANDS_CALLBACK_EXECUTE = `${COMMANDS_CALLBACK_PREFIX}execute`;
20
20
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
21
21
  function formatExecutingCommandMessage(commandName, args) {
22
- const commandText = `/${commandName}`;
22
+ const prefix = t("commands.executing_prefix");
23
+ const commandText = `/${commandName}`;
23
24
  const argsSuffix = args ? ` ${args}` : "";
24
- return `${t("commands.executing_prefix")}\n${commandText}${argsSuffix}`;
25
+ return {
26
+ text: `${prefix}\n${commandText}${argsSuffix}`,
27
+ entities: [
28
+ {
29
+ type: "code",
30
+ offset: prefix.length + 1,
31
+ length: commandText.length,
32
+ },
33
+ ],
34
+ };
25
35
  }
26
36
  export function buildCommandPageCallback(page) {
27
37
  return `${COMMANDS_CALLBACK_PAGE_PREFIX}${page}`;
@@ -181,7 +191,10 @@ async function getCommandList(projectDirectory) {
181
191
  throw error || new Error("No command data received");
182
192
  }
183
193
  return data
184
- .filter((command) => typeof command.name === "string" && command.name.trim().length > 0)
194
+ .filter((command) => {
195
+ const source = command.source;
196
+ return (typeof command.name === "string" && command.name.trim().length > 0 && source === "command");
197
+ })
185
198
  .map((command) => ({
186
199
  name: command.name,
187
200
  description: command.description,
@@ -252,7 +265,8 @@ async function executeCommand(ctx, deps, params) {
252
265
  return;
253
266
  }
254
267
  const args = params.argumentsText.trim();
255
- await ctx.reply(formatExecutingCommandMessage(params.commandName, args));
268
+ const executingMessage = formatExecutingCommandMessage(params.commandName, args);
269
+ await ctx.reply(executingMessage.text, { entities: executingMessage.entities });
256
270
  const session = await ensureSessionForProject(ctx, params.projectDirectory);
257
271
  if (!session) {
258
272
  return;
package/dist/bot/index.js CHANGED
@@ -56,6 +56,7 @@ import { getStoredModel } from "../model/manager.js";
56
56
  import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
57
57
  import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
58
58
  import { ResponseStreamer } from "./streaming/response-streamer.js";
59
+ import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
59
60
  import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./utils/send-with-markdown-fallback.js";
60
61
  let botInstance = null;
61
62
  let chatIdInstance = null;
@@ -187,6 +188,58 @@ const responseStreamer = new ResponseStreamer({
187
188
  });
188
189
  },
189
190
  });
191
+ const toolCallStreamer = new ToolCallStreamer({
192
+ throttleMs: RESPONSE_STREAM_THROTTLE_MS,
193
+ sendText: async (sessionId, text) => {
194
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
195
+ throw new Error("Bot context missing for tool stream send");
196
+ }
197
+ const currentSession = getCurrentSession();
198
+ if (!currentSession || currentSession.id !== sessionId) {
199
+ throw new Error(`Tool stream session mismatch for send: ${sessionId}`);
200
+ }
201
+ const sentMessage = await botInstance.api.sendMessage(chatIdInstance, text, {
202
+ disable_notification: true,
203
+ });
204
+ return sentMessage.message_id;
205
+ },
206
+ editText: async (sessionId, messageId, text) => {
207
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
208
+ throw new Error("Bot context missing for tool stream edit");
209
+ }
210
+ const currentSession = getCurrentSession();
211
+ if (!currentSession || currentSession.id !== sessionId) {
212
+ throw new Error(`Tool stream session mismatch for edit: ${sessionId}`);
213
+ }
214
+ try {
215
+ await botInstance.api.editMessageText(chatIdInstance, messageId, text);
216
+ }
217
+ catch (error) {
218
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
219
+ if (errorMessage.includes("message is not modified")) {
220
+ return;
221
+ }
222
+ throw error;
223
+ }
224
+ },
225
+ deleteText: async (sessionId, messageId) => {
226
+ if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
227
+ throw new Error("Bot context missing for tool stream delete");
228
+ }
229
+ const currentSession = getCurrentSession();
230
+ if (!currentSession || currentSession.id !== sessionId) {
231
+ throw new Error(`Tool stream session mismatch for delete: ${sessionId}`);
232
+ }
233
+ await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
234
+ const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
235
+ if (errorMessage.includes("message to delete not found") ||
236
+ errorMessage.includes("message identifier is not specified")) {
237
+ return;
238
+ }
239
+ throw error;
240
+ });
241
+ },
242
+ });
190
243
  async function ensureCommandsInitialized(ctx, next) {
191
244
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
192
245
  await next();
@@ -221,6 +274,7 @@ async function ensureEventSubscription(directory) {
221
274
  summaryAggregator.setTypingIndicatorEnabled(true);
222
275
  summaryAggregator.setOnCleared(() => {
223
276
  toolMessageBatcher.clearAll("summary_aggregator_clear");
277
+ toolCallStreamer.clearAll("summary_aggregator_clear");
224
278
  responseStreamer.clearAll("summary_aggregator_clear");
225
279
  });
226
280
  summaryAggregator.setOnPartial((sessionId, messageId, messageText) => {
@@ -246,12 +300,14 @@ async function ensureEventSubscription(directory) {
246
300
  if (!botInstance || !chatIdInstance) {
247
301
  logger.error("Bot or chat ID not available for sending message");
248
302
  responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
303
+ toolCallStreamer.clearSession(sessionId, "bot_context_missing");
249
304
  foregroundSessionState.markIdle(sessionId);
250
305
  return;
251
306
  }
252
307
  const currentSession = getCurrentSession();
253
308
  if (currentSession?.id !== sessionId) {
254
309
  responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
310
+ toolCallStreamer.clearSession(sessionId, "session_mismatch");
255
311
  foregroundSessionState.markIdle(sessionId);
256
312
  await scheduledTaskRuntime.flushDeferredDeliveries();
257
313
  return;
@@ -265,7 +321,10 @@ async function ensureEventSubscription(directory) {
265
321
  messageId,
266
322
  messageText,
267
323
  responseStreamer,
268
- flushPendingServiceMessages: () => toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
324
+ flushPendingServiceMessages: () => Promise.all([
325
+ toolMessageBatcher.flushSession(sessionId, "assistant_message_completed"),
326
+ toolCallStreamer.flushSession(sessionId, "assistant_message_completed"),
327
+ ]).then(() => undefined),
269
328
  prepareStreamingPayload,
270
329
  formatSummary,
271
330
  resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
@@ -315,7 +374,7 @@ async function ensureEventSubscription(directory) {
315
374
  try {
316
375
  const message = formatToolInfo(toolInfo);
317
376
  if (message) {
318
- toolMessageBatcher.enqueue(toolInfo.sessionId, message);
377
+ toolCallStreamer.append(toolInfo.sessionId, message);
319
378
  }
320
379
  }
321
380
  catch (err) {
@@ -332,6 +391,7 @@ async function ensureEventSubscription(directory) {
332
391
  return;
333
392
  }
334
393
  try {
394
+ await toolCallStreamer.breakSession(fileInfo.sessionId, "tool_file_boundary");
335
395
  const toolMessage = formatToolInfo(fileInfo);
336
396
  const caption = prepareDocumentCaption(toolMessage || fileInfo.fileData.caption);
337
397
  toolMessageBatcher.enqueueFile(fileInfo.sessionId, {
@@ -350,7 +410,10 @@ async function ensureEventSubscription(directory) {
350
410
  }
351
411
  const currentSession = getCurrentSession();
352
412
  if (currentSession) {
353
- await toolMessageBatcher.flushSession(currentSession.id, "question_asked");
413
+ await Promise.all([
414
+ toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
415
+ toolCallStreamer.flushSession(currentSession.id, "question_asked"),
416
+ ]);
354
417
  }
355
418
  if (questionManager.isActive()) {
356
419
  logger.warn("[Bot] Replacing active poll with a new one");
@@ -382,7 +445,10 @@ async function ensureEventSubscription(directory) {
382
445
  logger.error("Bot or chat ID not available for showing permission request");
383
446
  return;
384
447
  }
385
- await toolMessageBatcher.flushSession(request.sessionID, "permission_asked");
448
+ await Promise.all([
449
+ toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
450
+ toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
451
+ ]);
386
452
  logger.info(`[Bot] Received permission request from agent: type=${request.permission}, requestID=${request.id}`);
387
453
  await showPermissionRequest(botInstance.api, chatIdInstance, request);
388
454
  });
@@ -395,6 +461,7 @@ async function ensureEventSubscription(directory) {
395
461
  return;
396
462
  }
397
463
  logger.debug("[Bot] Agent started thinking");
464
+ await toolCallStreamer.breakSession(sessionId, "thinking_started");
398
465
  deliverThinkingMessage(sessionId, toolMessageBatcher, {
399
466
  responseStreaming: config.bot.responseStreaming,
400
467
  hideThinkingMessages: config.bot.hideThinkingMessages,
@@ -451,12 +518,16 @@ async function ensureEventSubscription(directory) {
451
518
  const currentSession = getCurrentSession();
452
519
  if (!currentSession || currentSession.id !== sessionId) {
453
520
  responseStreamer.clearSession(sessionId, "session_error_not_current");
521
+ toolCallStreamer.clearSession(sessionId, "session_error_not_current");
454
522
  foregroundSessionState.markIdle(sessionId);
455
523
  await scheduledTaskRuntime.flushDeferredDeliveries();
456
524
  return;
457
525
  }
458
526
  responseStreamer.clearSession(sessionId, "session_error");
459
- await toolMessageBatcher.flushSession(sessionId, "session_error");
527
+ await Promise.all([
528
+ toolMessageBatcher.flushSession(sessionId, "session_error"),
529
+ toolCallStreamer.flushSession(sessionId, "session_error"),
530
+ ]);
460
531
  const normalizedMessage = message.trim() || t("common.unknown_error");
461
532
  const truncatedMessage = normalizedMessage.length > 3500
462
533
  ? `${normalizedMessage.slice(0, 3497)}...`
@@ -482,7 +553,7 @@ async function ensureEventSubscription(directory) {
482
553
  ? `${normalizedMessage.slice(0, 3497)}...`
483
554
  : normalizedMessage;
484
555
  const retryMessage = t("bot.session_retry", { message: truncatedMessage });
485
- toolMessageBatcher.enqueueUniqueByPrefix(sessionId, retryMessage, SESSION_RETRY_PREFIX);
556
+ toolCallStreamer.replaceByPrefix(sessionId, SESSION_RETRY_PREFIX, retryMessage);
486
557
  });
487
558
  summaryAggregator.setOnSessionDiff(async (_sessionId, diffs) => {
488
559
  if (!pinnedMessageManager.isInitialized()) {
@@ -0,0 +1,285 @@
1
+ import { logger } from "../../utils/logger.js";
2
+ const TELEGRAM_MESSAGE_SAFE_LENGTH = 4000;
3
+ function getErrorMessage(error) {
4
+ if (error instanceof Error) {
5
+ return error.message;
6
+ }
7
+ return String(error);
8
+ }
9
+ function getRetryAfterMs(error) {
10
+ const message = getErrorMessage(error);
11
+ if (!/\b429\b/.test(message)) {
12
+ return null;
13
+ }
14
+ const retryMatch = message.match(/retry after\s+(\d+)/i);
15
+ if (!retryMatch) {
16
+ return null;
17
+ }
18
+ const seconds = Number.parseInt(retryMatch[1], 10);
19
+ if (!Number.isFinite(seconds) || seconds <= 0) {
20
+ return null;
21
+ }
22
+ return seconds * 1000;
23
+ }
24
+ function delay(ms) {
25
+ return new Promise((resolve) => {
26
+ setTimeout(resolve, ms);
27
+ });
28
+ }
29
+ function splitLongText(text, limit) {
30
+ if (text.length <= limit) {
31
+ return [text];
32
+ }
33
+ const chunks = [];
34
+ let remaining = text;
35
+ while (remaining.length > limit) {
36
+ let splitIndex = remaining.lastIndexOf("\n", limit);
37
+ if (splitIndex <= 0 || splitIndex < Math.floor(limit * 0.5)) {
38
+ splitIndex = limit;
39
+ }
40
+ chunks.push(remaining.slice(0, splitIndex));
41
+ remaining = remaining.slice(splitIndex).replace(/^\n+/, "");
42
+ }
43
+ if (remaining.length > 0) {
44
+ chunks.push(remaining);
45
+ }
46
+ return chunks;
47
+ }
48
+ function buildParts(entries) {
49
+ const text = entries
50
+ .map((entry) => entry.text.trim())
51
+ .filter(Boolean)
52
+ .join("\n\n");
53
+ if (!text) {
54
+ return [];
55
+ }
56
+ return splitLongText(text, TELEGRAM_MESSAGE_SAFE_LENGTH).filter(Boolean);
57
+ }
58
+ export class ToolCallStreamer {
59
+ throttleMs;
60
+ sendText;
61
+ editText;
62
+ deleteText;
63
+ states = new Map();
64
+ allStates = new Set();
65
+ constructor(options) {
66
+ this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
67
+ this.sendText = options.sendText;
68
+ this.editText = options.editText;
69
+ this.deleteText = options.deleteText;
70
+ }
71
+ append(sessionId, text) {
72
+ const normalizedText = text.trim();
73
+ if (!sessionId || !normalizedText) {
74
+ return;
75
+ }
76
+ const state = this.getOrCreateState(sessionId);
77
+ state.entries.push({ text: normalizedText });
78
+ state.latestParts = buildParts(state.entries);
79
+ this.ensureTimer(state);
80
+ }
81
+ replaceByPrefix(sessionId, prefix, text) {
82
+ const normalizedPrefix = prefix.trim();
83
+ const normalizedText = text.trim();
84
+ if (!sessionId || !normalizedPrefix || !normalizedText) {
85
+ return;
86
+ }
87
+ const state = this.getOrCreateState(sessionId);
88
+ const existingEntry = state.entries.find((entry) => entry.prefix === normalizedPrefix);
89
+ if (existingEntry) {
90
+ existingEntry.text = normalizedText;
91
+ }
92
+ else {
93
+ state.entries.push({ prefix: normalizedPrefix, text: normalizedText });
94
+ }
95
+ state.latestParts = buildParts(state.entries);
96
+ this.ensureTimer(state);
97
+ }
98
+ async flushSession(sessionId, reason) {
99
+ const state = this.states.get(sessionId);
100
+ if (!state) {
101
+ return;
102
+ }
103
+ this.clearTimer(state);
104
+ await this.enqueueTask(state, () => this.syncState(state, reason));
105
+ }
106
+ async breakSession(sessionId, reason) {
107
+ const state = this.states.get(sessionId);
108
+ if (!state) {
109
+ return;
110
+ }
111
+ state.isBreaking = true;
112
+ this.getOrCreateState(sessionId);
113
+ this.clearTimer(state);
114
+ await this.enqueueTask(state, () => this.syncState(state, reason));
115
+ this.cancelState(state);
116
+ this.removeState(state);
117
+ logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`);
118
+ }
119
+ clearSession(sessionId, reason) {
120
+ let clearedAny = false;
121
+ for (const state of Array.from(this.allStates)) {
122
+ if (state.sessionId !== sessionId) {
123
+ continue;
124
+ }
125
+ this.cancelState(state);
126
+ this.removeState(state);
127
+ clearedAny = true;
128
+ }
129
+ if (clearedAny) {
130
+ logger.debug(`[ToolCallStreamer] Cleared session stream: session=${sessionId}, reason=${reason}`);
131
+ }
132
+ }
133
+ clearAll(reason) {
134
+ for (const state of Array.from(this.allStates)) {
135
+ this.cancelState(state);
136
+ }
137
+ const count = this.allStates.size;
138
+ this.states.clear();
139
+ this.allStates.clear();
140
+ if (count > 0) {
141
+ logger.debug(`[ToolCallStreamer] Cleared all streams: count=${count}, reason=${reason}`);
142
+ }
143
+ }
144
+ getOrCreateState(sessionId) {
145
+ const existing = this.states.get(sessionId);
146
+ if (existing && !existing.isBroken && !existing.cancelled && !existing.isBreaking) {
147
+ return existing;
148
+ }
149
+ if (existing && (existing.isBroken || existing.cancelled)) {
150
+ this.clearTimer(existing);
151
+ this.removeState(existing);
152
+ }
153
+ const state = {
154
+ sessionId,
155
+ entries: [],
156
+ latestParts: [],
157
+ lastSentParts: [],
158
+ telegramMessageIds: [],
159
+ timer: null,
160
+ task: Promise.resolve(true),
161
+ cancelled: false,
162
+ isBroken: false,
163
+ isBreaking: false,
164
+ fatalErrorMessage: null,
165
+ fatalErrorLogged: false,
166
+ };
167
+ this.states.set(sessionId, state);
168
+ this.allStates.add(state);
169
+ return state;
170
+ }
171
+ clearTimer(state) {
172
+ if (!state.timer) {
173
+ return;
174
+ }
175
+ clearTimeout(state.timer);
176
+ state.timer = null;
177
+ }
178
+ ensureTimer(state) {
179
+ if (state.timer || state.isBroken || state.cancelled) {
180
+ return;
181
+ }
182
+ if (this.throttleMs === 0) {
183
+ void this.enqueueTask(state, () => this.syncState(state, "immediate")).catch((error) => {
184
+ logger.error(`[ToolCallStreamer] Immediate sync failed: session=${state.sessionId}`, error);
185
+ });
186
+ return;
187
+ }
188
+ state.timer = setTimeout(() => {
189
+ state.timer = null;
190
+ void this.enqueueTask(state, () => this.syncState(state, "throttle_elapsed")).catch((error) => {
191
+ logger.error(`[ToolCallStreamer] Throttled sync failed: session=${state.sessionId}`, error);
192
+ });
193
+ }, this.throttleMs);
194
+ }
195
+ enqueueTask(state, task) {
196
+ const nextTask = state.task.catch(() => false).then(task);
197
+ state.task = nextTask;
198
+ return nextTask;
199
+ }
200
+ cancelState(state) {
201
+ state.cancelled = true;
202
+ this.clearTimer(state);
203
+ }
204
+ removeState(state) {
205
+ if (this.states.get(state.sessionId) === state) {
206
+ this.states.delete(state.sessionId);
207
+ }
208
+ this.allStates.delete(state);
209
+ }
210
+ async syncState(state, reason) {
211
+ if (state.cancelled) {
212
+ return false;
213
+ }
214
+ if (state.isBroken) {
215
+ return false;
216
+ }
217
+ while (!state.isBroken && !state.cancelled) {
218
+ const parts = state.latestParts;
219
+ const unchanged = parts.length === state.lastSentParts.length &&
220
+ parts.every((part, index) => state.lastSentParts[index] === part);
221
+ if (unchanged) {
222
+ return state.telegramMessageIds.length > 0;
223
+ }
224
+ if (parts.length === 0) {
225
+ return state.telegramMessageIds.length > 0;
226
+ }
227
+ try {
228
+ await this.syncMessages(state, parts);
229
+ if (state.cancelled) {
230
+ return false;
231
+ }
232
+ logger.debug(`[ToolCallStreamer] Stream synced: session=${state.sessionId}, reason=${reason}, parts=${parts.length}`);
233
+ return true;
234
+ }
235
+ catch (error) {
236
+ const retryAfterMs = getRetryAfterMs(error);
237
+ if (retryAfterMs === null) {
238
+ this.markStreamBroken(state, error, reason);
239
+ return false;
240
+ }
241
+ const delayMs = Math.max(this.throttleMs, retryAfterMs);
242
+ logger.warn(`[ToolCallStreamer] Stream sync rate-limited, retrying in ${delayMs}ms: session=${state.sessionId}, reason=${reason}`, error);
243
+ await delay(delayMs);
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+ markStreamBroken(state, error, reason) {
249
+ state.isBroken = true;
250
+ state.fatalErrorMessage = getErrorMessage(error);
251
+ if (state.fatalErrorLogged) {
252
+ return;
253
+ }
254
+ state.fatalErrorLogged = true;
255
+ logger.error(`[ToolCallStreamer] Stream marked as broken: session=${state.sessionId}, reason=${reason}, error=${state.fatalErrorMessage}`, error);
256
+ }
257
+ async syncMessages(state, parts) {
258
+ for (let index = 0; index < parts.length; index++) {
259
+ if (state.cancelled) {
260
+ return;
261
+ }
262
+ const text = parts[index];
263
+ const currentMessageId = state.telegramMessageIds[index];
264
+ if (currentMessageId) {
265
+ await this.editText(state.sessionId, currentMessageId, text);
266
+ state.lastSentParts[index] = text;
267
+ continue;
268
+ }
269
+ const messageId = await this.sendText(state.sessionId, text);
270
+ state.telegramMessageIds[index] = messageId;
271
+ state.lastSentParts[index] = text;
272
+ }
273
+ for (let index = state.telegramMessageIds.length - 1; index >= parts.length; index--) {
274
+ if (state.cancelled) {
275
+ return;
276
+ }
277
+ const messageId = state.telegramMessageIds[index];
278
+ if (messageId) {
279
+ await this.deleteText(state.sessionId, messageId);
280
+ }
281
+ state.telegramMessageIds.pop();
282
+ state.lastSentParts.pop();
283
+ }
284
+ }
285
+ }
@@ -4,9 +4,6 @@ export function deliverThinkingMessage(sessionId, batcher, options) {
4
4
  return;
5
5
  }
6
6
  const message = t("bot.thinking");
7
- if (options.responseStreaming) {
8
- batcher.sendTextNow(sessionId, message, "thinking_started_streaming");
9
- return;
10
- }
11
- batcher.enqueue(sessionId, message);
7
+ void options.responseStreaming;
8
+ batcher.sendTextNow(sessionId, message, "thinking_started");
12
9
  }
package/dist/i18n/de.js CHANGED
@@ -273,7 +273,7 @@ export const de = {
273
273
  "task.schedule_too_frequent": "Der wiederkehrende Zeitplan ist zu häufig. Das minimale erlaubte Intervall ist einmal alle 5 Minuten.",
274
274
  "task.kind.cron": "wiederkehrend",
275
275
  "task.kind.once": "einmalig",
276
- "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}\n\n{result}",
276
+ "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}",
277
277
  "task.run.error": "🔴 Geplante Aufgabe fehlgeschlagen: {description}\n\nFehler: {error}",
278
278
  "tasklist.empty": "📭 Noch keine geplanten Aufgaben.",
279
279
  "tasklist.select": "Wähle eine geplante Aufgabe:",
package/dist/i18n/en.js CHANGED
@@ -273,7 +273,7 @@ export const en = {
273
273
  "task.schedule_too_frequent": "Recurring schedule is too frequent. The minimum allowed interval is once every 5 minutes.",
274
274
  "task.kind.cron": "recurring",
275
275
  "task.kind.once": "one-time",
276
- "task.run.success": "⏰ Scheduled task completed: {description}\n\n{result}",
276
+ "task.run.success": "⏰ Scheduled task completed: {description}",
277
277
  "task.run.error": "🔴 Scheduled task failed: {description}\n\nError: {error}",
278
278
  "tasklist.empty": "📭 No scheduled tasks yet.",
279
279
  "tasklist.select": "Select a scheduled task:",
package/dist/i18n/es.js CHANGED
@@ -273,7 +273,7 @@ export const es = {
273
273
  "task.schedule_too_frequent": "El horario recurrente es demasiado frecuente. El intervalo mínimo permitido es una vez cada 5 minutos.",
274
274
  "task.kind.cron": "recurrente",
275
275
  "task.kind.once": "única",
276
- "task.run.success": "⏰ Tarea programada completada: {description}\n\n{result}",
276
+ "task.run.success": "⏰ Tarea programada completada: {description}",
277
277
  "task.run.error": "🔴 La tarea programada falló: {description}\n\nError: {error}",
278
278
  "tasklist.empty": "📭 Aún no hay tareas programadas.",
279
279
  "tasklist.select": "Elige una tarea programada:",
package/dist/i18n/fr.js CHANGED
@@ -273,7 +273,7 @@ export const fr = {
273
273
  "task.schedule_too_frequent": "Le planning récurrent est trop fréquent. L'intervalle minimum autorisé est d'une fois toutes les 5 minutes.",
274
274
  "task.kind.cron": "récurrente",
275
275
  "task.kind.once": "ponctuelle",
276
- "task.run.success": "⏰ Tâche planifiée terminée : {description}\n\n{result}",
276
+ "task.run.success": "⏰ Tâche planifiée terminée : {description}",
277
277
  "task.run.error": "🔴 Échec de la tâche planifiée : {description}\n\nErreur : {error}",
278
278
  "tasklist.empty": "📭 Aucune tâche planifiée pour le moment.",
279
279
  "tasklist.select": "Sélectionnez une tâche planifiée :",
package/dist/i18n/ru.js CHANGED
@@ -273,7 +273,7 @@ export const ru = {
273
273
  "task.schedule_too_frequent": "Повторяющееся расписание слишком частое. Минимально допустимый интервал - один запуск в 5 минут.",
274
274
  "task.kind.cron": "повторяющаяся",
275
275
  "task.kind.once": "однократная",
276
- "task.run.success": "⏰ Задача по расписанию выполнена: {description}\n\n{result}",
276
+ "task.run.success": "⏰ Задача по расписанию выполнена: {description}",
277
277
  "task.run.error": "🔴 Ошибка выполнения задачи по расписанию: {description}\n\nОшибка: {error}",
278
278
  "tasklist.empty": "📭 Задач по расписанию пока нет.",
279
279
  "tasklist.select": "Выберите задачу по расписанию:",
package/dist/i18n/zh.js CHANGED
@@ -273,7 +273,7 @@ export const zh = {
273
273
  "task.schedule_too_frequent": "重复任务过于频繁。最小允许间隔为每 5 分钟一次。",
274
274
  "task.kind.cron": "重复",
275
275
  "task.kind.once": "一次性",
276
- "task.run.success": "⏰ 定时任务已完成: {description}\n\n{result}",
276
+ "task.run.success": "⏰ 定时任务已完成: {description}",
277
277
  "task.run.error": "🔴 定时任务执行失败: {description}\n\n错误: {error}",
278
278
  "tasklist.empty": "📭 还没有定时任务。",
279
279
  "tasklist.select": "请选择一个定时任务:",
@@ -1,4 +1,5 @@
1
1
  import { config } from "../config.js";
2
+ import { escapePlainTextForTelegramMarkdownV2, formatSummaryWithMode, } from "../summary/formatter.js";
2
3
  import { t } from "../i18n/index.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
@@ -7,28 +8,30 @@ import { executeScheduledTask } from "./executor.js";
7
8
  import { foregroundSessionState } from "./foreground-state.js";
8
9
  import { computeNextRunAt, isTaskDue } from "./next-run.js";
9
10
  import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
10
- const TELEGRAM_MESSAGE_LIMIT = 4096;
11
11
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
12
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
12
13
  const TASK_DESCRIPTION_PREVIEW_LENGTH = 64;
13
14
  const RESTART_INTERRUPTED_ERROR = "Interrupted by bot restart during scheduled task execution.";
14
- function splitTelegramText(text) {
15
- if (text.length <= TELEGRAM_MESSAGE_LIMIT) {
16
- return [text];
15
+ function getScheduledTaskDeliveryFormat() {
16
+ return config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw";
17
+ }
18
+ function buildScheduledTaskSuccessMessageParts(delivery) {
19
+ if (!delivery.resultText) {
20
+ return [delivery.notificationText];
17
21
  }
18
- const parts = [];
19
- let remaining = text;
20
- while (remaining.length > TELEGRAM_MESSAGE_LIMIT) {
21
- let splitIndex = remaining.lastIndexOf("\n", TELEGRAM_MESSAGE_LIMIT);
22
- if (splitIndex <= 0 || splitIndex < Math.floor(TELEGRAM_MESSAGE_LIMIT * 0.5)) {
23
- splitIndex = TELEGRAM_MESSAGE_LIMIT;
24
- }
25
- parts.push(remaining.slice(0, splitIndex).trim());
26
- remaining = remaining.slice(splitIndex).trimStart();
22
+ if (config.bot.messageFormatMode !== "markdown") {
23
+ return formatSummaryWithMode(`${delivery.notificationText}\n\n${delivery.resultText}`, config.bot.messageFormatMode);
24
+ }
25
+ const header = escapePlainTextForTelegramMarkdownV2(delivery.notificationText);
26
+ const resultParts = formatSummaryWithMode(delivery.resultText, config.bot.messageFormatMode);
27
+ if (resultParts.length === 0) {
28
+ return [header];
27
29
  }
28
- if (remaining.trim()) {
29
- parts.push(remaining.trim());
30
+ const firstPart = `${header}\n\n${resultParts[0]}`;
31
+ if (firstPart.length <= TELEGRAM_MESSAGE_LIMIT) {
32
+ return [firstPart, ...resultParts.slice(1)];
30
33
  }
31
- return parts;
34
+ return [header, ...resultParts];
32
35
  }
33
36
  function normalizeTaskPrompt(prompt) {
34
37
  const normalized = prompt.replace(/\s+/g, " ").trim();
@@ -44,10 +47,10 @@ function buildSuccessDelivery(task, runAt, resultText) {
44
47
  prompt: task.prompt,
45
48
  runAt,
46
49
  status: "success",
47
- messageText: t("task.run.success", {
50
+ notificationText: t("task.run.success", {
48
51
  description: normalizeTaskPrompt(task.prompt),
49
- result: resultText,
50
52
  }),
53
+ resultText,
51
54
  };
52
55
  }
53
56
  function buildErrorDelivery(task, runAt, errorMessage) {
@@ -57,7 +60,7 @@ function buildErrorDelivery(task, runAt, errorMessage) {
57
60
  prompt: task.prompt,
58
61
  runAt,
59
62
  status: "error",
60
- messageText: t("task.run.error", {
63
+ notificationText: t("task.run.error", {
61
64
  description: normalizeTaskPrompt(task.prompt),
62
65
  error: errorMessage,
63
66
  }),
@@ -342,13 +345,16 @@ export class ScheduledTaskRuntime {
342
345
  return false;
343
346
  }
344
347
  try {
345
- const messageParts = splitTelegramText(delivery.messageText);
348
+ const messageParts = delivery.status === "success"
349
+ ? buildScheduledTaskSuccessMessageParts(delivery)
350
+ : [delivery.notificationText];
351
+ const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
346
352
  for (const part of messageParts) {
347
353
  await sendBotText({
348
354
  api: this.botApi,
349
355
  chatId: this.chatId,
350
356
  text: part,
351
- format: "raw",
357
+ format,
352
358
  });
353
359
  }
354
360
  return true;
@@ -5,6 +5,7 @@ import { logger } from "../utils/logger.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { getCurrentProject } from "../settings/manager.js";
7
7
  const TELEGRAM_MESSAGE_LIMIT = 4096;
8
+ const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
8
9
  function splitText(text, maxLength) {
9
10
  const parts = [];
10
11
  let currentIndex = 0;
@@ -134,16 +135,48 @@ export function getAssistantParseMode() {
134
135
  }
135
136
  return undefined;
136
137
  }
138
+ export function escapePlainTextForTelegramMarkdownV2(text) {
139
+ return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
140
+ }
137
141
  function formatMarkdownForTelegram(text) {
138
142
  try {
139
143
  const preprocessed = preprocessMarkdownForTelegram(text);
140
- return convert(preprocessed, "keep");
144
+ return escapeMarkdownV2PipesOutsideCode(convert(preprocessed, "keep"));
141
145
  }
142
146
  catch (error) {
143
147
  logger.warn("[Formatter] Failed to convert markdown summary, falling back to raw text", error);
144
148
  return text;
145
149
  }
146
150
  }
151
+ function escapeMarkdownV2PipesOutsideCode(text) {
152
+ let result = "";
153
+ let index = 0;
154
+ let inInlineCode = false;
155
+ let inCodeFence = false;
156
+ while (index < text.length) {
157
+ if (text.startsWith("```", index)) {
158
+ result += "```";
159
+ index += 3;
160
+ inCodeFence = !inCodeFence;
161
+ continue;
162
+ }
163
+ const char = text[index];
164
+ if (!inCodeFence && char === "`") {
165
+ inInlineCode = !inInlineCode;
166
+ result += char;
167
+ index += 1;
168
+ continue;
169
+ }
170
+ if (!inCodeFence && !inInlineCode && char === "|" && text[index - 1] !== "\\") {
171
+ result += "\\|";
172
+ index += 1;
173
+ continue;
174
+ }
175
+ result += char;
176
+ index += 1;
177
+ }
178
+ return result;
179
+ }
147
180
  export function formatSummaryWithMode(text, mode, maxLength = TELEGRAM_MESSAGE_LIMIT) {
148
181
  if (!text || text.trim().length === 0) {
149
182
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
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",