@grinev/opencode-telegram-bot 0.14.0 → 0.15.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.
package/dist/bot/index.js CHANGED
@@ -23,6 +23,7 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
23
23
  import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
24
24
  import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
25
25
  import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
26
+ import { ttsCommand } from "./commands/tts.js";
26
27
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
27
28
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
28
29
  import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
@@ -43,13 +44,15 @@ import { getCurrentSession } from "../session/manager.js";
43
44
  import { ingestSessionInfoForCache } from "../session/cache-manager.js";
44
45
  import { logger } from "../utils/logger.js";
45
46
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
47
+ import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
46
48
  import { pinnedMessageManager } from "../pinned/manager.js";
47
49
  import { t } from "../i18n/index.js";
48
- import { processUserPrompt } from "./handlers/prompt.js";
50
+ import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
49
51
  import { handleVoiceMessage } from "./handlers/voice.js";
50
52
  import { handleDocumentMessage } from "./handlers/document.js";
51
53
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
52
54
  import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
55
+ import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
53
56
  import { deliverThinkingMessage } from "./utils/thinking-message.js";
54
57
  import { sendBotText } from "./utils/telegram-text.js";
55
58
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
@@ -63,7 +66,7 @@ let botInstance = null;
63
66
  let chatIdInstance = null;
64
67
  let commandsInitialized = false;
65
68
  const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
66
- const RESPONSE_STREAM_THROTTLE_MS = 200;
69
+ const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
67
70
  const RESPONSE_STREAM_TEXT_LIMIT = 3800;
68
71
  const SESSION_RETRY_PREFIX = "🔁";
69
72
  const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -93,7 +96,7 @@ function prepareStreamingPayload(messageText) {
93
96
  }
94
97
  return {
95
98
  parts,
96
- format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
99
+ format: "raw",
97
100
  };
98
101
  }
99
102
  const toolMessageBatcher = new ToolMessageBatcher({
@@ -241,6 +244,12 @@ const toolCallStreamer = new ToolCallStreamer({
241
244
  });
242
245
  },
243
246
  });
