@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/.env.example +16 -0
- package/README.md +44 -25
- package/dist/agent/manager.js +34 -6
- package/dist/agent/types.js +7 -1
- package/dist/bot/commands/commands.js +2 -2
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/new.js +3 -2
- package/dist/bot/commands/projects.js +3 -2
- package/dist/bot/commands/sessions.js +3 -0
- package/dist/bot/commands/status.js +5 -2
- package/dist/bot/commands/tts.js +13 -0
- package/dist/bot/handlers/agent.js +3 -4
- package/dist/bot/handlers/model.js +3 -2
- package/dist/bot/handlers/prompt.js +22 -5
- package/dist/bot/handlers/variant.js +3 -2
- package/dist/bot/index.js +40 -10
- package/dist/bot/message-patterns.js +2 -2
- package/dist/bot/streaming/tool-call-streamer.js +31 -24
- package/dist/bot/utils/finalize-assistant-response.js +6 -3
- package/dist/bot/utils/keyboard.js +6 -6
- package/dist/bot/utils/send-tts-response.js +37 -0
- package/dist/bot/utils/send-with-markdown-fallback.js +82 -4
- package/dist/bot/utils/telegram-text.js +4 -2
- package/dist/config.js +8 -0
- package/dist/i18n/de.js +17 -9
- package/dist/i18n/en.js +17 -9
- package/dist/i18n/es.js +17 -9
- package/dist/i18n/fr.js +17 -9
- package/dist/i18n/ru.js +17 -9
- package/dist/i18n/zh.js +17 -9
- package/dist/pinned/manager.js +164 -31
- package/dist/settings/manager.js +7 -0
- package/dist/summary/formatter.js +10 -1
- package/dist/tts/client.js +59 -0
- package/dist/utils/telegram-rate-limit-retry.js +93 -0
- package/package.json +1 -1
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
792
|
+
// Handle Reply Keyboard button press (agent indicator)
|
|
763
793
|
bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
|
|
764
|
-
logger.debug(`[Bot] Agent
|
|
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.+\
|
|
2
|
-
export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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(
|
|
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
|
-
|
|
206
|
-
|
|
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 (
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
60
|
-
// Single button with current agent
|
|
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,
|
|
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,
|
|
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
|
|
36
|
-
"help.keyboard_hint": "💡 Nutze die unteren Buttons für
|
|
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": "
|
|
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": "
|
|
131
|
-
"agent.changed_message": "✅
|
|
132
|
-
"agent.change_error_callback": "
|
|
133
|
-
"agent.menu.current": "Aktueller
|
|
134
|
-
"agent.menu.select": "
|
|
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}
|
|
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}",
|