@grinev/opencode-telegram-bot 0.17.0 → 0.19.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 +22 -1
- package/README.md +35 -6
- package/dist/app/start-bot-app.js +4 -0
- package/dist/attach/manager.js +66 -0
- package/dist/attach/service.js +171 -0
- package/dist/bot/commands/abort.js +0 -4
- package/dist/bot/commands/commands.js +13 -3
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/mcps.js +394 -0
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/opencode-start.js +2 -10
- package/dist/bot/commands/sessions.js +19 -20
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/handlers/prompt.js +26 -31
- package/dist/bot/index.js +82 -10
- package/dist/bot/middleware/interaction-guard.js +1 -1
- package/dist/bot/utils/busy-guard.js +3 -2
- package/dist/bot/utils/external-user-input.js +46 -0
- package/dist/bot/utils/switch-project.js +2 -0
- package/dist/config.js +25 -6
- package/dist/external-input/suppression.js +54 -0
- package/dist/i18n/de.js +34 -0
- package/dist/i18n/en.js +34 -0
- package/dist/i18n/es.js +34 -0
- package/dist/i18n/fr.js +34 -0
- package/dist/i18n/ru.js +34 -0
- package/dist/i18n/zh.js +34 -0
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/auto-restart.js +92 -0
- package/dist/opencode/events.js +13 -1
- package/dist/opencode/process.js +18 -1
- package/dist/pinned/manager.js +39 -0
- package/dist/summary/aggregator.js +89 -9
- package/dist/summary/formatter.js +2 -2
- package/dist/summary/markdown-to-telegram-v2.js +99 -0
- package/dist/summary/subagent-formatter.js +23 -2
- package/dist/telegram/render/inline-renderer.js +18 -0
- package/dist/tts/client.js +94 -19
- package/package.json +2 -2
|
@@ -18,6 +18,8 @@ import { logger } from "../../utils/logger.js";
|
|
|
18
18
|
import { t } from "../../i18n/index.js";
|
|
19
19
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
20
20
|
import { assistantRunState } from "../assistant-run-state.js";
|
|
21
|
+
import { attachToSession, detachAttachedSession, markAttachedSessionBusy, markAttachedSessionIdle, } from "../../attach/service.js";
|
|
22
|
+
import { externalUserInputSuppressionManager } from "../../external-input/suppression.js";
|
|
21
23
|
/** Module-level references for async callbacks that don't have ctx. */
|
|
22
24
|
let botInstance = null;
|
|
23
25
|
let chatIdInstance = null;
|
|
@@ -59,6 +61,7 @@ async function isSessionBusy(sessionId, directory) {
|
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
async function resetMismatchedSessionContext() {
|
|
64
|
+
detachAttachedSession("session_mismatch_reset");
|
|
62
65
|
stopEventListening();
|
|
63
66
|
summaryAggregator.clear();
|
|
64
67
|
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
@@ -96,13 +99,8 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
96
99
|
}
|
|
97
100
|
botInstance = bot;
|
|
98
101
|
chatIdInstance = ctx.chat.id;
|
|
99
|
-
// Initialize pinned message manager if not already
|
|
100
|
-
if (!pinnedMessageManager.isInitialized()) {
|
|
101
|
-
pinnedMessageManager.initialize(bot.api, ctx.chat.id);
|
|
102
|
-
}
|
|
103
|
-
// Initialize keyboard manager if not already
|
|
104
|
-
keyboardManager.initialize(bot.api, ctx.chat.id);
|
|
105
102
|
let currentSession = getCurrentSession();
|
|
103
|
+
let createdNewSession = false;
|
|
106
104
|
if (currentSession && currentSession.directory !== currentProject.worktree) {
|
|
107
105
|
logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
|
|
108
106
|
await resetMismatchedSessionContext();
|
|
@@ -126,38 +124,28 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
126
124
|
};
|
|
127
125
|
setCurrentSession(currentSession);
|
|
128
126
|
await ingestSessionInfoForCache(session);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
127
|
+
createdNewSession = true;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
131
|
+
}
|
|
132
|
+
await attachToSession({
|
|
133
|
+
bot,
|
|
134
|
+
chatId: ctx.chat.id,
|
|
135
|
+
session: currentSession,
|
|
136
|
+
ensureEventSubscription,
|
|
137
|
+
});
|
|
138
|
+
if (createdNewSession) {
|
|
136
139
|
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
137
140
|
const currentModel = getStoredModel();
|
|
138
|
-
const contextInfo = pinnedMessageManager.getContextInfo();
|
|
139
|
-
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
140
141
|
keyboardManager.updateAgent(currentAgent);
|
|
142
|
+
const contextInfo = keyboardManager.getContextInfo();
|
|
143
|
+
const variantName = formatVariantForButton(currentModel.variant || "default");
|
|
141
144
|
const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
|
|
142
|
-
await ctx.reply(t("bot.session_created", { title:
|
|
145
|
+
await ctx.reply(t("bot.session_created", { title: currentSession.title }), {
|
|
143
146
|
reply_markup: keyboard,
|
|
144
147
|
});
|
|
145
148
|
}
|
|
146
|
-
else {
|
|
147
|
-
logger.info(`[Bot] Using existing session: id=${currentSession.id}, title="${currentSession.title}"`);
|
|
148
|
-
// Ensure pinned message exists for existing session
|
|
149
|
-
if (!pinnedMessageManager.getState().messageId) {
|
|
150
|
-
try {
|
|
151
|
-
await pinnedMessageManager.onSessionChange(currentSession.id, currentSession.title);
|
|
152
|
-
}
|
|
153
|
-
catch (err) {
|
|
154
|
-
logger.error("[Bot] Error creating pinned message for existing session:", err);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
await ensureEventSubscription(currentSession.directory);
|
|
159
|
-
summaryAggregator.setSession(currentSession.id);
|
|
160
|
-
summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
|
|
161
149
|
const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
|
|
162
150
|
if (sessionIsBusy) {
|
|
163
151
|
logger.info(`[Bot] Ignoring new prompt: session ${currentSession.id} is busy`);
|
|
@@ -211,6 +199,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
211
199
|
};
|
|
212
200
|
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
213
201
|
foregroundSessionState.markBusy(currentSession.id);
|
|
202
|
+
await markAttachedSessionBusy(currentSession.id);
|
|
214
203
|
assistantRunState.startRun(currentSession.id, {
|
|
215
204
|
startedAt: Date.now(),
|
|
216
205
|
configuredAgent: currentAgent,
|
|
@@ -218,6 +207,9 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
218
207
|
configuredModelID: storedModel.modelID,
|
|
219
208
|
});
|
|
220
209
|
setPromptResponseMode(currentSession.id, responseMode);
|
|
210
|
+
if (text.trim().length > 0) {
|
|
211
|
+
externalUserInputSuppressionManager.register(currentSession.id, text);
|
|
212
|
+
}
|
|
221
213
|
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
222
214
|
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
223
215
|
// which blocks receiving button callback_query updates.
|
|
@@ -228,6 +220,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
228
220
|
onSuccess: ({ error }) => {
|
|
229
221
|
if (error) {
|
|
230
222
|
foregroundSessionState.markIdle(currentSession.id);
|
|
223
|
+
void markAttachedSessionIdle(currentSession.id);
|
|
231
224
|
assistantRunState.clearRun(currentSession.id, "session_prompt_api_error");
|
|
232
225
|
clearPromptResponseMode(currentSession.id);
|
|
233
226
|
const details = formatErrorDetails(error, 6000);
|
|
@@ -242,6 +235,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
242
235
|
},
|
|
243
236
|
onError: (error) => {
|
|
244
237
|
foregroundSessionState.markIdle(currentSession.id);
|
|
238
|
+
void markAttachedSessionIdle(currentSession.id);
|
|
245
239
|
assistantRunState.clearRun(currentSession.id, "session_prompt_background_error");
|
|
246
240
|
clearPromptResponseMode(currentSession.id);
|
|
247
241
|
const details = formatErrorDetails(error, 6000);
|
|
@@ -256,6 +250,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
|
|
|
256
250
|
catch (err) {
|
|
257
251
|
if (currentSession) {
|
|
258
252
|
foregroundSessionState.markIdle(currentSession.id);
|
|
253
|
+
await markAttachedSessionIdle(currentSession.id);
|
|
259
254
|
assistantRunState.clearRun(currentSession.id, "session_prompt_handler_error");
|
|
260
255
|
}
|
|
261
256
|
logger.error("Error in prompt handler:", err);
|
package/dist/bot/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands
|
|
|
26
26
|
import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
|
|
27
27
|
import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
|
|
28
28
|
import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
|
|
29
|
+
import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
|
|
29
30
|
import { ttsCommand } from "./commands/tts.js";
|
|
30
31
|
import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
|
|
31
32
|
import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
|
|
@@ -66,7 +67,11 @@ import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
|
66
67
|
import { assistantRunState } from "./assistant-run-state.js";
|
|
67
68
|
import { ResponseStreamer } from "./streaming/response-streamer.js";
|
|
68
69
|
import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
|
|
70
|
+
import { attachManager } from "../attach/manager.js";
|
|
71
|
+
import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurrentSession, } from "../attach/service.js";
|
|
72
|
+
import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
|
|
69
73
|
import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
|
|
74
|
+
import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
|
|
70
75
|
let botInstance = null;
|
|
71
76
|
let chatIdInstance = null;
|
|
72
77
|
let commandsInitialized = false;
|
|
@@ -263,6 +268,27 @@ function getToolStreamKey(tool) {
|
|
|
263
268
|
}
|
|
264
269
|
return "default";
|
|
265
270
|
}
|
|
271
|
+
function getEventSessionId(event) {
|
|
272
|
+
const properties = event.properties;
|
|
273
|
+
return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
|
|
274
|
+
}
|
|
275
|
+
function shouldMarkAttachedBusyFromEvent(event) {
|
|
276
|
+
switch (event.type) {
|
|
277
|
+
case "session.status":
|
|
278
|
+
return event.properties.status?.type === "busy";
|
|
279
|
+
case "message.updated": {
|
|
280
|
+
const info = event.properties.info;
|
|
281
|
+
return info?.role === "assistant" && !info.time?.completed;
|
|
282
|
+
}
|
|
283
|
+
case "message.part.updated":
|
|
284
|
+
case "message.part.delta":
|
|
285
|
+
case "question.asked":
|
|
286
|
+
case "permission.asked":
|
|
287
|
+
return true;
|
|
288
|
+
default:
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
266
292
|
async function ensureCommandsInitialized(ctx, next) {
|
|
267
293
|
if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
|
|
268
294
|
await next();
|
|
@@ -388,6 +414,26 @@ async function ensureEventSubscription(directory) {
|
|
|
388
414
|
}
|
|
389
415
|
});
|
|
390
416
|
});
|
|
417
|
+
summaryAggregator.setOnExternalUserInput(async (sessionId, _messageId, messageText) => {
|
|
418
|
+
void enqueueSessionCompletionTask(sessionId, async () => {
|
|
419
|
+
if (!botInstance || !chatIdInstance) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
await deliverExternalUserInputNotification({
|
|
424
|
+
api: botInstance.api,
|
|
425
|
+
chatId: chatIdInstance,
|
|
426
|
+
currentSessionId: getCurrentSession()?.id ?? null,
|
|
427
|
+
sessionId,
|
|
428
|
+
text: messageText,
|
|
429
|
+
consumeSuppressedInput: (incomingSessionId, incomingText) => externalUserInputSuppressionManager.consume(incomingSessionId, incomingText),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
logger.error("[Bot] Failed to deliver external user input to Telegram:", err);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
});
|
|
391
437
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
392
438
|
if (!botInstance || !chatIdInstance) {
|
|
393
439
|
logger.error("Bot or chat ID not available for sending tool notification");
|
|
@@ -461,18 +507,19 @@ async function ensureEventSubscription(directory) {
|
|
|
461
507
|
logger.error("Failed to send file to Telegram:", err);
|
|
462
508
|
}
|
|
463
509
|
});
|
|
464
|
-
summaryAggregator.setOnQuestion(async (questions, requestID) => {
|
|
510
|
+
summaryAggregator.setOnQuestion(async (questions, requestID, sessionId) => {
|
|
465
511
|
if (!botInstance || !chatIdInstance) {
|
|
466
512
|
logger.error("Bot or chat ID not available for showing questions");
|
|
467
513
|
return;
|
|
468
514
|
}
|
|
469
515
|
const currentSession = getCurrentSession();
|
|
470
|
-
if (currentSession) {
|
|
471
|
-
|
|
472
|
-
toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
|
|
473
|
-
toolCallStreamer.flushSession(currentSession.id, "question_asked"),
|
|
474
|
-
]);
|
|
516
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
517
|
+
return;
|
|
475
518
|
}
|
|
519
|
+
await Promise.all([
|
|
520
|
+
toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
|
|
521
|
+
toolCallStreamer.flushSession(currentSession.id, "question_asked"),
|
|
522
|
+
]);
|
|
476
523
|
if (questionManager.isActive()) {
|
|
477
524
|
logger.warn("[Bot] Replacing active poll with a new one");
|
|
478
525
|
const previousMessageIds = questionManager.getMessageIds();
|
|
@@ -503,6 +550,10 @@ async function ensureEventSubscription(directory) {
|
|
|
503
550
|
logger.error("Bot or chat ID not available for showing permission request");
|
|
504
551
|
return;
|
|
505
552
|
}
|
|
553
|
+
const currentSession = getCurrentSession();
|
|
554
|
+
if (!currentSession || currentSession.id !== request.sessionID) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
506
557
|
await Promise.all([
|
|
507
558
|
toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
|
|
508
559
|
toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
|
|
@@ -583,6 +634,7 @@ async function ensureEventSubscription(directory) {
|
|
|
583
634
|
}
|
|
584
635
|
});
|
|
585
636
|
summaryAggregator.setOnSessionIdle(async (sessionId) => {
|
|
637
|
+
await markAttachedSessionIdle(sessionId);
|
|
586
638
|
await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
|
|
587
639
|
const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
|
|
588
640
|
clearPromptResponseMode(sessionId);
|
|
@@ -627,6 +679,7 @@ async function ensureEventSubscription(directory) {
|
|
|
627
679
|
}
|
|
628
680
|
});
|
|
629
681
|
summaryAggregator.setOnSessionError(async (sessionId, message) => {
|
|
682
|
+
await markAttachedSessionIdle(sessionId);
|
|
630
683
|
if (!botInstance || !chatIdInstance) {
|
|
631
684
|
clearPromptResponseMode(sessionId);
|
|
632
685
|
assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
|
|
@@ -706,6 +759,11 @@ async function ensureEventSubscription(directory) {
|
|
|
706
759
|
});
|
|
707
760
|
logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
|
|
708
761
|
subscribeToEvents(directory, (event) => {
|
|
762
|
+
const attached = attachManager.getSnapshot();
|
|
763
|
+
const eventSessionId = getEventSessionId(event);
|
|
764
|
+
if (attached && eventSessionId === attached.sessionId && shouldMarkAttachedBusyFromEvent(event)) {
|
|
765
|
+
void markAttachedSessionBusy(attached.sessionId);
|
|
766
|
+
}
|
|
709
767
|
if (event.type === "session.created" || event.type === "session.updated") {
|
|
710
768
|
const info = event.properties.info;
|
|
711
769
|
if (info?.directory) {
|
|
@@ -723,6 +781,7 @@ async function ensureEventSubscription(directory) {
|
|
|
723
781
|
export function createBot() {
|
|
724
782
|
clearAllInteractionState("bot_startup");
|
|
725
783
|
sessionCompletionTasks.clear();
|
|
784
|
+
attachManager.clear("bot_startup");
|
|
726
785
|
assistantRunState.clearAll("bot_startup");
|
|
727
786
|
if (heartbeatTimer) {
|
|
728
787
|
clearInterval(heartbeatTimer);
|
|
@@ -748,6 +807,8 @@ export function createBot() {
|
|
|
748
807
|
};
|
|
749
808
|
}
|
|
750
809
|
const bot = new Bot(config.telegram.token, botOptions);
|
|
810
|
+
botInstance = bot;
|
|
811
|
+
chatIdInstance = config.telegram.allowedUserId;
|
|
751
812
|
// Heartbeat for diagnostics: verify the event loop is not blocked
|
|
752
813
|
let heartbeatCounter = 0;
|
|
753
814
|
heartbeatTimer = setInterval(() => {
|
|
@@ -806,13 +867,14 @@ export function createBot() {
|
|
|
806
867
|
bot.command("worktree", worktreeCommand);
|
|
807
868
|
bot.command("open", openCommand);
|
|
808
869
|
bot.command("sessions", sessionsCommand);
|
|
809
|
-
bot.command("new", newCommand);
|
|
870
|
+
bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
|
|
810
871
|
bot.command("abort", abortCommand);
|
|
811
872
|
bot.command("task", taskCommand);
|
|
812
873
|
bot.command("tasklist", taskListCommand);
|
|
813
874
|
bot.command("rename", renameCommand);
|
|
814
875
|
bot.command("commands", commandsCommand);
|
|
815
876
|
bot.command("skills", skillsCommand);
|
|
877
|
+
bot.command("mcps", mcpsCommand);
|
|
816
878
|
bot.on("message:text", unknownCommandMiddleware);
|
|
817
879
|
bot.on("callback_query:data", async (ctx) => {
|
|
818
880
|
logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
|
|
@@ -827,7 +889,7 @@ export function createBot() {
|
|
|
827
889
|
// Clean up path index when the open-directory menu is cancelled
|
|
828
890
|
clearOpenPathIndex();
|
|
829
891
|
}
|
|
830
|
-
const handledSession = await handleSessionSelect(ctx);
|
|
892
|
+
const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
|
|
831
893
|
const handledProject = await handleProjectSelect(ctx);
|
|
832
894
|
const handledWorktree = await handleWorktreeCallback(ctx);
|
|
833
895
|
const handledOpen = await handleOpenCallback(ctx);
|
|
@@ -842,7 +904,8 @@ export function createBot() {
|
|
|
842
904
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
843
905
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
844
906
|
const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
|
|
845
|
-
|
|
907
|
+
const handledMcps = await handleMcpsCallback(ctx);
|
|
908
|
+
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
846
909
|
if (!handledInlineCancel &&
|
|
847
910
|
!handledSession &&
|
|
848
911
|
!handledProject &&
|
|
@@ -858,7 +921,8 @@ export function createBot() {
|
|
|
858
921
|
!handledTaskList &&
|
|
859
922
|
!handledRenameCancel &&
|
|
860
923
|
!handledCommands &&
|
|
861
|
-
!handledSkills
|
|
924
|
+
!handledSkills &&
|
|
925
|
+
!handledMcps) {
|
|
862
926
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
863
927
|
await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
|
|
864
928
|
}
|
|
@@ -960,6 +1024,14 @@ export function createBot() {
|
|
|
960
1024
|
});
|
|
961
1025
|
// Voice and audio message handlers (STT transcription -> prompt)
|
|
962
1026
|
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
1027
|
+
safeBackgroundTask({
|
|
1028
|
+
taskName: "bot.restoreFollowedSession",
|
|
1029
|
+
task: () => restoreAttachedCurrentSession({
|
|
1030
|
+
bot,
|
|
1031
|
+
chatId: config.telegram.allowedUserId,
|
|
1032
|
+
ensureEventSubscription,
|
|
1033
|
+
}),
|
|
1034
|
+
});
|
|
963
1035
|
bot.on("message:voice", async (ctx) => {
|
|
964
1036
|
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
965
1037
|
botInstance = bot;
|
|
@@ -80,7 +80,7 @@ export async function interactionGuardMiddleware(ctx, next) {
|
|
|
80
80
|
const message = decision.busy
|
|
81
81
|
? decision.state?.kind === "question" || decision.state?.kind === "permission"
|
|
82
82
|
? getInteractionBlockedMessage(decision.reason, decision.state.kind)
|
|
83
|
-
: t("
|
|
83
|
+
: t("bot.session_busy")
|
|
84
84
|
: getInteractionBlockedMessage(decision.reason, decision.state?.kind);
|
|
85
85
|
logger.debug(`[InteractionGuard] Blocked input: interactionKind=${decision.state?.kind || "none"}, inputType=${decision.inputType}, reason=${decision.reason || "unknown"}, command=${decision.command || "-"}, busy=${decision.busy ? "yes" : "no"}`);
|
|
86
86
|
if (ctx.callbackQuery) {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
2
|
+
import { attachManager } from "../../attach/manager.js";
|
|
2
3
|
import { t } from "../../i18n/index.js";
|
|
3
4
|
export function isForegroundBusy() {
|
|
4
|
-
return foregroundSessionState.isBusy();
|
|
5
|
+
return foregroundSessionState.isBusy() || attachManager.isBusy();
|
|
5
6
|
}
|
|
6
7
|
export async function replyBusyBlocked(ctx) {
|
|
7
|
-
const message = t("
|
|
8
|
+
const message = t("bot.session_busy");
|
|
8
9
|
if (ctx.callbackQuery) {
|
|
9
10
|
await ctx.answerCallbackQuery({ text: message }).catch(() => { });
|
|
10
11
|
return;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { t } from "../../i18n/index.js";
|
|
2
|
+
import { escapePlainTextForTelegramMarkdownV2 } from "../../summary/formatter.js";
|
|
3
|
+
import { sendBotText } from "./telegram-text.js";
|
|
4
|
+
function normalizeExternalUserInputText(text) {
|
|
5
|
+
return text.replace(/\r\n/g, "\n").trim();
|
|
6
|
+
}
|
|
7
|
+
function buildQuotedPlainText(text) {
|
|
8
|
+
return text
|
|
9
|
+
.split("\n")
|
|
10
|
+
.map((line) => (line.length > 0 ? `> ${line}` : ">"))
|
|
11
|
+
.join("\n");
|
|
12
|
+
}
|
|
13
|
+
function buildQuotedMarkdownText(text) {
|
|
14
|
+
return text
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((line) => line.length > 0 ? `> ${escapePlainTextForTelegramMarkdownV2(line)}` : ">")
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
export function buildExternalUserInputNotification(text) {
|
|
20
|
+
const normalizedText = normalizeExternalUserInputText(text);
|
|
21
|
+
if (!normalizedText) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const title = `👤 ${t("bot.external_user_input")}`;
|
|
25
|
+
return {
|
|
26
|
+
text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(normalizedText)}`,
|
|
27
|
+
rawFallbackText: `${title}\n\n${buildQuotedPlainText(normalizedText)}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
|
|
31
|
+
const notification = buildExternalUserInputNotification(text);
|
|
32
|
+
if (!notification || currentSessionId !== sessionId) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (consumeSuppressedInput(sessionId, text)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
await sendBotText({
|
|
39
|
+
api,
|
|
40
|
+
chatId,
|
|
41
|
+
text: notification.text,
|
|
42
|
+
rawFallbackText: notification.rawFallbackText,
|
|
43
|
+
format: "markdown_v2",
|
|
44
|
+
});
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -3,6 +3,7 @@ import { clearSession } from "../../session/manager.js";
|
|
|
3
3
|
import { summaryAggregator } from "../../summary/aggregator.js";
|
|
4
4
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
5
5
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
6
|
+
import { detachAttachedSession } from "../../attach/service.js";
|
|
6
7
|
import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
7
8
|
import { getStoredModel } from "../../model/manager.js";
|
|
8
9
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
@@ -23,6 +24,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
23
24
|
* @param reason short tag for `clearAllInteractionState` (e.g. "project_switched")
|
|
24
25
|
*/
|
|
25
26
|
export async function switchToProject(ctx, project, reason) {
|
|
27
|
+
detachAttachedSession(reason);
|
|
26
28
|
setCurrentProject(project);
|
|
27
29
|
clearSession();
|
|
28
30
|
summaryAggregator.clear();
|
package/dist/config.js
CHANGED
|
@@ -50,6 +50,18 @@ function getOptionalMessageFormatModeEnvVar(key, defaultValue) {
|
|
|
50
50
|
}
|
|
51
51
|
return defaultValue;
|
|
52
52
|
}
|
|
53
|
+
const VALID_TTS_PROVIDERS = ["openai", "google"];
|
|
54
|
+
function getOptionalTtsProviderEnvVar(key, defaultValue) {
|
|
55
|
+
const value = getEnvVar(key, false);
|
|
56
|
+
if (!value) {
|
|
57
|
+
return defaultValue;
|
|
58
|
+
}
|
|
59
|
+
const normalized = value.trim().toLowerCase();
|
|
60
|
+
if (VALID_TTS_PROVIDERS.includes(normalized)) {
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
return defaultValue;
|
|
64
|
+
}
|
|
53
65
|
export const config = {
|
|
54
66
|
telegram: {
|
|
55
67
|
token: getEnvVar("TELEGRAM_BOT_TOKEN"),
|
|
@@ -60,6 +72,8 @@ export const config = {
|
|
|
60
72
|
apiUrl: getEnvVar("OPENCODE_API_URL", false) || "http://localhost:4096",
|
|
61
73
|
username: getEnvVar("OPENCODE_SERVER_USERNAME", false) || "opencode",
|
|
62
74
|
password: getEnvVar("OPENCODE_SERVER_PASSWORD", false),
|
|
75
|
+
autoRestartEnabled: getOptionalBooleanEnvVar("OPENCODE_AUTO_RESTART_ENABLED", false),
|
|
76
|
+
monitorIntervalSec: getOptionalPositiveIntEnvVar("OPENCODE_MONITOR_INTERVAL_SEC", 300),
|
|
63
77
|
model: {
|
|
64
78
|
provider: getEnvVar("OPENCODE_MODEL_PROVIDER", true), // Required
|
|
65
79
|
modelId: getEnvVar("OPENCODE_MODEL_ID", true), // Required
|
|
@@ -95,10 +109,15 @@ export const config = {
|
|
|
95
109
|
language: getEnvVar("STT_LANGUAGE", false),
|
|
96
110
|
notePrompt: getEnvVar("STT_NOTE_PROMPT", false),
|
|
97
111
|
},
|
|
98
|
-
tts: {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
112
|
+
tts: (() => {
|
|
113
|
+
const provider = getOptionalTtsProviderEnvVar("TTS_PROVIDER", "openai");
|
|
114
|
+
const defaultVoice = provider === "google" ? "en-US-Studio-O" : "alloy";
|
|
115
|
+
return {
|
|
116
|
+
apiUrl: getEnvVar("TTS_API_URL", false),
|
|
117
|
+
apiKey: getEnvVar("TTS_API_KEY", false),
|
|
118
|
+
provider,
|
|
119
|
+
model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
|
|
120
|
+
voice: getEnvVar("TTS_VOICE", false) || defaultVoice,
|
|
121
|
+
};
|
|
122
|
+
})(),
|
|
104
123
|
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const SUPPRESSION_TTL_MS = 60_000;
|
|
2
|
+
function normalizeExternalUserInputText(text) {
|
|
3
|
+
return text.replace(/\r\n/g, "\n").trim();
|
|
4
|
+
}
|
|
5
|
+
class ExternalUserInputSuppressionManager {
|
|
6
|
+
entriesBySession = new Map();
|
|
7
|
+
register(sessionId, text, now = Date.now()) {
|
|
8
|
+
const normalizedText = normalizeExternalUserInputText(text);
|
|
9
|
+
if (!sessionId || !normalizedText) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
this.prune(now);
|
|
13
|
+
const sessionEntries = this.entriesBySession.get(sessionId) ?? [];
|
|
14
|
+
sessionEntries.push({ text: normalizedText, createdAt: now });
|
|
15
|
+
this.entriesBySession.set(sessionId, sessionEntries);
|
|
16
|
+
}
|
|
17
|
+
consume(sessionId, text, now = Date.now()) {
|
|
18
|
+
const normalizedText = normalizeExternalUserInputText(text);
|
|
19
|
+
if (!sessionId || !normalizedText) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
this.prune(now);
|
|
23
|
+
const sessionEntries = this.entriesBySession.get(sessionId);
|
|
24
|
+
if (!sessionEntries?.length) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const entryIndex = sessionEntries.findIndex((entry) => entry.text === normalizedText);
|
|
28
|
+
if (entryIndex < 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
sessionEntries.splice(entryIndex, 1);
|
|
32
|
+
if (sessionEntries.length === 0) {
|
|
33
|
+
this.entriesBySession.delete(sessionId);
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
clearAll() {
|
|
38
|
+
this.entriesBySession.clear();
|
|
39
|
+
}
|
|
40
|
+
__resetForTests() {
|
|
41
|
+
this.clearAll();
|
|
42
|
+
}
|
|
43
|
+
prune(now) {
|
|
44
|
+
for (const [sessionId, sessionEntries] of this.entriesBySession.entries()) {
|
|
45
|
+
const activeEntries = sessionEntries.filter((entry) => now - entry.createdAt <= SUPPRESSION_TTL_MS);
|
|
46
|
+
if (activeEntries.length === 0) {
|
|
47
|
+
this.entriesBySession.delete(sessionId);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
this.entriesBySession.set(sessionId, activeEntries);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const externalUserInputSuppressionManager = new ExternalUserInputSuppressionManager();
|
package/dist/i18n/de.js
CHANGED
|
@@ -10,6 +10,7 @@ export const de = {
|
|
|
10
10
|
"cmd.description.tasklist": "Geplante Aufgaben anzeigen",
|
|
11
11
|
"cmd.description.commands": "Benutzerdefinierte Befehle",
|
|
12
12
|
"cmd.description.skills": "Skill-Katalog",
|
|
13
|
+
"cmd.description.mcps": "MCP servers",
|
|
13
14
|
"cmd.description.opencode_start": "OpenCode-Server starten",
|
|
14
15
|
"cmd.description.opencode_stop": "OpenCode-Server stoppen",
|
|
15
16
|
"cmd.description.help": "Hilfe",
|
|
@@ -48,6 +49,7 @@ export const de = {
|
|
|
48
49
|
"bot.prompt_send_error": "Anfrage konnte nicht an OpenCode gesendet werden.",
|
|
49
50
|
"bot.session_error": "🔴 OpenCode meldete einen Fehler: {message}",
|
|
50
51
|
"bot.session_retry": "🔁 {message}\n\nDer Provider liefert bei wiederholten Versuchen immer wieder denselben Fehler. Mit /abort abbrechen.",
|
|
52
|
+
"bot.external_user_input": "Externe Benutzereingabe",
|
|
51
53
|
"bot.unknown_command": "⚠️ Unbekannter Befehl: {command}. Nutze /help, um verfügbare Befehle zu sehen.",
|
|
52
54
|
"bot.photo_downloading": "⏳ Lade Foto herunter...",
|
|
53
55
|
"bot.photo_too_large": "⚠️ Foto ist zu groß (max. {maxSizeMb}MB)",
|
|
@@ -113,6 +115,17 @@ export const de = {
|
|
|
113
115
|
"sessions.preview.title": "Letzte Nachrichten:",
|
|
114
116
|
"sessions.preview.you": "Du:",
|
|
115
117
|
"sessions.preview.agent": "Agent:",
|
|
118
|
+
"attach.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
|
|
119
|
+
"attach.session_not_selected": "💬 Keine Sitzung ausgewählt.\n\nWähle zuerst mit /sessions eine Sitzung aus.",
|
|
120
|
+
"attach.session_project_mismatch": "⚠️ Die ausgewählte Sitzung passt nicht zum aktuellen Projekt. Wähle sie über /sessions erneut aus.",
|
|
121
|
+
"attach.connected": "✅ Mit Sitzung verbunden: {title}",
|
|
122
|
+
"attach.already_connected": "ℹ️ Bereits mit Sitzung verbunden: {title}",
|
|
123
|
+
"attach.status.idle_message": "Status: idle. Warte auf neue Ereignisse.",
|
|
124
|
+
"attach.status.busy_message": "Status: busy. Neue Prompts sind vorübergehend blockiert.",
|
|
125
|
+
"attach.restored_question": "Eine ausstehende Frage für diese Sitzung wurde wiederhergestellt.",
|
|
126
|
+
"attach.restored_permissions": "Ausstehende Berechtigungsanfragen wiederhergestellt: {count}.",
|
|
127
|
+
"attach.disconnect_hint": "Zum Trennen einfach zu einer anderen Sitzung oder einem anderen Projekt wechseln.",
|
|
128
|
+
"attach.error": "🔴 Verbindung mit der aktuellen Sitzung fehlgeschlagen.",
|
|
116
129
|
"new.project_not_selected": "🏗 Projekt ist nicht ausgewählt.\n\nWähle zuerst ein Projekt mit /projects.",
|
|
117
130
|
"new.created": "✅ Neue Sitzung erstellt: {title}",
|
|
118
131
|
"new.create_error": "🔴 OpenCode-Server ist nicht verfügbar oder beim Erstellen der Sitzung ist ein Fehler aufgetreten.",
|
|
@@ -232,6 +245,9 @@ export const de = {
|
|
|
232
245
|
"pinned.line.project": "Projekt: {project}",
|
|
233
246
|
"pinned.line.worktree": "Worktree: {worktree}",
|
|
234
247
|
"pinned.line.model": "Modell: {model}",
|
|
248
|
+
"pinned.line.attach": "Tracking: {status}",
|
|
249
|
+
"pinned.attach.status.idle": "aktiv, idle",
|
|
250
|
+
"pinned.attach.status.busy": "aktiv, busy",
|
|
235
251
|
"pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
|
|
236
252
|
"pinned.line.cost": "Kosten: {cost} ausgegeben",
|
|
237
253
|
"subagent.header": "Subagent {agent}: {description}",
|
|
@@ -349,6 +365,24 @@ export const de = {
|
|
|
349
365
|
"skills.button.next_page": "Weiter ➡️",
|
|
350
366
|
"skills.page_empty_callback": "Keine Skills auf dieser Seite",
|
|
351
367
|
"skills.page_load_error_callback": "Diese Seite konnte nicht geladen werden. Bitte versuche es erneut.",
|
|
368
|
+
"mcps.select": "MCP servers:",
|
|
369
|
+
"mcps.empty": "📭 No MCP servers configured.",
|
|
370
|
+
"mcps.fetch_error": "🔴 Failed to load MCP servers.",
|
|
371
|
+
"mcps.toggle_error": "🔴 Failed to toggle MCP server.",
|
|
372
|
+
"mcps.enabling": "Enabling...",
|
|
373
|
+
"mcps.disabling": "Disabling...",
|
|
374
|
+
"mcps.status.connected": "🟢 Connected",
|
|
375
|
+
"mcps.status.disabled": "🔴 Disabled",
|
|
376
|
+
"mcps.status.failed": "⚠️ Failed",
|
|
377
|
+
"mcps.status.needs_auth": "🔒 Needs auth",
|
|
378
|
+
"mcps.status.needs_client_registration": "🔒 Needs registration",
|
|
379
|
+
"mcps.detail.title": "Server: {name}",
|
|
380
|
+
"mcps.detail.status": "Status: {status}",
|
|
381
|
+
"mcps.detail.error": "Error: {error}",
|
|
382
|
+
"mcps.button.enable": "🟢 Enable",
|
|
383
|
+
"mcps.button.disable": "🔴 Disable",
|
|
384
|
+
"mcps.button.back": "⬅️ Back",
|
|
385
|
+
"mcps.auth_required": "This server requires authorization and cannot be enabled from the bot.",
|
|
352
386
|
"cmd.description.rename": "Aktuelle Sitzung umbenennen",
|
|
353
387
|
"legacy.models.fetch_error": "🔴 Modellliste konnte nicht geladen werden. Prüfe den Serverstatus mit /status.",
|
|
354
388
|
"legacy.models.empty": "📋 Keine verfügbaren Modelle. Konfiguriere Provider in OpenCode.",
|