@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.
Files changed (39) hide show
  1. package/.env.example +22 -1
  2. package/README.md +35 -6
  3. package/dist/app/start-bot-app.js +4 -0
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +171 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +1 -0
  9. package/dist/bot/commands/mcps.js +394 -0
  10. package/dist/bot/commands/new.js +10 -18
  11. package/dist/bot/commands/opencode-start.js +2 -10
  12. package/dist/bot/commands/sessions.js +19 -20
  13. package/dist/bot/commands/start.js +2 -0
  14. package/dist/bot/handlers/prompt.js +26 -31
  15. package/dist/bot/index.js +82 -10
  16. package/dist/bot/middleware/interaction-guard.js +1 -1
  17. package/dist/bot/utils/busy-guard.js +3 -2
  18. package/dist/bot/utils/external-user-input.js +46 -0
  19. package/dist/bot/utils/switch-project.js +2 -0
  20. package/dist/config.js +25 -6
  21. package/dist/external-input/suppression.js +54 -0
  22. package/dist/i18n/de.js +34 -0
  23. package/dist/i18n/en.js +34 -0
  24. package/dist/i18n/es.js +34 -0
  25. package/dist/i18n/fr.js +34 -0
  26. package/dist/i18n/ru.js +34 -0
  27. package/dist/i18n/zh.js +34 -0
  28. package/dist/interaction/guard.js +2 -1
  29. package/dist/opencode/auto-restart.js +92 -0
  30. package/dist/opencode/events.js +13 -1
  31. package/dist/opencode/process.js +18 -1
  32. package/dist/pinned/manager.js +39 -0
  33. package/dist/summary/aggregator.js +89 -9
  34. package/dist/summary/formatter.js +2 -2
  35. package/dist/summary/markdown-to-telegram-v2.js +99 -0
  36. package/dist/summary/subagent-formatter.js +23 -2
  37. package/dist/telegram/render/inline-renderer.js +18 -0
  38. package/dist/tts/client.js +94 -19
  39. 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
- // Create pinned message for new session
130
- try {
131
- await pinnedMessageManager.onSessionChange(session.id, session.title);
132
- }
133
- catch (err) {
134
- logger.error("[Bot] Error creating pinned message for new session:", err);
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: session.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
- await Promise.all([
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
- 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}`);
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("interaction.blocked.finish_current")
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("interaction.blocked.finish_current");
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
- apiUrl: getEnvVar("TTS_API_URL", false),
100
- apiKey: getEnvVar("TTS_API_KEY", false),
101
- model: getEnvVar("TTS_MODEL", false) || "gpt-4o-mini-tts",
102
- voice: getEnvVar("TTS_VOICE", false) || "alloy",
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.",