@grinev/opencode-telegram-bot 0.16.1 → 0.18.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 +5 -0
- package/README.md +72 -43
- package/dist/app/start-bot-app.js +0 -2
- package/dist/attach/manager.js +66 -0
- package/dist/attach/service.js +166 -0
- package/dist/bot/commands/abort.js +0 -4
- package/dist/bot/commands/commands.js +13 -3
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/new.js +10 -18
- package/dist/bot/commands/opencode-start.js +27 -24
- package/dist/bot/commands/opencode-stop.js +51 -23
- package/dist/bot/commands/projects.js +30 -6
- package/dist/bot/commands/sessions.js +19 -20
- package/dist/bot/commands/skills.js +376 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/status.js +21 -14
- package/dist/bot/commands/worktree.js +196 -0
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/handlers/prompt.js +26 -31
- package/dist/bot/handlers/voice.js +8 -1
- package/dist/bot/index.js +90 -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 +2 -0
- package/dist/external-input/suppression.js +54 -0
- package/dist/git/worktree.js +158 -0
- package/dist/i18n/de.js +54 -1
- package/dist/i18n/en.js +54 -1
- package/dist/i18n/es.js +54 -1
- package/dist/i18n/fr.js +54 -1
- package/dist/i18n/ru.js +54 -1
- package/dist/i18n/zh.js +54 -1
- package/dist/interaction/guard.js +2 -1
- package/dist/opencode/events.js +13 -1
- package/dist/opencode/process.js +182 -0
- package/dist/pinned/manager.js +46 -61
- package/dist/project/manager.js +47 -32
- package/dist/runtime/bootstrap.js +119 -7
- package/dist/scheduled-task/executor.js +137 -7
- package/dist/settings/manager.js +9 -12
- package/dist/summary/aggregator.js +54 -9
- package/package.json +1 -1
- package/dist/process/manager.js +0 -273
- package/dist/process/types.js +0 -1
|
@@ -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);
|
|
@@ -156,8 +156,15 @@ export async function handleVoiceMessage(ctx, deps) {
|
|
|
156
156
|
logger.warn("[Voice] Failed to edit status message with recognized text:", editError);
|
|
157
157
|
}
|
|
158
158
|
logger.info(`[Voice] Transcribed audio: ${recognizedText.length} chars`);
|
|
159
|
+
let textForLLM = recognizedText;
|
|
160
|
+
const notePrompt = config.stt.notePrompt.trim();
|
|
161
|
+
if (notePrompt && notePrompt.toLowerCase() !== "false" && notePrompt !== "0") {
|
|
162
|
+
const llmNote = `[Note: ${notePrompt}]`;
|
|
163
|
+
logger.debug(`[Voice] Added STT note to LLM prompt: ${llmNote}`);
|
|
164
|
+
textForLLM = `${llmNote}\n${recognizedText}`;
|
|
165
|
+
}
|
|
159
166
|
// Process the recognized text as a prompt
|
|
160
|
-
await processPrompt(ctx,
|
|
167
|
+
await processPrompt(ctx, textForLLM, deps);
|
|
161
168
|
}
|
|
162
169
|
catch (err) {
|
|
163
170
|
const errorMessage = err instanceof Error ? err.message : "unknown error";
|
package/dist/bot/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTT
|
|
|
16
16
|
import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
|
|
17
17
|
import { newCommand } from "./commands/new.js";
|
|
18
18
|
import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
|
|
19
|
+
import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
|
|
19
20
|
import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
|
|
20
21
|
import { abortCommand } from "./commands/abort.js";
|
|
21
22
|
import { opencodeStartCommand } from "./commands/opencode-start.js";
|
|
@@ -24,6 +25,7 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
|
|
|
24
25
|
import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
|
|
25
26
|
import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
|
|
26
27
|
import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
|
|
28
|
+
import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
|
|
27
29
|
import { ttsCommand } from "./commands/tts.js";
|
|
28
30
|
import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
|
|
29
31
|
import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
|
|
@@ -64,7 +66,11 @@ import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
|
64
66
|
import { assistantRunState } from "./assistant-run-state.js";
|
|
65
67
|
import { ResponseStreamer } from "./streaming/response-streamer.js";
|
|
66
68
|
import { ToolCallStreamer } from "./streaming/tool-call-streamer.js";
|
|
69
|
+
import { attachManager } from "../attach/manager.js";
|
|
70
|
+
import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurrentSession, } from "../attach/service.js";
|
|
71
|
+
import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
|
|
67
72
|
import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
|
|
73
|
+
import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
|
|
68
74
|
let botInstance = null;
|
|
69
75
|
let chatIdInstance = null;
|
|
70
76
|
let commandsInitialized = false;
|
|
@@ -261,6 +267,27 @@ function getToolStreamKey(tool) {
|
|
|
261
267
|
}
|
|
262
268
|
return "default";
|
|
263
269
|
}
|
|
270
|
+
function getEventSessionId(event) {
|
|
271
|
+
const properties = event.properties;
|
|
272
|
+
return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
|
|
273
|
+
}
|
|
274
|
+
function shouldMarkAttachedBusyFromEvent(event) {
|
|
275
|
+
switch (event.type) {
|
|
276
|
+
case "session.status":
|
|
277
|
+
return event.properties.status?.type === "busy";
|
|
278
|
+
case "message.updated": {
|
|
279
|
+
const info = event.properties.info;
|
|
280
|
+
return info?.role === "assistant" && !info.time?.completed;
|
|
281
|
+
}
|
|
282
|
+
case "message.part.updated":
|
|
283
|
+
case "message.part.delta":
|
|
284
|
+
case "question.asked":
|
|
285
|
+
case "permission.asked":
|
|
286
|
+
return true;
|
|
287
|
+
default:
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
264
291
|
async function ensureCommandsInitialized(ctx, next) {
|
|
265
292
|
if (commandsInitialized || !ctx.from || ctx.from.id !== config.telegram.allowedUserId) {
|
|
266
293
|
await next();
|
|
@@ -386,6 +413,26 @@ async function ensureEventSubscription(directory) {
|
|
|
386
413
|
}
|
|
387
414
|
});
|
|
388
415
|
});
|
|
416
|
+
summaryAggregator.setOnExternalUserInput(async (sessionId, _messageId, messageText) => {
|
|
417
|
+
void enqueueSessionCompletionTask(sessionId, async () => {
|
|
418
|
+
if (!botInstance || !chatIdInstance) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
await deliverExternalUserInputNotification({
|
|
423
|
+
api: botInstance.api,
|
|
424
|
+
chatId: chatIdInstance,
|
|
425
|
+
currentSessionId: getCurrentSession()?.id ?? null,
|
|
426
|
+
sessionId,
|
|
427
|
+
text: messageText,
|
|
428
|
+
consumeSuppressedInput: (incomingSessionId, incomingText) => externalUserInputSuppressionManager.consume(incomingSessionId, incomingText),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
logger.error("[Bot] Failed to deliver external user input to Telegram:", err);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
});
|
|
389
436
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
390
437
|
if (!botInstance || !chatIdInstance) {
|
|
391
438
|
logger.error("Bot or chat ID not available for sending tool notification");
|
|
@@ -459,18 +506,19 @@ async function ensureEventSubscription(directory) {
|
|
|
459
506
|
logger.error("Failed to send file to Telegram:", err);
|
|
460
507
|
}
|
|
461
508
|
});
|
|
462
|
-
summaryAggregator.setOnQuestion(async (questions, requestID) => {
|
|
509
|
+
summaryAggregator.setOnQuestion(async (questions, requestID, sessionId) => {
|
|
463
510
|
if (!botInstance || !chatIdInstance) {
|
|
464
511
|
logger.error("Bot or chat ID not available for showing questions");
|
|
465
512
|
return;
|
|
466
513
|
}
|
|
467
514
|
const currentSession = getCurrentSession();
|
|
468
|
-
if (currentSession) {
|
|
469
|
-
|
|
470
|
-
toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
|
|
471
|
-
toolCallStreamer.flushSession(currentSession.id, "question_asked"),
|
|
472
|
-
]);
|
|
515
|
+
if (!currentSession || currentSession.id !== sessionId) {
|
|
516
|
+
return;
|
|
473
517
|
}
|
|
518
|
+
await Promise.all([
|
|
519
|
+
toolMessageBatcher.flushSession(currentSession.id, "question_asked"),
|
|
520
|
+
toolCallStreamer.flushSession(currentSession.id, "question_asked"),
|
|
521
|
+
]);
|
|
474
522
|
if (questionManager.isActive()) {
|
|
475
523
|
logger.warn("[Bot] Replacing active poll with a new one");
|
|
476
524
|
const previousMessageIds = questionManager.getMessageIds();
|
|
@@ -501,6 +549,10 @@ async function ensureEventSubscription(directory) {
|
|
|
501
549
|
logger.error("Bot or chat ID not available for showing permission request");
|
|
502
550
|
return;
|
|
503
551
|
}
|
|
552
|
+
const currentSession = getCurrentSession();
|
|
553
|
+
if (!currentSession || currentSession.id !== request.sessionID) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
504
556
|
await Promise.all([
|
|
505
557
|
toolMessageBatcher.flushSession(request.sessionID, "permission_asked"),
|
|
506
558
|
toolCallStreamer.flushSession(request.sessionID, "permission_asked"),
|
|
@@ -581,6 +633,7 @@ async function ensureEventSubscription(directory) {
|
|
|
581
633
|
}
|
|
582
634
|
});
|
|
583
635
|
summaryAggregator.setOnSessionIdle(async (sessionId) => {
|
|
636
|
+
await markAttachedSessionIdle(sessionId);
|
|
584
637
|
await sessionCompletionTasks.get(sessionId)?.catch(() => undefined);
|
|
585
638
|
const completedRun = assistantRunState.finishRun(sessionId, "session_idle");
|
|
586
639
|
clearPromptResponseMode(sessionId);
|
|
@@ -625,6 +678,7 @@ async function ensureEventSubscription(directory) {
|
|
|
625
678
|
}
|
|
626
679
|
});
|
|
627
680
|
summaryAggregator.setOnSessionError(async (sessionId, message) => {
|
|
681
|
+
await markAttachedSessionIdle(sessionId);
|
|
628
682
|
if (!botInstance || !chatIdInstance) {
|
|
629
683
|
clearPromptResponseMode(sessionId);
|
|
630
684
|
assistantRunState.clearRun(sessionId, "session_error_no_bot_context");
|
|
@@ -704,6 +758,11 @@ async function ensureEventSubscription(directory) {
|
|
|
704
758
|
});
|
|
705
759
|
logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
|
|
706
760
|
subscribeToEvents(directory, (event) => {
|
|
761
|
+
const attached = attachManager.getSnapshot();
|
|
762
|
+
const eventSessionId = getEventSessionId(event);
|
|
763
|
+
if (attached && eventSessionId === attached.sessionId && shouldMarkAttachedBusyFromEvent(event)) {
|
|
764
|
+
void markAttachedSessionBusy(attached.sessionId);
|
|
765
|
+
}
|
|
707
766
|
if (event.type === "session.created" || event.type === "session.updated") {
|
|
708
767
|
const info = event.properties.info;
|
|
709
768
|
if (info?.directory) {
|
|
@@ -721,6 +780,7 @@ async function ensureEventSubscription(directory) {
|
|
|
721
780
|
export function createBot() {
|
|
722
781
|
clearAllInteractionState("bot_startup");
|
|
723
782
|
sessionCompletionTasks.clear();
|
|
783
|
+
attachManager.clear("bot_startup");
|
|
724
784
|
assistantRunState.clearAll("bot_startup");
|
|
725
785
|
if (heartbeatTimer) {
|
|
726
786
|
clearInterval(heartbeatTimer);
|
|
@@ -746,6 +806,8 @@ export function createBot() {
|
|
|
746
806
|
};
|
|
747
807
|
}
|
|
748
808
|
const bot = new Bot(config.telegram.token, botOptions);
|
|
809
|
+
botInstance = bot;
|
|
810
|
+
chatIdInstance = config.telegram.allowedUserId;
|
|
749
811
|
// Heartbeat for diagnostics: verify the event loop is not blocked
|
|
750
812
|
let heartbeatCounter = 0;
|
|
751
813
|
heartbeatTimer = setInterval(() => {
|
|
@@ -801,14 +863,16 @@ export function createBot() {
|
|
|
801
863
|
bot.command("opencode_start", opencodeStartCommand);
|
|
802
864
|
bot.command("opencode_stop", opencodeStopCommand);
|
|
803
865
|
bot.command("projects", projectsCommand);
|
|
866
|
+
bot.command("worktree", worktreeCommand);
|
|
804
867
|
bot.command("open", openCommand);
|
|
805
868
|
bot.command("sessions", sessionsCommand);
|
|
806
|
-
bot.command("new", newCommand);
|
|
869
|
+
bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
|
|
807
870
|
bot.command("abort", abortCommand);
|
|
808
871
|
bot.command("task", taskCommand);
|
|
809
872
|
bot.command("tasklist", taskListCommand);
|
|
810
873
|
bot.command("rename", renameCommand);
|
|
811
874
|
bot.command("commands", commandsCommand);
|
|
875
|
+
bot.command("skills", skillsCommand);
|
|
812
876
|
bot.on("message:text", unknownCommandMiddleware);
|
|
813
877
|
bot.on("callback_query:data", async (ctx) => {
|
|
814
878
|
logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
|
|
@@ -823,8 +887,9 @@ export function createBot() {
|
|
|
823
887
|
// Clean up path index when the open-directory menu is cancelled
|
|
824
888
|
clearOpenPathIndex();
|
|
825
889
|
}
|
|
826
|
-
const handledSession = await handleSessionSelect(ctx);
|
|
890
|
+
const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
|
|
827
891
|
const handledProject = await handleProjectSelect(ctx);
|
|
892
|
+
const handledWorktree = await handleWorktreeCallback(ctx);
|
|
828
893
|
const handledOpen = await handleOpenCallback(ctx);
|
|
829
894
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
830
895
|
const handledPermission = await handlePermissionCallback(ctx);
|
|
@@ -836,10 +901,12 @@ export function createBot() {
|
|
|
836
901
|
const handledTaskList = await handleTaskListCallback(ctx);
|
|
837
902
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
838
903
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
839
|
-
|
|
904
|
+
const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
|
|
905
|
+
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}`);
|
|
840
906
|
if (!handledInlineCancel &&
|
|
841
907
|
!handledSession &&
|
|
842
908
|
!handledProject &&
|
|
909
|
+
!handledWorktree &&
|
|
843
910
|
!handledOpen &&
|
|
844
911
|
!handledQuestion &&
|
|
845
912
|
!handledPermission &&
|
|
@@ -850,7 +917,8 @@ export function createBot() {
|
|
|
850
917
|
!handledTask &&
|
|
851
918
|
!handledTaskList &&
|
|
852
919
|
!handledRenameCancel &&
|
|
853
|
-
!handledCommands
|
|
920
|
+
!handledCommands &&
|
|
921
|
+
!handledSkills) {
|
|
854
922
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
855
923
|
await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
|
|
856
924
|
}
|
|
@@ -952,6 +1020,14 @@ export function createBot() {
|
|
|
952
1020
|
});
|
|
953
1021
|
// Voice and audio message handlers (STT transcription -> prompt)
|
|
954
1022
|
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
1023
|
+
safeBackgroundTask({
|
|
1024
|
+
taskName: "bot.restoreFollowedSession",
|
|
1025
|
+
task: () => restoreAttachedCurrentSession({
|
|
1026
|
+
bot,
|
|
1027
|
+
chatId: config.telegram.allowedUserId,
|
|
1028
|
+
ensureEventSubscription,
|
|
1029
|
+
}),
|
|
1030
|
+
});
|
|
955
1031
|
bot.on("message:voice", async (ctx) => {
|
|
956
1032
|
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
957
1033
|
botInstance = bot;
|
|
@@ -1049,6 +1125,10 @@ export function createBot() {
|
|
|
1049
1125
|
if (handledCommandArgs) {
|
|
1050
1126
|
return;
|
|
1051
1127
|
}
|
|
1128
|
+
const handledSkillArgs = await handleSkillTextArguments(ctx, promptDeps);
|
|
1129
|
+
if (handledSkillArgs) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1052
1132
|
await processUserPrompt(ctx, text, promptDeps);
|
|
1053
1133
|
logger.debug("[Bot] message:text handler completed (prompt sent in background)");
|
|
1054
1134
|
});
|
|
@@ -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
|
@@ -73,6 +73,7 @@ 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
|
+
scheduledTaskExecutionTimeoutMinutes: getOptionalPositiveIntEnvVar("SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES", 120),
|
|
76
77
|
responseStreamThrottleMs: getOptionalPositiveIntEnvVar("RESPONSE_STREAM_THROTTLE_MS", 500),
|
|
77
78
|
bashToolDisplayMaxLength: getOptionalPositiveIntEnvVar("BASH_TOOL_DISPLAY_MAX_LENGTH", 128),
|
|
78
79
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
@@ -92,6 +93,7 @@ export const config = {
|
|
|
92
93
|
apiKey: getEnvVar("STT_API_KEY", false),
|
|
93
94
|
model: getEnvVar("STT_MODEL", false) || "whisper-large-v3-turbo",
|
|
94
95
|
language: getEnvVar("STT_LANGUAGE", false),
|
|
96
|
+
notePrompt: getEnvVar("STT_NOTE_PROMPT", false),
|
|
95
97
|
},
|
|
96
98
|
tts: {
|
|
97
99
|
apiUrl: getEnvVar("TTS_API_URL", false),
|
|
@@ -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();
|