247
+ function getToolStreamKey(tool) {
248
+ if (tool === "todowrite") {
249
+ return "todo";
250
+ }
251
+ return "default";
252
+ }
244
253
  async function ensureCommandsInitialized(ctx, next) {
245
254
  if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
246
255
  await next();
@@ -296,6 +305,7 @@ async function ensureEventSubscription(directory) {
296
305
  summaryAggregator.setOnComplete(async (sessionId, messageId, messageText) => {
297
306
  if (!botInstance || !chatIdInstance) {
298
307
  logger.error("Bot or chat ID not available for sending message");
308
+ clearPromptResponseMode(sessionId);
299
309
  responseStreamer.clearMessage(sessionId, messageId, "bot_context_missing");
300
310
  toolCallStreamer.clearSession(sessionId, "bot_context_missing");
301
311
  foregroundSessionState.markIdle(sessionId);
@@ -303,6 +313,7 @@ async function ensureEventSubscription(directory) {
303
313
  }
304
314
  const currentSession = getCurrentSession();
305
315
  if (currentSession?.id !== sessionId) {
316
+ clearPromptResponseMode(sessionId);
306
317
  responseStreamer.clearMessage(sessionId, messageId, "session_mismatch");
307
318
  toolCallStreamer.clearSession(sessionId, "session_mismatch");
308
319
  foregroundSessionState.markIdle(sessionId);
@@ -323,13 +334,15 @@ async function ensureEventSubscription(directory) {
323
334
  ]).then(() => undefined),
324
335
  prepareStreamingPayload,
325
336
  formatSummary,
337
+ formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
326
338
  resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
327
339
  getReplyKeyboard: getCurrentReplyKeyboard,
328
- sendText: async (text, options, format) => {
340
+ sendText: async (text, rawFallbackText, options, format) => {
329
341
  await sendBotText({
330
342
  api: botApi,
331
343
  chatId,
332
344
  text,
345
+ rawFallbackText,
333
346
  options: options,
334
347
  format,
335
348
  });
@@ -345,8 +358,15 @@ async function ensureEventSubscription(directory) {
345
358
  }
346
359
  },
347
360
  });
361
+ await sendTtsResponseForSession({
362
+ api: botApi,
363
+ sessionId,
364
+ chatId,
365
+ text: messageText,
366
+ });
348
367
  }
349
368
  catch (err) {
369
+ clearPromptResponseMode(sessionId);
350
370
  logger.error("Failed to send message to Telegram:", err);
351
371
  // Stop processing events after critical error to prevent infinite loop
352
372
  logger.error("[Bot] CRITICAL: Stopping event processing due to error");
@@ -376,7 +396,7 @@ async function ensureEventSubscription(directory) {
376
396
  try {
377
397
  const message = formatToolInfo(toolInfo);
378
398
  if (message) {
379
- toolCallStreamer.append(toolInfo.sessionId, message);
399
+ toolCallStreamer.append(toolInfo.sessionId, message, getToolStreamKey(toolInfo.tool));
380
400
  }
381
401
  }
382
402
  catch (err) {
@@ -399,7 +419,7 @@ async function ensureEventSubscription(directory) {
399
419
  if (!renderedCards) {
400
420
  return;
401
421
  }
402
- toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards);
422
+ toolCallStreamer.replaceByPrefix(sessionId, SUBAGENT_STREAM_PREFIX, renderedCards, "subagent");
403
423
  }
404
424
  catch (err) {
405
425
  logger.error("Failed to render subagent activity for Telegram:", err);
@@ -550,11 +570,13 @@ async function ensureEventSubscription(directory) {
550
570
  });
551
571
  summaryAggregator.setOnSessionError(async (sessionId, message) => {
552
572
  if (!botInstance || !chatIdInstance) {
573
+ clearPromptResponseMode(sessionId);
553
574
  foregroundSessionState.markIdle(sessionId);
554
575
  return;
555
576
  }
556
577
  const currentSession = getCurrentSession();
557
578
  if (!currentSession || currentSession.id !== sessionId) {
579
+ clearPromptResponseMode(sessionId);
558
580
  responseStreamer.clearSession(sessionId, "session_error_not_current");
559
581
  toolCallStreamer.clearSession(sessionId, "session_error_not_current");
560
582
  foregroundSessionState.markIdle(sessionId);
@@ -562,6 +584,7 @@ async function ensureEventSubscription(directory) {
562
584
  return;
563
585
  }
564
586
  responseStreamer.clearSession(sessionId, "session_error");
587
+ clearPromptResponseMode(sessionId);
565
588
  await Promise.all([
566
589
  toolMessageBatcher.flushSession(sessionId, "session_error"),
567
590
  toolCallStreamer.flushSession(sessionId, "session_error"),
@@ -675,11 +698,17 @@ export function createBot() {
675
698
  const timeSinceLast = now - lastGetUpdatesTime;
676
699
  logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
677
700
  lastGetUpdatesTime = now;
701
+ return prev(method, payload, signal);
678
702
  }
679
- else if (method === "sendMessage") {
703
+ if (method === "sendMessage") {
680
704
  logger.debug(`[Bot API] sendMessage to chat ${payload.chat_id}`);
681
705
  }
682
- return prev(method, payload, signal);
706
+ return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
707
+ maxRetries: 5,
708
+ onRetry: ({ attempt, retryAfterMs, error }) => {
709
+ logger.warn(`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`, error);
710
+ },
711
+ });
683
712
  });
684
713
  bot.use((ctx, next) => {
685
714
  const hasCallbackQuery = !!ctx.callbackQuery;
@@ -703,6 +732,7 @@ export function createBot() {
703
732
  bot.command("start", startCommand);
704
733
  bot.command("help", helpCommand);
705
734
  bot.command("status", statusCommand);
735
+ bot.command("tts", ttsCommand);
706
736
  bot.command("opencode_start", opencodeStartCommand);
707
737
  bot.command("opencode_stop", opencodeStopCommand);
708
738
  bot.command("projects", projectsCommand);
@@ -759,9 +789,9 @@ export function createBot() {
759
789
  await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
760
790
  }
761
791
  });
762
- // Handle Reply Keyboard button press (agent mode indicator)
792
+ // Handle Reply Keyboard button press (agent indicator)
763
793
  bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
764
- logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
794
+ logger.debug(`[Bot] Agent button pressed: ${ctx.message?.text}`);
765
795
  try {
766
796
  if (await blockMenuWhileInteractionActive(ctx)) {
767
797
  return;
@@ -1,4 +1,4 @@
1
- export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\sMode$/;
2
- export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\sMode$)[\s\S]+$/;
1
+ export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\s(?:Mode|Agent)$/;
2
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\s(?:Mode|Agent)$)[\s\S]+$/;
3
3
  // Keep support for both legacy "💭" and current "💡" prefix.
4
4
  export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
@@ -1,5 +1,6 @@
1
1
  import { logger } from "../../utils/logger.js";
2
2
  const TELEGRAM_MESSAGE_SAFE_LENGTH = 4000;
3
+ const DEFAULT_STREAM_KEY = "default";
3
4
  function getErrorMessage(error) {
4
5
  if (error instanceof Error) {
5
6
  return error.message;
@@ -68,23 +69,23 @@ export class ToolCallStreamer {
68
69
  this.editText = options.editText;
69
70
  this.deleteText = options.deleteText;
70
71
  }
71
- append(sessionId, text) {
72
+ append(sessionId, text, streamKey = DEFAULT_STREAM_KEY) {
72
73
  const normalizedText = text.trim();
73
74
  if (!sessionId || !normalizedText) {
74
75
  return;
75
76
  }
76
- const state = this.getOrCreateState(sessionId);
77
+ const state = this.getOrCreateState(sessionId, streamKey);
77
78
  state.entries.push({ text: normalizedText });
78
79
  state.latestParts = buildParts(state.entries);
79
80
  this.ensureTimer(state);
80
81
  }
81
- replaceByPrefix(sessionId, prefix, text) {
82
+ replaceByPrefix(sessionId, prefix, text, streamKey = DEFAULT_STREAM_KEY) {
82
83
  const normalizedPrefix = prefix.trim();
83
84
  const normalizedText = text.trim();
84
85
  if (!sessionId || !normalizedPrefix || !normalizedText) {
85
86
  return;
86
87
  }
87
- const state = this.getOrCreateState(sessionId);
88
+ const state = this.getOrCreateState(sessionId, streamKey);
88
89
  const existingEntry = state.entries.find((entry) => entry.prefix === normalizedPrefix);
89
90
  if (existingEntry) {
90
91
  existingEntry.text = normalizedText;
@@ -96,24 +97,21 @@ export class ToolCallStreamer {
96
97
  this.ensureTimer(state);
97
98
  }
98
99
  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));
100
+ const states = this.getStatesForSession(sessionId);
101
+ await Promise.all(states.map(async (state) => {
102
+ this.clearTimer(state);
103
+ await this.enqueueTask(state, () => this.syncState(state, reason));
104
+ }));
105
105
  }
106
106
  async breakSession(sessionId, reason) {
107
- const state = this.states.get(sessionId);
108
- if (!state) {
109
- return;
107
+ const states = this.getStatesForSession(sessionId);
108
+ for (const state of states) {
109
+ state.isBreaking = true;
110
+ this.clearTimer(state);
111
+ await this.enqueueTask(state, () => this.syncState(state, reason));
112
+ this.cancelState(state);
113
+ this.removeState(state);
110
114
  }
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
115
  logger.debug(`[ToolCallStreamer] Broke session stream: session=${sessionId}, reason=${reason}`);
118
116
  }
119
117
  clearSession(sessionId, reason) {
@@ -141,8 +139,15 @@ export class ToolCallStreamer {
141
139
  logger.debug(`[ToolCallStreamer] Cleared all streams: count=${count}, reason=${reason}`);
142
140
  }
143
141
  }
144
- getOrCreateState(sessionId) {
145
- const existing = this.states.get(sessionId);
142
+ getStateId(sessionId, streamKey) {
143
+ return `${sessionId}:${streamKey}`;
144
+ }
145
+ getStatesForSession(sessionId) {
146
+ return Array.from(this.allStates).filter((state) => state.sessionId === sessionId);
147
+ }
148
+ getOrCreateState(sessionId, streamKey = DEFAULT_STREAM_KEY) {
149
+ const stateId = this.getStateId(sessionId, streamKey);
150
+ const existing = this.states.get(stateId);
146
151
  if (existing && !existing.isBroken && !existing.cancelled && !existing.isBreaking) {
147
152
  return existing;
148
153
  }
@@ -151,6 +156,7 @@ export class ToolCallStreamer {
151
156
  this.removeState(existing);
152
157
  }
153
158
  const state = {
159
+ key: streamKey,
154
160
  sessionId,
155
161
  entries: [],
156
162
  latestParts: [],
@@ -164,7 +170,7 @@ export class ToolCallStreamer {
164
170
  fatalErrorMessage: null,
165
171
  fatalErrorLogged: false,
166
172
  };
167
- this.states.set(sessionId, state);
173
+ this.states.set(stateId, state);
168
174
  this.allStates.add(state);
169
175
  return state;
170
176
  }
@@ -202,8 +208,9 @@ export class ToolCallStreamer {
202
208
  this.clearTimer(state);
203
209
  }
204
210
  removeState(state) {
205
- if (this.states.get(state.sessionId) === state) {
206
- this.states.delete(state.sessionId);
211
+ const stateId = this.getStateId(state.sessionId, state.key);
212
+ if (this.states.get(stateId) === state) {
213
+ this.states.delete(stateId);
207
214
  }
208
215
  this.allStates.delete(state);
209
216
  }
@@ -1,5 +1,5 @@
1
1
  import { logger } from "../../utils/logger.js";
2
- export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
2
+ export async function finalizeAssistantResponse({ sessionId, messageId, messageText, responseStreamer, flushPendingServiceMessages, prepareStreamingPayload, formatSummary, formatRawSummary, resolveFormat, getReplyKeyboard, sendText, deleteMessages, }) {
3
3
  let streamedMessageIds = [];
4
4
  const preparedStreamPayload = prepareStreamingPayload(messageText);
5
5
  if (preparedStreamPayload) {
@@ -22,11 +22,14 @@ export async function finalizeAssistantResponse({ sessionId, messageId, messageT
22
22
  }
23
23
  }
24
24
  const parts = formatSummary(messageText);
25
+ const rawParts = formatRawSummary(messageText);
25
26
  const format = resolveFormat();
26
- for (const part of parts) {
27
+ for (let partIndex = 0; partIndex < parts.length; partIndex++) {
28
+ const part = parts[partIndex];
29
+ const rawFallbackText = rawParts[partIndex];
27
30
  const keyboard = getReplyKeyboard();
28
31
  const options = keyboard ? { reply_markup: keyboard } : undefined;
29
- await sendText(part, options, format);
32
+ await sendText(part, rawFallbackText, options, format);
30
33
  }
31
34
  return false;
32
35
  }
@@ -1,5 +1,5 @@
1
1
  import { Keyboard } from "grammy";
2
- import { getAgentDisplayName } from "../../agent/types.js";
2
+ import { getAgentButtonLabel } from "../../agent/types.js";
3
3
  import { formatModelForButton } from "../../model/types.js";
4
4
  import { t } from "../../i18n/index.js";
5
5
  /**
@@ -33,7 +33,7 @@ function formatContextForButton(contextInfo) {
33
33
  */
34
34
  export function createMainKeyboard(currentAgent, currentModel, contextInfo, variantName) {
35
35
  const keyboard = new Keyboard();
36
- const agentText = getAgentDisplayName(currentAgent);
36
+ const agentText = getAgentButtonLabel(currentAgent);
37
37
  // Format model with compact provider/model text and icon
38
38
  const modelText = formatModelForButton(currentModel.providerID, currentModel.modelID);
39
39
  // Context text - show "0" if no data available
@@ -49,15 +49,15 @@ export function createMainKeyboard(currentAgent, currentModel, contextInfo, vari
49
49
  return keyboard.resized().persistent();
50
50
  }
51
51
  /**
52
- * Create Reply Keyboard with agent mode indicator
52
+ * Create Reply Keyboard with agent indicator
53
53
  * @param currentAgent Current agent name (e.g., "build", "plan")
54
- * @returns Reply Keyboard with single button showing current mode
54
+ * @returns Reply Keyboard with single button showing current agent
55
55
  * @deprecated Use createMainKeyboard instead
56
56
  */
57
57
  export function createAgentKeyboard(currentAgent) {
58
58
  const keyboard = new Keyboard();
59
- const displayName = getAgentDisplayName(currentAgent);
60
- // Single button with current agent mode
59
+ const displayName = getAgentButtonLabel(currentAgent);
60
+ // Single button with current agent
61
61
  keyboard.text(displayName).row();
62
62
  return keyboard.resized().persistent();
63
63
  }
@@ -0,0 +1,37 @@
1
+ import { InputFile } from "grammy";
2
+ import { consumePromptResponseMode } from "../handlers/prompt.js";
3
+ import { isTtsConfigured, synthesizeSpeech } from "../../tts/client.js";
4
+ import { t } from "../../i18n/index.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ const MAX_TTS_INPUT_CHARS = 4_000;
7
+ export async function sendTtsResponseForSession({ api, sessionId, chatId, text, consumeResponseMode: consumeResponseModeImpl = consumePromptResponseMode, isTtsConfigured: isTtsConfiguredImpl = isTtsConfigured, synthesizeSpeech: synthesizeSpeechImpl = synthesizeSpeech, }) {
8
+ const responseMode = consumeResponseModeImpl(sessionId);
9
+ if (responseMode !== "text_and_tts") {
10
+ return false;
11
+ }
12
+ const normalizedText = text.trim();
13
+ if (!normalizedText) {
14
+ return false;
15
+ }
16
+ if (!isTtsConfiguredImpl()) {
17
+ logger.info(`[TTS] Skipping audio reply for session ${sessionId}: TTS is not configured`);
18
+ return false;
19
+ }
20
+ if (normalizedText.length > MAX_TTS_INPUT_CHARS) {
21
+ logger.warn(`[TTS] Skipping audio reply for session ${sessionId}: text length ${normalizedText.length} exceeds limit ${MAX_TTS_INPUT_CHARS}`);
22
+ return false;
23
+ }
24
+ try {
25
+ const speech = await synthesizeSpeechImpl(normalizedText);
26
+ await api.sendAudio(chatId, new InputFile(speech.buffer, speech.filename));
27
+ logger.info(`[TTS] Sent audio reply for session ${sessionId}`);
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ logger.warn(`[TTS] Failed to send audio reply for session ${sessionId}`, error);
32
+ await api.sendMessage(chatId, t("tts.failed")).catch((sendError) => {
33
+ logger.warn(`[TTS] Failed to send audio error message for session ${sessionId}`, sendError);
34
+ });
35
+ return false;
36
+ }
37
+ }
@@ -6,6 +6,50 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
6
6
  "entity beginning",
7
7
  "bad request: can't parse",
8
8
  ];
9
+ const MARKDOWN_V2_RESERVED_CHARS = new Set([
10
+ "_",
11
+ "*",
12
+ "[",
13
+ "]",
14
+ "(",
15
+ ")",
16
+ "~",
17
+ "`",
18
+ ">",
19
+ "#",
20
+ "+",
21
+ "-",
22
+ "=",
23
+ "|",
24
+ "{",
25
+ "}",
26
+ ".",
27
+ "!",
28
+ "\\",
29
+ ]);
30
+ const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
31
+ function escapeTelegramMarkdownV2(text) {
32
+ let result = "";
33
+ let trailingBackslashes = 0;
34
+ for (const char of text) {
35
+ if (char === "\\") {
36
+ result += char;
37
+ trailingBackslashes += 1;
38
+ continue;
39
+ }
40
+ const isEscaped = trailingBackslashes % 2 === 1;
41
+ trailingBackslashes = 0;
42
+ if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
43
+ result += `\\${char}`;
44
+ continue;
45
+ }
46
+ result += char;
47
+ }
48
+ return result;
49
+ }
50
+ function unescapeTelegramMarkdownV2(text) {
51
+ return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
52
+ }
9
53
  function stripMarkdownFormattingOptions(options) {
10
54
  if (!options) {
11
55
  return options;
@@ -47,7 +91,7 @@ export function isTelegramMarkdownParseError(error) {
47
91
  }
48
92
  return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
49
93
  }
50
- export async function sendMessageWithMarkdownFallback({ api, chatId, text, options, parseMode, }) {
94
+ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
51
95
  if (!parseMode) {
52
96
  return api.sendMessage(chatId, text, options);
53
97
  }
@@ -55,6 +99,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
55
99
  ...(options || {}),
56
100
  parse_mode: parseMode,
57
101
  };
102
+ const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
58
103
  try {
59
104
  return await api.sendMessage(chatId, text, markdownOptions);
60
105
  }
@@ -62,11 +107,27 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, optio
62
107
  if (!isTelegramMarkdownParseError(error)) {
63
108
  throw error;
64
109
  }
110
+ if (parseMode === "MarkdownV2") {
111
+ const escapedText = escapeTelegramMarkdownV2(text);
112
+ if (escapedText !== text) {
113
+ logger.warn("[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2", error);
114
+ try {
115
+ return await api.sendMessage(chatId, escapedText, markdownOptions);
116
+ }
117
+ catch (escapedError) {
118
+ if (!isTelegramMarkdownParseError(escapedError)) {
119
+ throw escapedError;
120
+ }
121
+ logger.warn("[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode", escapedError);
122
+ return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
123
+ }
124
+ }
125
+ }
65
126
  logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
66
- return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
127
+ return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
67
128
  }
68
129
  }
69
- export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, options, parseMode, }) {
130
+ export async function editMessageWithMarkdownFallback({ api, chatId, messageId, text, rawFallbackText, options, parseMode, }) {
70
131
  if (!parseMode) {
71
132
  return api.editMessageText(chatId, messageId, text, options);
72
133
  }
@@ -74,6 +135,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
74
135
  ...(options || {}),
75
136
  parse_mode: parseMode,
76
137
  };
138
+ const fallbackText = rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
77
139
  try {
78
140
  return await api.editMessageText(chatId, messageId, text, markdownOptions);
79
141
  }
@@ -81,7 +143,23 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
81
143
  if (!isTelegramMarkdownParseError(error)) {
82
144
  throw error;
83
145
  }
146
+ if (parseMode === "MarkdownV2") {
147
+ const escapedText = escapeTelegramMarkdownV2(text);
148
+ if (escapedText !== text) {
149
+ logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
150
+ try {
151
+ return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
152
+ }
153
+ catch (escapedError) {
154
+ if (!isTelegramMarkdownParseError(escapedError)) {
155
+ throw escapedError;
156
+ }
157
+ logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
158
+ return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
159
+ }
160
+ }
161
+ }
84
162
  logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
85
- return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
163
+ return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
86
164
  }
87
165
  }
@@ -5,21 +5,23 @@ function resolveParseMode(format) {
5
5
  }
6
6
  return undefined;
7
7
  }
8
- export async function sendBotText({ api, chatId, text, options, format = "raw", }) {
8
+ export async function sendBotText({ api, chatId, text, rawFallbackText, options, format = "raw", }) {
9
9
  await sendMessageWithMarkdownFallback({
10
10
  api,
11
11
  chatId,
12
12
  text,
13
+ rawFallbackText,
13
14
  options,
14
15
  parseMode: resolveParseMode(format),
15
16
  });
16
17
  }
17
- export async function editBotText({ api, chatId, messageId, text, options, format = "raw", }) {
18
+ export async function editBotText({ api, chatId, messageId, text, rawFallbackText, options, format = "raw", }) {
18
19
  await editMessageWithMarkdownFallback({
19
20
  api,
20
21
  chatId,
21
22
  messageId,
22
23
  text,
24
+ rawFallbackText,
23
25
  options,
24
26
  parseMode: resolveParseMode(format),
25
27
  });
package/dist/config.js CHANGED
@@ -73,6 +73,8 @@ export const config = {
73
73
  projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
74
74
  commandsListLimit: getOptionalPositiveIntEnvVar("COMMANDS_LIST_LIMIT", 10),
75
75
  taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
76
+ responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
77
+ bashToolDisplayMaxLength: getOptionalPositiveIntEnvVar("BASH_TOOL_DISPLAY_MAX_LENGTH", 128),
76
78
  locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
77
79
  hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
78
80
  hideToolCallMessages: getOptionalBooleanEnvVar("HIDE_TOOL_CALL_MESSAGES", false),
@@ -87,4 +89,10 @@ export const config = {
87
89
  model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
88
90
  language: getEnvVar("STT_LANGUAGE", false),
89
91
  },
92
+ tts: {
93
+ apiUrl: getEnvVar("TTS_API_URL", false),
94
+ apiKey: getEnvVar("TTS_API_KEY", false),
95
+ model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
96
+ voice: getEnvVar("TTS_VOICE", false) || "alloy",
97
+ },
90
98
  };
package/dist/i18n/de.js CHANGED
@@ -3,6 +3,7 @@ export const de = {
3
3
  "cmd.description.new": "Neue Sitzung erstellen",
4
4
  "cmd.description.stop": "Aktuelle Aktion stoppen",
5
5
  "cmd.description.sessions": "Sitzungen auflisten",
6
+ "cmd.description.tts": "Audioantworten umschalten",
6
7
  "cmd.description.projects": "Projekte auflisten",
7
8
  "cmd.description.task": "Geplante Aufgabe erstellen",
8
9
  "cmd.description.tasklist": "Geplante Aufgaben anzeigen",
@@ -32,8 +33,8 @@ export const de = {
32
33
  "inline.cancelled_callback": "Abgebrochen",
33
34
  "common.unknown": "unbekannt",
34
35
  "common.unknown_error": "unbekannter Fehler",
35
- "start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/task — geplante Aufgabe\n/tasklist — geplante Aufgaben\n/status — Status\n/help — Hilfe\n\nNutze die unteren Buttons, um Modus, Modell und Variante zu wählen.",
36
- "help.keyboard_hint": "💡 Nutze die unteren Buttons für Modus, Modell, Variante und Kontextaktionen.",
36
+ "start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/task — geplante Aufgabe\n/tasklist — geplante Aufgaben\n/status — Status\n/help — Hilfe\n\nNutze die unteren Buttons, um Agent, Modell und Variante zu wählen.",
37
+ "help.keyboard_hint": "💡 Nutze die unteren Buttons für Agent, Modell, Variante und Kontextaktionen.",
37
38
  "help.text": "📖 **Hilfe**\n\n/status - Serverstatus prüfen\n/sessions - Sitzungsliste\n/new - Neue Sitzung erstellen\n/help - Hilfe",
38
39
  "bot.thinking": "💭 Denke...",
39
40
  "bot.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
@@ -65,8 +66,11 @@ export const de = {
65
66
  "status.line.managed_no": "Vom Bot gestartet: Nein",
66
67
  "status.line.pid": "PID: {pid}",
67
68
  "status.line.uptime_sec": "Betriebszeit: {seconds} s",
68
- "status.line.mode": "Modus: {mode}",
69
+ "status.line.mode": "Agent: {mode}",
69
70
  "status.line.model": "Modell: {model}",
71
+ "status.line.tts": "TTS-Antworten: {tts}",
72
+ "status.tts.on": "Ein",
73
+ "status.tts.off": "Aus",
70
74
  "status.agent_not_set": "nicht gesetzt",
71
75
  "status.project_selected": "Projekt: {project}",
72
76
  "status.project_not_selected": "Projekt: nicht ausgewählt",
@@ -75,6 +79,10 @@ export const de = {
75
79
  "status.session_not_selected": "Aktuelle Sitzung: nicht ausgewählt",
76
80
  "status.session_hint": "Nutze /sessions zur Auswahl oder /new zum Erstellen",
77
81
  "status.server_unavailable": "🔴 OpenCode-Server ist nicht verfügbar\n\nNutze /opencode_start, um den Server zu starten.",
82
+ "tts.enabled": "🔊 Audioantworten global aktiviert.",
83
+ "tts.not_configured": "⚠️ Audioantworten sind nicht verfugbar. Setze zuerst `TTS_API_URL` und `TTS_API_KEY`.",
84
+ "tts.disabled": "🔇 Audioantworten global deaktiviert.",
85
+ "tts.failed": "⚠️ Audioreply konnte nicht erzeugt werden.",
78
86
  "projects.empty": "📭 Keine Projekte gefunden.\n\nÖffne ein Verzeichnis in OpenCode und erstelle mindestens eine Sitzung, dann erscheint es hier.",
79
87
  "projects.select": "Projekt auswählen:",
80
88
  "projects.select_with_current": "Projekt auswählen:\n\nAktuell: 🏗 {project}",
@@ -127,11 +135,11 @@ export const de = {
127
135
  "opencode_stop.stop_error": "🔴 OpenCode-Server konnte nicht gestoppt werden\n\nFehler: {error}",
128
136
  "opencode_stop.success": "✅ OpenCode-Server erfolgreich gestoppt",
129
137
  "opencode_stop.error": "🔴 Beim Stoppen des Servers ist ein Fehler aufgetreten.\n\nSiehe Anwendungslogs für Details.",
130
- "agent.changed_callback": "Modus geändert: {name}",
131
- "agent.changed_message": "✅ Modus geändert zu: {name}",
132
- "agent.change_error_callback": "Modus konnte nicht geändert werden",
133
- "agent.menu.current": "Aktueller Modus: {name}\n\nModus auswählen:",
134
- "agent.menu.select": "Arbeitsmodus auswählen:",
138
+ "agent.changed_callback": "Agent geändert: {name}",
139
+ "agent.changed_message": "✅ Agent geändert zu: {name}",
140
+ "agent.change_error_callback": "Agent konnte nicht geändert werden",
141
+ "agent.menu.current": "Aktueller Agent: {name}\n\nAgent auswählen:",
142
+ "agent.menu.select": "Agent auswählen:",
135
143
  "agent.menu.empty": "⚠️ Keine verfügbaren Agenten",
136
144
  "agent.menu.error": "🔴 Agentenliste konnte nicht geladen werden",
137
145
  "model.changed_callback": "Modell geändert: {name}",
@@ -205,7 +213,7 @@ export const de = {
205
213
  "question.summary.title": "✅ Umfrage abgeschlossen!\n\n",
206
214
  "question.summary.question": "Frage {index}:\n{question}\n\n",
207
215
  "question.summary.answer": "Antwort:\n{answer}\n\n",
208
- "keyboard.agent_mode": "{emoji} {name} Modus",
216
+ "keyboard.agent_mode": "{emoji} {name} Agent",
209
217
  "keyboard.context": "📊 {used} / {limit} ({percent}%)",
210
218
  "keyboard.context_empty": "📊 0",
211
219
  "keyboard.variant": "💭 {name}",